news 2026/10/16 7:45:21

用Canvas打造动态星空粒子登录页:从原理到完整代码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Canvas打造动态星空粒子登录页:从原理到完整代码

前阵子接了一个演示系统的登录页需求,客户反复强调要“科技感”,又明确说不要那种满屏太空飞船的俗气素材。我把表单换了无数种配色,最后发现真正撑起整页气质的不是输入框,而是背景那层动态星空粒子。粒子缓慢漂浮、近距离自动连线、鼠标滑过时形成交互轨迹,登录表单再用半透明玻璃风格浮在上面,整套效果做完,客户当场就拍了板。

这篇文章把整个实现过程拆开来讲:为什么选择Canvas方案、页面层级怎么设计、粒子引擎的核心逻辑怎么写、参数调到什么值最好看,最后附上一份可以直接复制运行的完整代码。适合前端刚入门、或者想快速交付一个高品质登录页的开发者。

1. 为什么最终选择了Canvas方案:三层筛选下来的结论

很多人第一次看到这种星空粒子背景,第一反应是“这玩意儿得用Three.js吧”,实际上完全不需要。这套效果本质上是几十到一百多个半径为1到3像素的小球在画布上匀速漂移,再用半透明线条把距离足够近的粒子连起来,最后叠加鼠标坐标作为额外的连线目标。技术构成并不复杂,难点在于选对实现路径。

1.1 先看清这个效果的技术构成

拆开来看,动态星空登录页的视觉效果由六块拼成:粒子本身、粒子运动、粒子之间的连线、鼠标与粒子的互动、背景底色、以及浮在前台的登录表单。前五块都在背景层完成,和表单本身完全解耦。

粒子就是一组带坐标和速度的小圆点,运动就是每一帧把速度加到坐标上,连线则是每帧遍历所有粒子,找出距离小于某个阈值的配对,画一条透明度随距离变化的直线。鼠标交互无非是额外增加一个“虚拟粒子”参与连线和微弱的吸引或排斥。把这六块逻辑理清楚之后,剩下的问题就变成了:用什么技术来画这些内容。

1.2 CSS动画、Canvas 2D、WebGL三条路怎么选

我最初也考虑过纯CSS方式:每个粒子用一个带box-shadow的div或者径向渐变背景,通过CSS animation控制位移动画。这个方案在粒子数量少的时候视觉效果没问题,但一旦粒子超过40到50个,页面上的DOM节点会明显变多,而且粒子之间的连线实时计算在CSS里几乎无法实现——连线需要动态创建DOM并且每帧更新坐标,想想都知道性能代价很大。

真正的选择其实在Canvas 2D和WebGL之间。我把三条路的取舍整理成一张对比表,方便你判断自己的场景:

实现方式粒子承载量交互连线实现成本依赖与体积适合场景
纯CSS动画40个以内很高,几乎不现实无依赖普通装饰性漂浮点
Canvas 2D单屏500个内流畅低,每帧重绘即可无依赖登录页、活动页、H5背景
WebGL / Three.js数千至数万中高,需要着色器知识需引入库数据可视化、大型3D场景

登录页只是一个单页场景,粒子上限大概100多个,视觉效果已经足够丰满。Canvas 2D无疑是最具性价比的选项:一张画布就够了,不需要额外引入任何库,连线逻辑用一次双重循环就能算完,代码量也不大。WebGL在这个规模下属于杀鸡用牛刀,还要额外处理着色器编译、上下文丢失等问题,对普通项目来说得不偿失。

1.3 星空登录页的“度”在哪:炫技和克制之间

技术选型定了之后,更要紧的是控制效果的分寸。我见过不少翻车案例,粒子数量盲目拉到300个,连线距离设到200像素,整个背景变成一张白色蛛网,登录表单反而看不清楚,这就违背了页面转化效率优先的原则。

我的建议是三个“不要”:粒子单帧透明度不要超过0.8,粒子半径不要超过3像素,连线最大距离不要超过150像素。背景负责营造氛围,主体永远是登录表单。星空效果最好的状态是让人第一眼觉得“舒服、高级”,而不是“这个网站技术很炫”。后面调参的时候,我会再具体说每个参数的推荐范围。

2. 页面骨架:登录表单与粒子背景怎么分层才不打架

