ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Canvas正弦波实现DNA双螺旋动画:三角函数与性能优化实战

Canvas正弦波实现DNA双螺旋动画:三角函数与性能优化实战

1. 项目缘起与核心思路拆解

1.1 为什么选DNA螺旋做前端效果练习

做前端效果练习,最容易陷入的误区就是“什么火做什么”——今天跟风做个毛玻璃卡片,明天抄个粒子爆炸按钮,做完就忘,技术点散落一地,形不成体系。我给自己定的这个“100天挑战100个前端效果”计划,核心目的不是攒作品集,而是用100个不同维度的视觉案例,把CSS和Canvas里那些零散的知识点串成一张网。

第五天选DNA螺旋结构,不是随手翻到的。前四天我分别做了渐变流光按钮、卡片3D翻转、粒子文字消散和SVG路径描边动画,基本覆盖了CSS过渡、3D变换、Canvas基础绘制和SVG动画。到第五天,我需要一个能同时练到三角函数计算、循环结构、动态样式生成、透视投影的案例。DNA螺旋恰好全中。

这个效果本质上是什么?一句话概括:用一排水平排列的圆点,通过正弦函数控制每个点的垂直偏移和缩放,再叠加一个旋转角度,形成双螺旋的视觉错觉。听起来简单,但里面藏着好几个容易翻车的细节,后面会一个个拆。

适合谁来参考?如果你已经能写基本的HTML结构和CSS样式,对JavaScript的数组遍历、Math对象不陌生,但一遇到“用代码画图形”就不知道从哪下手,那这个案例非常适合你。它不需要任何第三方库,纯原生实现,代码量控制在150行以内,但视觉效果足够唬人。

1.2 双螺旋的数学本质:两个相位差180度的正弦波

很多人第一次看到DNA螺旋效果,第一反应是“这得用3D引擎吧”。其实完全不用。你盯着屏幕看,它确实像一条螺旋上升的链子,但拆开来看,它只是二维平面上两个正弦曲线的叠加。

具体来说,假设我们有N个节点,从左到右水平排列。每个节点的水平位置是固定的,垂直位置由正弦函数决定:

y = amplitude * sin(frequency * x + phase)

其中phase是相位,控制波形左右移动。如果我们画两条曲线,第二条的相位比第一条多π(也就是180度),就会得到两条上下对称、交错排列的波形。这就是DNA双螺旋的骨架。

但光有两条正弦波还不够,那样看起来像两条蛇。DNA的视觉特征是每个节点是一个小球,球的大小随位置变化,而且球与球之间有连接线。所以还需要:

  • 每个节点的半径用余弦函数控制,形成“粗-细-粗”的节奏感
  • 相邻节点之间画一条线,线的透明度随距离变化
  • 整体加一个缓慢的相位偏移,让波形“流动”起来

这里的关键参数是频率和振幅的比例。频率太高,波形太密,看起来像弹簧;频率太低,波形太平缓,看起来像波浪。我试了七八组参数,最后定在频率0.3、振幅60px左右,在800px宽的容器里刚好呈现3到4个完整波形,视觉上最接近真实DNA的螺旋密度。

1.3 技术选型:为什么用Canvas而不是纯CSS

实现这个效果有两条路:纯CSS用transform: rotateY()做3D旋转,或者用Canvas逐帧绘制。我选了Canvas,原因有三个。

第一,节点数量多。我最终用了60个节点,每个节点要独立控制位置、大小、颜色、透明度。如果用CSS,意味着要生成60个div,每个div写独立的style,DOM操作开销大,而且动画过程中频繁改样式会触发大量重排重绘。Canvas是一张画布,所有绘制都在一个元素里完成,性能好得多。

第二,连接线的绘制。DNA螺旋的节点之间需要画线,而且线的透明度要动态变化。CSS画线只能用伪元素或者border,控制起来非常别扭。Canvas的lineTo和stroke天然适合这种需求。