代码结构上,最忌讳的是把canvas和表单混在一个容器里,一边定位一边找层级。这里最稳妥的做法是:canvas固定铺满全屏作为背景层,登录卡片绝对定位居中作为前景层,两层之间用z-index隔开。这样背景动画不管怎么跑,都不会影响表单区域的点击和输入。

2.1 HTML结构:Canvas固定铺底,表单绝对居中

先看HTML骨架,非常简单,总共两个节点:

<canvas id="particle-canvas"></canvas> <div class="login-card"> <h1>欢迎回来</h1> <p class="subtitle">请登录您的账号</p> <form> <label for="username">账号</label> <input type="text" id="username" placeholder="请输入账号" /> <label for="password">密码</label> <input type="password" id="password" placeholder="请输入密码" /> <button type="submit">登 录</button> </form> <p class="footer-text">还没有账号?<a href="#">立即注册</a></p> </div>

Canvas不需要放在某个专门的背景容器里,直接作为body的第一个子元素就行。我习惯让canvas用固定定位覆盖整个视口,表单用绝对定位配合transform居中,这样即使在窗口resize或者移动端地址栏收起时,卡片也能稳稳停在视觉中心。

2.2 玻璃拟态登录卡的CSS细节

登录卡片的视觉核心就是目前很流行的玻璃拟态:半透明底色、背景模糊、细边框、圆角、柔和阴影。这几条CSS组合起来的效果,是让卡片看起来像一块磨砂玻璃悬浮在粒子星空上面。

.login-card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 360px; max-width: 90vw; padding: 40px 32px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.12); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); }

有一处必须提醒:backdrop-filter属性在少数老版本浏览器上不支持,如果整个页面只有这一层半透明卡片,一旦不支持就会出现“底透明、字飘在背景上”的阅读困难。稳妥的做法是给同一选择器添加一个纯色回退背景,我习惯写成:

background: rgba(10, 14, 26, 0.85); background: rgba(255, 255, 255, 0.06);

第一行作为不支持模糊时的兜底,第二行是正常生效时的样式,后者会覆盖前者。这样既保留了玻璃质感,又不会在兼容性出问题时让表单彻底失去对比度。

2.3 输入框和按钮的交互反馈

登录表单是否好用,往往体现在输入框和按钮的交互反馈上。输入框我全部采用下划线加淡色底的方式,背景为rgba白色微透明,聚焦时边框高亮为偏蓝的科技色,同时加一个轻微的阴影扩散效果模拟“发光”。按钮则做成渐变底,悬停时亮度上浮,按下时微缩,让点击有真实的反馈。

input { width: 100%; padding: 12px 14px; margin-top: 8px; margin-bottom: 18px; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 8px; background: rgba(255, 255, 255, 0.05); color: #fff; font-size: 14px; transition: border-color 0.2s, box-shadow 0.2s; } input:focus { outline: none; border-color: #4a6cf7; box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.2); } button { width: 100%; padding: 12px; margin-top: 6px; border: none; border-radius: 8px; background: linear-gradient(135deg, #4a6cf7 0%, #6a5af0 100%); color: #fff; font-size: 15px; cursor: pointer; transition: opacity 0.2s, transform 0.1s; } button:hover { opacity: 0.9; } button:active { transform: scale(0.98); }

这些交互在用户输入账号密码的过程中会持续给出视觉反馈,虽然都是几毫秒的切换,但整体体验会比“静态表单”高级很多。真正让页面活起来的,还是接下来要讲的粒子引擎。

3. 粒子引擎三件套:初始化、连线判定、鼠标交互

粒子引擎是整个登录页的灵魂。我习惯把逻辑拆成三个函数:初始化粒子数组、每帧更新粒子坐标并重绘、鼠标移动时更新交互坐标。三个部分各司其职,调试时也容易定位问题。

3.1 粒子初始化:数量自适应与随机分布

粒子数量不能写死,因为不同屏幕的可用面积差距很大。我用的公式是“屏幕面积除以14000”得到一个基准值,再和上限做比较。以1920乘1080的屏幕为例,算出来大约是148个;以375乘812的手机屏算,大约是21个。考虑到手机性能与视觉密度,实际取上限120个以内。

const interval = 14000; let count = Math.floor((canvas.width * canvas.height) / interval); count = Math.min(count, 120);

每个粒子需要四个基本属性:x坐标、y坐标、水平速度、垂直速度,另外再加半径和透明度。速度我控制在正负0.4到0.8之间,这样粒子看起来是漂浮而不是乱飞。所有粒子的初始位置用Math.random()均匀分布在画布范围内,速度方向和大小也随机,保证画面没有明显的“扎堆”现象。

const particle = { x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, r: Math.random() * 2 + 0.5, alpha: Math.random() * 0.6 + 0.2 };

3.2 连线判定:双层循环里的数学与性能

粒子之间的连线用的是双层循环,也就是每个粒子和它后面的所有粒子逐一比较距离。这个算法的时间复杂度是O(n²),粒子数量越多计算量增长越明显。这也是为什么我建议登录页粒子数量控制在120个以内——这个规模下,每帧计算约7000多次距离判断,完全在浏览器可承受范围内。

这里有个性能小技巧:距离判断不用开平方。判断两个粒子是否在连线阈值内,只需要比较“距离平方”和“阈值平方”的关系就可以了,这样每帧少了几千次Math.sqrt调用。真正需要开平方的时候,只有确定要画线并且需要根据距离计算透明度时。

const maxDist = 120; const maxDistSquare = maxDist * maxDist; for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const dx = particles[i].x - particles[j].x; const dy = particles[i].y - particles[j].y; const distSquare = dx * dx + dy * dy; if (distSquare < maxDistSquare) { const opacity = (1 - Math.sqrt(distSquare) / maxDist) * 0.4; ctx.strokeStyle = `rgba(120, 160, 255, ${opacity})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(particles[i].x, particles[i].y); ctx.lineTo(particles[j].x, particles[j].y); ctx.stroke(); } } }

透明度随距离线性递减,远处连线几乎不可见,近处的连线比较清晰,这样形成的视觉层次非常自然。整体连线透明度我再乘了一个0.4,防止线条堆叠后过亮变成“蛛网”。

3.3 鼠标交互与动画循环的配合

鼠标交互的思路是把鼠标坐标当成一个额外的连接点。鼠标监听事件绑定在window上,鼠标位置存到一个全局对象里,每帧绘制时计算鼠标位置与每个粒子的距离,小于连线阈值的同样画线。效果就是粒子随着鼠标移动被“点亮”,形成一条跟随轨迹。

如果想让交互更有趣,还可以在鼠标半径内给粒子一个微弱的吸引力或者排斥力。我个人测试下来,排斥力比吸引力更有星空的感觉——粒子像被风吹开一样散向四周,鼠标离开后慢慢恢复,非常适合科技感场景。

const mouse = { x: null, y: null }; window.addEventListener('mousemove', (e) => { mouse.x = e.clientX; mouse.y = e.clientY; });

动画循环用requestAnimationFrame而不是setInterval。原因是requestAnimationFrame会和屏幕刷新率同步,通常稳定在60帧每秒,而且当页面切到后台时浏览器会自动暂停动画,省电也不消耗不必要的CPU。每帧的逻辑很简单:清空画布、更新粒子位置、画所有粒子、画所有连线。

function tick() { ctx.clearRect(0, 0, width, height); particles.forEach((p) => { p.x += p.vx; p.y += p.vy; if (p.x < 0 || p.x > width) p.vx = -p.vx; if (p.y < 0 || p.y > height) p.vy = -p.vy; ctx.fillStyle = `rgba(255, 255, 255, ${p.alpha})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); }); // 这里是连线逻辑... requestAnimationFrame(tick); } tick();

边界碰撞采用反弹处理而不是穿越,能避免粒子聚集在边缘消失,画面看起来也更稳定像一个封闭空间。

4. 参数调优实战:从“能跑”到“好看”

代码能在浏览器里跑出粒子之后,真正的打磨才刚刚开始。每一个参数微调,最终呈现的气质都会不一样。我把自己在项目里反复调过的参数整理成一张速查表,你可以按这张表直接起步。

4.1 一张表说清每个参数改到什么值

参数推荐值调高效果调低效果
粒子数量系数面积/14000星空更密,线条更多画面稀疏寡淡
粒子速度0.4到0.8动态感强,容易眩晕近乎静止,氛围沉闷
粒子半径0.5到2.5星点更明显细腻精致
最大连线距离120到150连接网更密集连线稀少,偏向独立星点
连线透明度系数0.3到0.5线条更亮线条若有若无
粒子单点透明度0.2到0.8星点亮度过高背景过暗