第三,数学计算的灵活性。正弦函数、余弦函数、相位偏移,这些在Canvas的绘制循环里就是几行代码的事。用CSS的话,你得把计算结果转成translate和scale的值,再拼成字符串塞进style,中间多了一层转换,调试起来麻烦。

当然,Canvas也不是没有代价。它需要你手动处理设备像素比,否则在高分屏上会模糊。这个坑我后面会详细说。

2. 核心细节解析与实操要点

2.1 节点布局:水平等距排列的数学计算

先确定画布尺寸。我设成800x400,留出足够的垂直空间给振幅。水平方向,60个节点从x=50排到x=750,间距是(750-50)/(60-1) ≈ 11.86px。这个间距很关键:太密了节点会重叠,太疏了连接线会拉得很长,看起来不像螺旋。

计算间距的公式:

const startX = 50; const endX = 750; const nodeCount = 60; const gap = (endX - startX) / (nodeCount - 1);

每个节点的x坐标就是startX + i * gap。这个计算很简单,但有一个细节:节点数量必须是偶数。因为双螺旋是两条链,每条链的节点数要相等,否则最后会多出一个“孤儿”节点,视觉上不对称。60是偶数,没问题。

垂直方向,每个节点的y坐标由正弦函数决定:

const amplitude = 60; const frequency = 0.3; const phase = time * 0.02; // time是动画时间戳 const y1 = centerY + amplitude * Math.sin(frequency * i + phase); const y2 = centerY + amplitude * Math.sin(frequency * i + phase + Math.PI);

这里centerY是画布垂直中心,400/2=200。i是节点索引,从0到59。phase随时间递增,让波形流动起来。Math.PI就是180度相位差,保证两条链上下对称。

注意:频率参数frequency不要直接乘以x坐标,而是乘以索引i。因为x坐标的间距是固定的,乘以x和乘以i的效果一样,但乘以i更直观,调整频率时不用考虑间距的影响。

2.2 半径变化:用余弦函数制造“粗细节奏”

如果所有节点半径一样,看起来就是一串珠子,没有DNA那种“大球小球交替”的感觉。真实DNA的碱基对在螺旋上的投影,视觉上就是大小交替的。

我用余弦函数控制半径:

const baseRadius = 6; const radiusVariation = 4; const r1 = baseRadius + radiusVariation * Math.cos(frequency * i + phase); const r2 = baseRadius + radiusVariation * Math.cos(frequency * i + phase + Math.PI);

这样半径在2到10之间变化。注意余弦和正弦的相位关系:当正弦值最大(节点在最高点)时,余弦值最小(半径最小)。这正好符合透视规律——离观察者远的节点看起来小,离观察者近的节点看起来大。虽然我们做的是2D投影,但加上这个半径变化,立体感会强很多。

实测下来,baseRadius=6、radiusVariation=4这组参数在800x400的画布上最协调。半径太小,节点像芝麻;半径太大,节点会挤在一起,连接线都看不清。

2.3 连接线的绘制策略:只连相邻节点

DNA螺旋的视觉特征之一是两条链之间有“横档”连接。但如果你把每个节点和对面链的所有节点都连起来,画面会乱成一团。正确的做法是:只连接相邻的节点,而且只连接同一条链上的相邻节点,以及对面链上对应位置的节点。

具体来说,对于第i个节点:

  • 连接同链的第i个和第i+1个节点(如果i+1存在)
  • 连接第i个节点和对面链的第i个节点(横档)

横档的透明度要随距离变化。当两个节点在垂直方向离得近时,横档明显;离得远时,横档淡出。这样看起来就像螺旋的“正面”和“背面”有透视差异。

// 同链连接 ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 + gap, y1_next); ctx.strokeStyle = `rgba(100, 200, 255, ${alpha})`; ctx.stroke(); // 横档连接 ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.strokeStyle = `rgba(255, 255, 255, ${crossAlpha})`; ctx.stroke();