我通常以“粒子之间能连成几条线,但又不至于织成一张网”为视觉目标来调节。注意粒子密度集中在屏幕中央时,连线会非常密集,所以可以把粒子的初始位置做一点中心偏向,让四周也有分布,画面更均衡。

4.2 三套现成配色方案直接用

配色是科技感最直观的体现。我整理了三套可以直接替换的配色主题,分别对应常见的“深空蓝”“赛博紫红”“暗夜绿”风格:

主题背景色粒子颜色连线颜色按钮渐变
深空蓝#0b0f1argba(255,255,255)rgba(120,160,255)#4a6cf7 到 #6a5af0
赛博紫红#0d061frgba(255,255,255)rgba(255,100,150)#ff2d78 到 #b836ff
暗夜绿#07120ergba(215,255,231)rgba(75,220,150)#00c891 到 #0ae3a5

其中深空蓝是我的默认选择,蓝色连线传递出理性、现代、可靠的感觉,和大多数企业级系统需求比较匹配。赛博紫红适合直播、娱乐、游戏类产品,视觉冲击力更强。暗夜绿则相对冷静,偏极客风格。改成第二套时,只需要把CSS里的按钮渐变、canvas绘制时的粒子颜色和连线颜色三处替换掉就行。

4.3 移动端适配与高分辨率屏幕的处理

移动端是整个效果最容易翻车的地方。屏幕小、粒子数量少,大片留白容易显得单调;像素密度高,处理不好又会卡顿。我建议的适配方案分三步。

第一步是设备像素比处理。现在很多手机屏幕的devicePixelRatio是2甚至3,直接把canvas的宽高设置为CSS像素的话,画面会模糊。正确做法是读取DPI并把canvas的实际像素尺寸乘上这个比例,同时用CSS把显示尺寸控制在100%:

const ratio = Math.min(window.devicePixelRatio || 1, 2); canvas.width = canvas.clientWidth * ratio; canvas.height = canvas.clientHeight * ratio; ctx.scale(ratio, ratio);

这里把ratio限制在2以内,是为了避免3倍屏的粒子计算量无谓翻倍。第二步是针对触屏设备,把鼠标事件换成touchmove事件,并把触点坐标同步到mouse对象。第三步是降低移动端粒子数量系数,例如改为面积除以16000,并缩小连线距离到100像素,保证中低端手机动画依然流畅。

5. 完整可运行代码、部署踩坑与扩展思路

到这里,所有零散的逻辑都已经讲透了,把它们放进一个文件就是完整的登录页。下面的代码可以直接在本地新建一个HTML文件打开,不需要任何服务器和依赖,复制粘贴就能看到效果。

5.1 一个HTML文件搞定全部

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>动态星空粒子登录页</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #0b0f1a; color: #fff; overflow: hidden; min-height: 100vh; } #particle-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 0; } .login-card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 360px; max-width: 90vw; padding: 40px 32px; background: rgba(10, 14, 26, 0.85); background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.12); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); z-index: 1; text-align: center; } .login-card h1 { font-size: 24px; font-weight: 600; letter-spacing: 2px; } .subtitle { margin-top: 8px; font-size: 13px; color: rgba(255, 255, 255, 0.6); } form { margin-top: 28px; text-align: left; } label { display: block; font-size: 13px; color: rgba(255, 255, 255, 0.7); margin-bottom: 4px; } input { width: 100%; padding: 12px 14px; margin-top: 8px; margin-bottom: 18px; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 8px; background: rgba(255, 255, 255, 0.05); color: #fff; font-size: 14px; transition: border-color 0.2s, box-shadow 0.2s; } input:focus { outline: none; border-color: #4a6cf7; box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.2); } button { width: 100%; padding: 12px; margin-top: 6px; border: none; border-radius: 8px; background: linear-gradient(135deg, #4a6cf7 0%, #6a5af0 100%); color: #fff; font-size: 15px; cursor: pointer; letter-spacing: 4px; transition: opacity 0.2s, transform 0.1s; } button:hover { opacity: 0.9; } button:active { transform: scale(0.98); } .footer-text { margin-top: 20px; font-size: 13px; color: rgba(255, 255, 255, 0.6); } .footer-text a { color: #4a6cf7; text-decoration: none; } .footer-text a:hover { text-decoration: underline; } </style> </head> <body> <canvas id="particle-canvas"></canvas> <div class="login-card"> <h1>欢迎回来</h1> <p class="subtitle">请登录您的账号</p> <form> <label for="username">账号</label> <input type="text" id="username" placeholder="请输入账号" /> <label for="password">密码</label> <input type="password" id="password" placeholder="请输入密码" /> <button type="submit">登 录</button> </form> <p class="footer-text">还没有账号?<a href="#">立即注册</a></p> </div> <script> (function () { const canvas = document.getElementById("particle-canvas"); const ctx = canvas.getContext("2d"); const mouse = { x: null, y: null, radius: 120 }; let particles = []; let width = 0; let height = 0; function init() { const ratio = Math.min(window.devicePixelRatio || 1, 2); width = canvas.clientWidth * ratio; height = canvas.clientHeight * ratio; canvas.width = width; canvas.height = height; ctx.scale(ratio, ratio); let count = Math.floor((canvas.clientWidth * canvas.clientHeight) / 14000); count = Math.min(count, 120); particles = []; for (let i = 0; i < count; i++) { particles.push({ x: Math.random() * canvas.clientWidth, y: Math.random() * canvas.clientHeight, vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, r: Math.random() * 2 + 0.5, alpha: Math.random() * 0.6 + 0.2 }); } } function drawParticles() { particles.forEach(function (p) { ctx.fillStyle = "rgba(255, 255, 255, " + p.alpha + ")"; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); }); } function drawLines() { const maxDist = 120; const maxDistSquare = maxDist * maxDist; for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const dx = particles[i].x - particles[j].x; const dy = particles[i].y - particles[j].y; const distSquare = dx * dx + dy * dy; if (distSquare < maxDistSquare) { const opacity = (1 - Math.sqrt(distSquare) / maxDist) * 0.4; ctx.strokeStyle = "rgba(120, 160, 255, " + opacity + ")"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(particles[i].x, particles[i].y); ctx.lineTo(particles[j].x, particles[j].y); ctx.stroke(); } } } if (mouse.x !== null && mouse.y !== null) { particles.forEach(function (p) { const dx = p.x - mouse.x; const dy = p.y - mouse.y; const distSquare = dx * dx + dy * dy; if (distSquare < maxDistSquare) { const opacity = (1 - Math.sqrt(distSquare) / maxDist) * 0.5; ctx.strokeStyle = "rgba(180, 200, 255, " + opacity + ")"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke(); } }); } } function tick() { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); particles.forEach(function (p) { p.x += p.vx; p.y += p.vy; if (p.x < 0 || p.x > canvas.clientWidth) p.vx = -p.vx; if (p.y < 0 || p.y > canvas.clientHeight) p.vy = -p.vy; }); drawParticles(); drawLines(); requestAnimationFrame(tick); } window.addEventListener("mousemove", function (e) { mouse.x = e.clientX; mouse.y = e.clientY; }); window.addEventListener("mouseout", function () { mouse.x = null; mouse.y = null; }); window.addEventListener("touchmove", function (e) { const touch = e.touches[0]; mouse.x = touch.clientX; mouse.y = touch.clientY; }); window.addEventListener("resize", function () { init(); }); init(); tick(); })(); </script> </body> </html>

这段代码在桌面和移动浏览器上都能直接运行,笔记通过CDN引入的只有原生API,所以离线环境下也完全可用。把title、占位文案和配色换成你自己的,就能直接交付。

5.2 部署和兼容性上容易踩的坑

第一次把这套页面部署到测试环境时,我遇到过几个坑,这里一并列出来,省得你重复踩。

第一个坑是backdrop-filter兼容性。这个问题在登录卡片上尤其明显,因为卡片可能在粒子背景上方,模糊一旦失效,半透明底下一堆线条穿过去,文字和背景就糊在一起。我的解决方式是前面说的双背景兜底,先给一个深色不透明底,再覆盖半透明底色,模糊失效也能保证可读性。