alpha和crossAlpha的计算后面会细说。

2.4 颜色方案:冷色调渐变营造科技感

颜色我选了蓝紫渐变。第一条链用#4facfe到#00f2fe,第二条链用#a18cd1到#fbc2eb。每个节点的颜色根据索引在渐变中插值。

插值公式:

function lerpColor(color1, color2, t) { const r1 = parseInt(color1.slice(1, 3), 16); const g1 = parseInt(color1.slice(3, 5), 16); const b1 = parseInt(color1.slice(5, 7), 16); const r2 = parseInt(color2.slice(1, 3), 16); const g2 = parseInt(color2.slice(3, 5), 16); const b2 = parseInt(color2.slice(5, 7), 16); const r = Math.round(r1 + (r2 - r1) * t); const g = Math.round(g1 + (g2 - g1) * t); const b = Math.round(b1 + (b2 - b1) * t); return `rgb(${r}, ${g}, ${b})`; }

t是索引除以节点总数,从0到1。这样第一条链从浅蓝渐变到青色,第二条链从紫色渐变到粉色。两条链颜色不同,视觉上更容易区分。

提示:不要用纯白色做背景。纯白背景下,浅色节点的对比度不够,看起来发灰。我用的是深色背景#0a0e27,节点和线条的亮度足够,整体像夜光效果。

3. 实操过程与核心环节实现

3.1 搭建基础HTML骨架与Canvas初始化

先写HTML结构。只需要一个canvas元素和一个容器:

<div class="dna-container"> <canvas id="dnaCanvas"></canvas> </div>

CSS部分:

.dna-container { width: 100%; max-width: 800px; margin: 0 auto; background: #0a0e27; border-radius: 12px; overflow: hidden; } #dnaCanvas { display: block; width: 100%; height: 400px; }

注意display: block,否则canvas底部会多出几像素的空白,这是行内元素的基线对齐导致的。

JavaScript初始化:

const canvas = document.getElementById('dnaCanvas'); const ctx = canvas.getContext('2d'); // 处理设备像素比 const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr);

这里有个坑:getBoundingClientRect()返回的是CSS像素尺寸,而canvas的width和height属性是物理像素。如果不乘以dpr,在高分屏上canvas会被拉伸,画面模糊。ctx.scale(dpr, dpr)之后,所有绘制坐标都按CSS像素来,不用手动换算。

注意:如果容器宽度是响应式的,窗口大小变化时需要重新设置canvas尺寸。我加了一个resize监听,重新计算dpr和画布尺寸。

3.2 核心绘制循环:requestAnimationFrame驱动动画

动画用requestAnimationFrame驱动,每一帧清空画布,重新计算所有节点的位置和半径,然后绘制。

let startTime = null; function draw(timestamp) { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; const phase = elapsed * 0.002; // 控制流动速度 ctx.clearRect(0, 0, canvas.width, canvas.height); const centerY = 200; const amplitude = 60; const frequency = 0.3; const startX = 50; const gap = (750 - 50) / 59; // 先画连接线,再画节点,保证节点在线的上层 for (let i = 0; i < 60; i++) { const x = startX + i * gap; const y1 = centerY + amplitude * Math.sin(frequency * i + phase); const y2 = centerY + amplitude * Math.sin(frequency * i + phase + Math.PI); // 画同链连接线 if (i < 59) { const nextX = startX + (i + 1) * gap; const nextY1 = centerY + amplitude * Math.sin(frequency * (i + 1) + phase); const nextY2 = centerY + amplitude * Math.sin(frequency * (i + 1) + phase + Math.PI); ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(nextX, nextY1); ctx.strokeStyle = `rgba(79, 172, 254, ${0.3 + 0.3 * Math.sin(frequency * i + phase)})`; ctx.lineWidth = 1.5; ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, y2); ctx.lineTo(nextX, nextY2); ctx.strokeStyle = `rgba(161, 140, 209, ${0.3 + 0.3 * Math.sin(frequency * i + phase + Math.PI)})`; ctx.stroke(); } // 画横档 ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); const crossAlpha = 0.15 + 0.15 * Math.abs(Math.sin(frequency * i + phase)); ctx.strokeStyle = `rgba(255, 255, 255, ${crossAlpha})`; ctx.lineWidth = 1; ctx.stroke(); } // 画节点 for (let i = 0; i < 60; i++) { const x = startX + i * gap; const y1 = centerY + amplitude * Math.sin(frequency * i + phase); const y2 = centerY + amplitude * Math.sin(frequency * i + phase + Math.PI); const r1 = 6 + 4 * Math.cos(frequency * i + phase); const r2 = 6 + 4 * Math.cos(frequency * i + phase + Math.PI); // 节点1 ctx.beginPath(); ctx.arc(x, y1, Math.abs(r1), 0, Math.PI * 2); ctx.fillStyle = lerpColor('#4facfe', '#00f2fe', i / 59); ctx.fill(); // 节点2 ctx.beginPath(); ctx.arc(x, y2, Math.abs(r2), 0, Math.PI * 2); ctx.fillStyle = lerpColor('#a18cd1', '#fbc2eb', i / 59); ctx.fill(); } requestAnimationFrame(draw); } requestAnimationFrame(draw);

这段代码是整个效果的核心。我拆几个关键点说。

为什么先画线再画节点?Canvas的绘制顺序就是图层顺序。后画的覆盖先画的。如果先画节点再画线,线会穿过节点,看起来像节点被线串起来了,很丑。先画线,节点盖在线上面,视觉上节点是“浮”在线上的,更自然。

连接线的透明度怎么算?我用0.3 + 0.3 * Math.sin(...),范围在0到0.6之间。当节点在波形的高点或低点时,正弦值接近±1,透明度变化。这样线条有明暗变化,看起来有呼吸感。

横档的透明度为什么用绝对值?横档连接的是上下两个节点。当两个节点离得近时(正弦值接近0),横档应该明显;离得远时(正弦值接近±1),横档应该淡出。用Math.abs(Math.sin(...)),值在0到1之间,乘以0.15再加0.15,范围是0.15到0.3。这样横档始终可见,但强弱有变化。

半径为什么要取绝对值?余弦函数可能产生负值,arc的半径参数不能为负。取绝对值后,半径在0到10之间变化。但这样会出现半径为0的节点,看起来像消失了。实际效果中,半径为0的节点正好在波形中间,视觉上被上下节点遮挡,反而增加了层次感。

3.3 参数调优:我试过的五组配置对比

调参是这个效果最耗时间的环节。我记录了五组配置的实际表现:

配置编号振幅频率节点数半径基数视觉效果问题
A400.2405波形平缓,节点稀疏像波浪,不像螺旋
B800.4806波形密集,节点拥挤像弹簧,线条看不清
C600.3606螺旋感强,节奏舒适无明显问题
D1000.25508振幅过大,节点溢出画布上下超出边界
E500.35705波形偏密,节点偏小整体偏暗,不够醒目

最终选C。振幅60px在400px高的画布里,上下各留140px,不会溢出。频率0.3意味着每约21个节点完成一个完整波形(2π/0.3≈20.9),60个节点大约3个波形,视觉上刚好。

节点数60是个平衡点。少于40,螺旋的“链感”不够;多于80,节点间距太小,连接线几乎看不见,变成一团糊。

3.4 性能优化:从30fps到60fps的调整

第一版跑起来只有30fps左右,在低端设备上更卡。我做了三个优化。

第一,减少beginPath调用次数。最初每个节点、每条线都单独beginPath和stroke,一帧要调用几百次。后来把同类型的绘制合并:所有同链连接线用一个beginPath,所有横档用一个beginPath,所有节点分两条链各用一个beginPath。这样一帧的绘制调用从300多次降到6次。

第二,避免在循环里创建对象。最初我在循环里用lerpColor返回字符串,每帧创建120个字符串。后来改成预计算颜色数组,初始化时算好60个颜色,循环里直接取。

第三,用clearRect而不是重设canvas.width。重设canvas.width会清空画布,但也会重置所有上下文状态,包括scale。每次重设都要重新scale,开销大。clearRect只清空像素,不影响状态。

优化后,在普通笔记本上稳定60fps,在手机上也能跑到45fps以上。

4. 常见问题与排查技巧实录

4.1 画面模糊:设备像素比没处理

这是Canvas最常见的坑。你写canvas.width = 800,在2倍屏上实际显示是1600物理像素,但浏览器把800像素的内容拉伸到1600,画面就模糊了。

解决方法前面提过:canvas.width = rect.width * dpr,然后ctx.scale(dpr, dpr)。注意rect要在CSS布局完成后获取,否则宽度可能是0。我习惯在window.onload或者DOMContentLoaded之后初始化。

提示:如果容器宽度是百分比,窗口缩放后rect.width会变,需要重新设置canvas尺寸。加一个防抖的resize监听,延迟200ms执行。

4.2 动画卡顿:每帧计算量过大

如果节点数超过100,或者每帧做了复杂的颜色插值,帧率会明显下降。排查方法是打开浏览器的Performance面板,录制几秒动画,看哪部分耗时最长。

常见的耗时点:

  • lerpColor里的parseInt和字符串拼接
  • 频繁的beginPath和stroke
  • 在循环里访问DOM属性(比如canvas.width)

优化手段就是前面说的预计算、合并绘制、缓存属性值。

4.3 节点重叠:间距和半径的比例失衡

当节点半径大于间距的一半时,相邻节点会重叠。比如间距12px,半径8px,两个节点就会叠在一起。视觉上变成一条粗线,失去“珠子”的感觉。

解决方法是调整半径或间距。我的经验公式是:半径最大值不超过间距的0.6倍。间距11.86px,半径最大10px,10/11.86≈0.84,其实偏大了。但实际效果中,半径最大的节点正好在波形中间,上下有空间,不会和相邻节点重叠。如果你发现重叠严重,把radiusVariation从4降到3,半径范围变成3到9,就安全了。

4.4 颜色断层:渐变插值不均匀

用lerpColor做渐变时,如果t的步长不均匀,颜色会出现断层。比如60个节点,t从0到1,步长是1/59≈0.017,很均匀,没问题。但如果节点数少,比如10个,步长0.1,颜色跳跃就明显了。

解决办法是用HSL插值代替RGB插值。HSL的色相是环形的,插值更平滑。不过RGB插值在这个案例里够用了,因为节点多,步长小。

4.5 常见问题速查表

问题现象可能原因排查方法解决方案
画面模糊未处理设备像素比检查canvas.width和rect.width是否相等乘以dpr并ctx.scale
动画卡顿每帧绘制调用过多Performance面板录制合并beginPath,预计算颜色
节点重叠半径大于间距一半目测节点是否粘连减小radiusVariation或增大间距
波形不流动phase未随时间变化检查elapsed是否递增确保requestAnimationFrame持续调用
颜色断层插值步长过大减少节点数观察改用HSL插值或增加节点数
上下溢出振幅过大检查centerY ± amplitude是否超出画布减小振幅或增大画布高度
线条穿过节点绘制顺序错误检查先画线还是先画节点先画线,再画节点

4.6 独家避坑技巧:用Math.abs处理负半径

ctx.arc的半径参数如果是负数,会抛出IndexSizeError。余弦函数产生的负值必须处理。我见过有人用Math.max(0, r),但这样半径为0的节点会消失,视觉上出现断点。用Math.abs(r)更好,负值变成正值,节点始终可见,只是大小变化。

另一个技巧:在绘制节点前加一个微小的阴影。ctx.shadowBlur = 8; ctx.shadowColor = 'rgba(79, 172, 254, 0.5)'。这样节点有发光效果,在深色背景上更醒目。但阴影很耗性能,节点多的时候要慎用。我的做法是只给半径大于5的节点加阴影,小节点不加。

5. 效果扩展与变体玩法

5.1 横向螺旋:把正弦换成余弦

现在的效果是节点水平排列,垂直方向做正弦运动。如果把x和y互换,让节点垂直排列,水平方向做正弦运动,就得到横向螺旋。代码改动很小:把x和y的计算互换,gap作用在y上。

横向螺旋适合做页面侧边的装饰元素,宽度窄、高度长,不占主体内容空间。

5.2 三维旋转:叠加一个旋转矩阵

现在的效果是2D投影,看起来像螺旋,但没有真正的3D旋转。如果想让它绕Y轴旋转,需要给每个节点计算3D坐标,然后做透视投影。

简化版的做法是:给每个节点的x坐标乘以Math.cos(rotationAngle),y坐标不变,然后根据x坐标的“深度”调整半径和透明度。这样旋转时,节点会左右移动,远处的节点变小变淡。

const rotX = x * Math.cos(angle); const depth = x * Math.sin(angle); const scale = 1 + depth / 500; const alpha = 0.5 + 0.5 * (1 - Math.abs(depth) / 400);

这个变体代码量增加不多,但立体感强很多。缺点是旋转角度大了之后,节点会重叠,需要额外处理绘制顺序(远的先画,近的后画)。

5.3 交互响应:鼠标位置影响振幅

加一个mousemove监听,鼠标的垂直位置控制振幅大小。鼠标在画布上方时振幅小,波形平缓;鼠标在下方时振幅大,波形剧烈。这样用户移动鼠标就能改变效果,互动感强。

canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const mouseY = e.clientY - rect.top; targetAmplitude = 20 + (mouseY / rect.height) * 80; });