第二个坑是手机横竖屏切换或者PC端拖动窗口后canvas白屏。原因很简单,resize时设置canvas.width会清空画布内容,如果只改尺寸没有重新初始化和重绘,画面就会变成一块白板或者黑幕。所以resize监听里必须调用init(),让粒子数组在当前画布尺寸下重新生成。要注意这个过程中粒子的位置全部重置,视觉上会有一个“刷新”过程,这是正常的。

第三个坑是鼠标移出窗口后,鼠标交互线残留在画面边缘。鼠标坐标只更新不清理,粒子就会持续和那个“幽灵坐标”连线,看起来像有个无形的洞在吸附粒子。我在代码里加了mouseout事件把坐标清空,移动端也建议在touchend时重置,这个小细节能让交互干净不少。

第四个坑是关于Canvas的实际渲染尺寸与CSS尺寸不一致导致的模糊。很多人在初始化时只写canvas.width和height,没有乘devicePixelRatio,在Retina屏幕上整个星空线条都是糊的。上面对dpr的处理已经写进init里了,如果你的页面里canvas在某个容器中,记得用canvas.getBoundingClientRect()取实际宽度,而不是用clientWidth,因为前者在遇到缩放或缩放容器时更准确。

5.3 继续玩下去:星座连线、粒子文字、鼠标拖尾

这个粒子登录页的底座其实是通用的Canvas粒子系统,往里面加玩法不会破坏现有结构。我自己试过几个扩展方向,都和这套骨架无缝衔接。

一个方向是星座连线效果。现在每条连线是等宽的直线,如果把连线宽度也随距离衰减,并且在粒子数量较少时把阈值调大,就能模拟星座图谱的观感,配合暗夜绿配色特别适合做技术社区的登录页。另一个方向是把鼠标交互从单向连线改成拖尾效果:当粒子靠近鼠标时,不画线而是让粒子的运动方向偏转一个角度,形成粒子流绕开鼠标的流体感,这个需要多写一小段向量物理逻辑,但视觉效果非常强。

还有个更有意思的玩法是粒子文字。先用一个离屏canvas把“登录”或某个品牌的字母画上去,再用getImageData读取像素坐标,把这些坐标作为粒子的目标位置,粒子会缓慢汇聚成文字形状。这个效果本身就可以独立做成一个页面,感兴趣的话可以参考粒子动画与图像像素采样的组合思路来扩展。

我在实际项目里最后用的是“深空蓝配星座连线”的变体,把粒子数量从120降到80,连线距离提到150,视觉上比默认版本更克制也更耐看。登录页这种场景,做完之后通常不会再频繁改动,所以建议你在交付前把参数定好、把移动端测试跑一遍,剩下的就是享受这套效果带来的正反馈了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 7:44:34

OpenCV通道分离与合并:split/merge原理、BGR顺序与实战避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 7:43:56

C#上位机串口转CAN通讯实战:协议设计、粘包处理与稳定性优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 7:43:36

基于SpringBoot的智能旅游行程规划系统:算法设计与工程实践

旅游行程规划系统&#xff0c;说白了就是把“今天去哪、明天去哪、几点去、坐什么车、玩多久”这件事交给程序去排。我做完这个项目之后最深的感受是&#xff1a;这个系统的难点根本不在CRUD&#xff0c;而在“规划”这两个字上。你光是把景点存进数据库&#xff0c;那只是一个…

作者头像 李华
网站建设 2026/10/16 7:43:23

时序数据库进入深水区:2026年技术分化与融合式架构解析

最近跟几个做时序数据库的朋友聊天&#xff0c;发现大家聊的已经不是“要不要上时序库”这种初级问题&#xff0c;而是“自家这套时序方案跟业务系统怎么融”“底层存储到底该走哪条路”这类更扎心的议题。回想五年前&#xff0c;国产时序数据库还在拼命证明自己能写能查&#…

作者头像 李华
网站建设 2026/10/16 7:42:38

React Native鸿蒙化实战:集成react-native-webview的完整指南

如果你的 React Native 项目已经走到鸿蒙化这一步&#xff0c;那迟早要面对一个叫 react-native-webview 的库。说句实在话&#xff0c;RN 应用里凡是内嵌 H5 页面、加载活动页、接第三方登录、放商品详情的&#xff0c;都绕不开它&#xff0c;我在多个项目里统计过&#xff0c…

作者头像 李华