然后在绘制循环里用lerp平滑过渡amplitude,避免突变。

5.4 粒子拖尾:记录历史位置

如果想让节点有拖尾效果,可以维护一个历史位置数组,每个节点保存最近10帧的位置,绘制时从旧到新画,透明度递增。这样节点移动时会留下一条渐隐的轨迹,像彗星一样。

代价是内存和计算量增加。60个节点乘以10帧历史,每帧要画600个圆。在低端设备上可能掉帧。折中方案是只给半径最大的前10个节点加拖尾。

6. 从这个小效果里我学到的

做这个DNA螺旋之前,我以为“用代码画图”就是调API。做完之后发现,真正难的不是API怎么用,而是把视觉需求翻译成数学表达式。DNA螺旋看起来复杂,拆开就是正弦加余弦。这个翻译过程,才是前端视觉效果的核心能力。

另一个体会是参数调试比写代码花时间。代码可能半小时写完,但调振幅、频率、半径、颜色,我花了两个多小时。每一组参数都要刷新页面看效果,截图对比。后来我学乖了,把参数做成滑块,实时调整,效率高很多。这个习惯我保留到了后面的挑战里,每个效果都配一个简易的参数面板。

还有一个坑是性能问题往往出在意想不到的地方。我以为瓶颈在绘制,结果Performance面板显示大部分时间花在lerpColor的字符串操作上。预计算颜色数组之后,帧率直接翻倍。这件事让我养成了一个习惯:任何在循环里调用的函数,都要问一句“能不能提到循环外面”。

这个效果后续还可以这样扩展:把节点换成图片,做成螺旋照片墙;把连接线换成贝塞尔曲线,做成飘带效果;把颜色映射到音频频谱,做成音乐可视化。思路打开了,一个简单的正弦波能玩出很多花样。

RELATED READING

延伸阅读

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