资讯中心

从零构建可交互电子沙漏:粒子系统与物理仿真实践

📅 2026/7/28 5:32:35
从零构建可交互电子沙漏:粒子系统与物理仿真实践
1. 从物理沙漏到数字沙漏一个被忽视的仿真价值提起沙漏我们脑海中浮现的往往是那个装着细沙、用来计时的古老工具。但今天当我们谈论“掌控电子沙漏”时指的远不止是制作一个简单的计时器动画。这背后是一个融合了物理模拟、交互设计、可视化编程乃至硬件控制的综合性仿真项目。它看似简单却是一个绝佳的入门沙盒能让你亲手“掌控”从牛顿力学到实时渲染的多个核心技术点。为什么一个沙漏值得用“仿真”这么重的词因为一个真实的沙漏其内部物理过程极其复杂。沙粒或更通用的“颗粒”的流动涉及到重力、碰撞、摩擦、堆积、堵塞等一系列现象。在计算机中精确地复现这一过程就是典型的“颗粒物质力学”仿真问题。它不仅是游戏特效如雪崩、沙堆的基础在工业领域如谷物仓储、制药粉末流动也有广泛应用。因此“掌控电子沙漏”这个标题其核心价值在于通过一个直观、有趣的载体深入理解并实践颗粒系统仿真的核心原理与实现方法。这篇文章我将从一个仿真开发者的角度带你完整走一遍构建一个高保真、可交互电子沙漏的全过程。我们不会停留在画几个圆圈下落而是会拆解如何用代码定义每一颗“沙粒”的物理属性如何模拟它们之间的相互作用以及如何设计流畅的交互逻辑来真正“掌控”它。无论你是刚接触仿真的学生还是想寻找一个有趣练手项目的开发者相信都能从中获得可以直接复现的代码思路和避坑经验。2. 仿真核心颗粒系统的物理建模与算法选型构建电子沙漏的第一步也是最具挑战性的一步就是为“沙粒”建立物理模型。你需要决定模拟的精度和性能的平衡点。这里主要有两大主流思路基于物理的离散元法DEM和基于经验的粒子系统Particle System。2.1 离散元法DEM追求物理精确性的重型方案如果你希望沙粒的行为尽可能真实例如能模拟沙粒形状非球形、潮湿沙粒的粘连、或者精确的力链传导那么离散元法是学术和工业界的选择。它的核心是为每个颗粒单独求解运动方程。每一颗沙粒的运动都遵循牛顿第二定律F m * a其中合力F主要包括重力F_gravity m * g方向竖直向下。接触力当两颗沙粒发生碰撞时产生。这通常分解为法向力和切向力摩擦力。法向力常用赫兹接触模型或线性弹簧阻尼模型来模拟切向摩擦力则常用库仑摩擦模型。阻尼力模拟能量耗散如空气阻力、内摩擦防止系统无限震荡。在代码中这意味着你需要为每个沙粒对象存储其位置、速度、质量、半径等属性并在每一帧或每个时间步进行如下计算检测所有沙粒之间以及沙粒与边界沙漏壁的碰撞。为发生碰撞的沙粒对计算接触力。对所有沙粒根据其受到的合力重力所有接触力更新加速度。根据加速度更新速度再根据速度更新位置。实操心得与避坑指南性能是最大瓶颈最简单的碰撞检测需要两两判断复杂度是O(N²)当沙粒数N超过几百时帧率会急剧下降。必须使用空间划分算法进行优化如四叉树2D或八叉树/网格法3D。这些算法能将邻近沙粒的检测复杂度降至接近O(N log N)或O(N)。时间积分要稳定显式欧拉法虽然简单但容易导致能量发散沙粒乱飞。推荐使用韦尔莱积分法Verlet Integration或蛙跳法Leapfrog Integration它们在处理这类二阶微分方程时更稳定。参数调校是门艺术弹簧刚度、阻尼系数、摩擦系数这些参数没有标准答案。我的经验是先设一个较小的刚度让沙粒“软”一些避免穿透阻尼系数调到能快速平息碰撞震荡为宜摩擦系数决定了沙堆的安息角需要反复试验。注意完整的DEM实现相当复杂对于首个电子沙漏项目我建议从更轻量级的粒子系统入手待核心流程跑通后再考虑引入部分DEM思想进行增强。2.2 粒子系统Particle System兼顾性能与表现力的实用选择在游戏和交互艺术中更常见的是使用粒子系统。它不严格求解每个粒子的受力而是用一组规则来近似群体行为牺牲一部分物理精度换取巨大的性能提升和艺术可控性。对于沙漏我们可以为粒子定义以下简化规则运动主要受恒定重力影响v g * dt。碰撞与容器壁的碰撞采用简单的反射或摩擦力模拟如碰到壁后法向速度反转并乘以一个衰减系数切向速度乘以摩擦系数。粒子之间的碰撞可以简化处理例如只做简单的排斥力当距离小于两倍半径时施加一个短程的、指向外的力或者为了性能甚至忽略粒子间碰撞只依赖与边界的碰撞和堆叠来形成沙堆。生命周期与状态沙粒从“上瓶”移动到“下瓶”后可以重置其位置模拟循环。为什么选择粒子系统作为起点对于“掌控电子沙盘”这个目标我们首要的是实现流畅的交互和即时的视觉反馈。一个拥有数千粒子的、运行在60FPS的粒子系统其视觉说服力远胜于一个只有几十个粒子、但计算“绝对正确”的卡顿DEM模拟。先做出效果再优化物理是更高效的开发路径。我的方案选型我将采用基于粒子系统但融入关键物理因素的混合模型。具体来说使用粒子系统作为基础保证大规模粒子下的性能。实现网格空间划分进行高效的边界碰撞检测。为粒子间引入简化的短程排斥力避免过度穿透让沙堆看起来更自然。重点模拟沙粒与沙漏狭窄“颈部”的相互作用这是沙漏流动特征的关键。3. 实现路径从零搭建可交互的电子沙漏确定了物理模型我们开始动手实现。我将以Web技术栈HTML5 Canvas JavaScript为例进行讲解因为其无需安装、易于分享和演示。其他平台如Processing, Unity, Python/Pygame的核心逻辑完全相通。3.1 环境准备与粒子数据结构首先创建一个HTML文件包含一个Canvas画布。!DOCTYPE html html head title电子沙漏仿真/title style body { margin: 0; overflow: hidden; background: #f0f0f0; } canvas { display: block; } /style /head body canvas idsandCanvas/canvas script srcsand.js/script /body /html在sand.js中我们初始化画布并定义粒子类。const canvas document.getElementById(sandCanvas); const ctx canvas.getContext(2d); canvas.width window.innerWidth; canvas.height window.innerHeight; class Particle { constructor(x, y, radius) { this.x x; // 位置x this.y y; // 位置y this.vx 0; // 速度x this.vy 0; // 速度y this.radius radius; // 半径 this.color hsl(${Math.random() * 30 20}, 70%, 60%); // 沙色 this.mass radius * radius; // 质量与面积成正比 this.fixed false; // 是否固定用于构成沙漏壁 } draw() { ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fillStyle this.color; ctx.fill(); // 添加一点高光让颗粒更立体 ctx.beginPath(); ctx.arc(this.x - this.radius*0.3, this.y - this.radius*0.3, this.radius*0.4, 0, Math.PI * 2); ctx.fillStyle rgba(255, 255, 255, 0.3); ctx.fill(); } }3.2 构建沙漏容器与边界碰撞沙漏容器由两个圆锥体或漏斗对接而成。我们可以用数学函数定义其边界。一个简单的方法是使用符号距离函数SDF。对于点(x, y)到沙漏壁的距离可以通过一个函数计算如果点在内部距离为正外部为负。这里我们用分段函数来近似一个沙漏形状class Hourglass { constructor(centerX, centerY, totalHeight, neckRadius) { this.cx centerX; this.cy centerY; this.h totalHeight; this.neckR neckRadius; this.topY centerY - totalHeight / 2; this.bottomY centerY totalHeight / 2; this.neckY centerY; } // 判断点(x,y)是否在沙漏容器内且不在壁内 contains(x, y) { const dy y - this.neckY; const distanceFromCenter Math.abs(x - this.cx); // 上半部分漏斗倒圆锥 if (dy 0) { const allowedRadius this.neckR (-dy / (this.h/2 - this.neckR)) * (this.cx - this.neckR); return distanceFromCenter allowedRadius - 2; // 减2是壁厚裕量 } // 下半部分漏斗正圆锥 else { const allowedRadius this.neckR (dy / (this.h/2 - this.neckR)) * (this.cx - this.neckR); return distanceFromCenter allowedRadius - 2; } } // 处理粒子与边界的碰撞 handleCollision(particle) { const dy particle.y - this.neckY; const dx particle.x - this.cx; const distanceFromCenter Math.abs(dx); let allowedRadius; if (dy 0) { // 上半部分 allowedRadius this.neckR (-dy / (this.h/2)) * (canvas.width/4 - this.neckR); } else { // 下半部分 allowedRadius this.neckR (dy / (this.h/2)) * (canvas.width/4 - this.neckR); } // 如果粒子碰到了壁 if (distanceFromCenter particle.radius allowedRadius) { // 计算碰撞点法向量指向圆心方向 const normalX -dx / distanceFromCenter; const normalY (dy 0) ? -0.3 : 0.3; // 给一个向下的分量模拟沙粒沿壁下滑 // 将粒子推回边界内 const overlap (distanceFromCenter particle.radius) - allowedRadius; particle.x - normalX * overlap * 0.5; particle.y - normalY * overlap * 0.5; // 速度分解法向分量反转并衰减切向分量保留并施加摩擦 const dotProduct particle.vx * normalX particle.vy * normalY; // 法向速度 const vNormalX normalX * dotProduct; const vNormalY normalY * dotProduct; // 切向速度 const vTangentX particle.vx - vNormalX; const vTangentY particle.vy - vNormalY; // 更新速度法向反弹带衰减切向摩擦 const restitution 0.3; // 反弹系数小于1表示非弹性碰撞 const friction 0.95; // 摩擦系数 particle.vx (vTangentX * friction) - (vNormalX * restitution); particle.vy (vTangentY * friction) - (vNormalY * restitution); } // 处理颈部特殊逻辑如果粒子非常接近颈部中心给予一个向下的引导力模拟“漏”的过程 if (Math.abs(dy) 10 distanceFromCenter this.neckR 5) { particle.vy 0.2; // 一个额外的向下的加速度 } } draw() { ctx.strokeStyle #333; ctx.lineWidth 3; // 绘制沙漏轮廓...略可用lineTo绘制 } }这个handleCollision函数是沙漏行为真实性的关键。它不仅仅是将粒子推出去还模拟了非弹性碰撞的能量损失和摩擦力这是沙粒不会无限弹跳的原因。3.3 粒子更新循环与空间划分有了容器和粒子我们需要一个主循环来更新和绘制每一帧。const particles []; const hourglass new Hourglass(canvas.width/2, canvas.height/2, 500, 10); const GRAVITY 0.2; const NUM_PARTICLES 1500; // 粒子数量 // 初始化粒子全部放在上半部分 for (let i 0; i NUM_PARTICLES; i) { const angle Math.random() * Math.PI * 2; const radius Math.random() * 100 50; // 在上半部分随机分布 const px hourglass.cx Math.cos(angle) * radius; const py hourglass.topY 50 Math.random() * (hourglass.neckY - hourglass.topY - 100); particles.push(new Particle(px, py, 3 Math.random() * 2)); // 半径略有差异 } // 空间网格划分用于优化边界碰撞检测此处简化为只对y轴进行粗略分区 const GRID_SIZE 50; const grid {}; function updateParticleGrid(particle) { const gridX Math.floor(particle.x / GRID_SIZE); const gridY Math.floor(particle.y / GRID_SIZE); const key ${gridX},${gridY}; if (!grid[key]) grid[key] []; // 实际项目中这里需要管理粒子的增删避免重复添加 } function animate() { // 清空画布 ctx.fillStyle rgba(240, 240, 240, 0.2); // 半透明清空产生拖尾效果 ctx.fillRect(0, 0, canvas.width, canvas.height); // 清空网格每帧重建简单但低效仅作演示。优化版应增量更新 for (let key in grid) delete grid[key]; // 更新并绘制每个粒子 for (let particle of particles) { // 1. 应用重力 particle.vy GRAVITY; // 2. 更新位置 particle.x particle.vx; particle.y particle.vy; // 3. 处理与沙漏边界的碰撞 hourglass.handleCollision(particle); // 4. 简单的粒子间排斥力仅处理相邻网格内的粒子此处简化 // ... 代码见下节 // 5. 速度阻尼模拟空气阻力等 particle.vx * 0.995; particle.vy * 0.995; // 6. 将粒子约束在沙漏容器内二次保险 if (!hourglass.contains(particle.x, particle.y)) { // 如果意外跑出将其重置到上半部随机位置 particle.x hourglass.cx (Math.random() - 0.5) * 50; particle.y hourglass.topY 50 Math.random() * 100; particle.vx particle.vy 0; } // 7. 绘制粒子 particle.draw(); // 8. 更新粒子在网格中的位置用于后续优化 updateParticleGrid(particle); } // 绘制沙漏容器 hourglass.draw(); requestAnimationFrame(animate); } animate();3.4 实现粒子间相互作用与“沙堆”形成忽略粒子间碰撞沙粒会像水一样穿过彼此无法形成沙堆。我们需要添加一个简化的短程排斥力。// 在animate循环的更新位置之后边界碰撞之前添加粒子间作用力 const REPEL_FORCE 0.5; const REPEL_DISTANCE 8; // 两倍于平均半径 // 优化只检查同一网格及相邻网格的粒子 for (let particle of particles) { const gridX Math.floor(particle.x / GRID_SIZE); const gridY Math.floor(particle.y / GRID_SIZE); for (let dx -1; dx 1; dx) { for (let dy -1; dy 1; dy) { const key ${gridX dx},${gridY dy}; if (grid[key]) { for (let otherParticle of grid[key]) { if (particle otherParticle) continue; const diffX otherParticle.x - particle.x; const diffY otherParticle.y - particle.y; const distance Math.sqrt(diffX * diffX diffY * diffY); const minDist particle.radius otherParticle.radius; if (distance minDist distance 0) { // 发生重叠施加排斥力 const force (minDist - distance) / minDist * REPEL_FORCE; const forceX (diffX / distance) * force; const forceY (diffY / distance) * force; // 根据质量分配反作用力简单处理 const totalMass particle.mass otherParticle.mass; particle.vx - forceX / particle.mass; particle.vy - forceY / particle.mass; otherParticle.vx forceX / otherParticle.mass; otherParticle.vy forceY / otherParticle.mass; // 同时直接修正位置解决穿透位置修正 const correction (minDist - distance) * 0.5; particle.x - (diffX / distance) * correction * (otherParticle.mass / totalMass); particle.y - (diffY / distance) * correction * (otherParticle.mass / totalMass); otherParticle.x (diffX / distance) * correction * (particle.mass / totalMass); otherParticle.y (diffY / distance) * correction * (particle.mass / totalMass); } } } } } }这段代码实现了粒子间的“软”碰撞。当两个粒子距离过近时会产生一个将它们推开的力同时直接微调它们的位置以防止深度穿透。结合边界碰撞和重力沙粒就能自然地下落、堆积并在颈部形成独特的流动。4. 交互与掌控让沙漏“活”起来一个静态的沙漏仿真只是第一步。标题中的“掌控”意味着交互。我们可以从以下几个方面增加控制维度4.1 动态控制流速与方向最直接的交互是控制沙漏的“开关”和流速。我们可以通过修改重力方向或颈部阻力来实现。let flowActive true; let gravityScale 1.0; let neckResistance 1.0; // 颈部阻力系数大于1减缓流速 // 在动画循环中应用可控参数 particle.vy GRAVITY * gravityScale; // 在hourglass.handleCollision的颈部处理逻辑中 if (Math.abs(dy) 10 distanceFromCenter this.neckR 5) { particle.vy 0.2 / neckResistance; // 阻力越大额外加速度越小 } // 添加键盘或鼠标事件控制这些参数 document.addEventListener(keydown, (e) { if (e.code Space) { flowActive !flowActive; gravityScale flowActive ? 1.0 : 0.0; // 空格键暂停/继续 } else if (e.code ArrowUp) { neckResistance Math.min(neckResistance 0.2, 5); // 上箭头增加阻力减缓流速 } else if (e.code ArrowDown) { neckResistance Math.max(neckResistance - 0.2, 0.2); // 下箭头减少阻力加快流速 } else if (e.code KeyR) { // R键翻转重力模拟倒转沙漏 gravityScale * -1; // 同时可以快速将所有粒子重置到另一侧 } });4.2 鼠标交互拨动、添加与移除沙粒让用户能用鼠标与沙粒直接互动仿真体验会大大提升。let mouseX 0, mouseY 0; let isMouseDown false; let mouseForce 5.0; canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); mouseX e.clientX - rect.left; mouseY e.clientY - rect.top; }); canvas.addEventListener(mousedown, () { isMouseDown true; }); canvas.addEventListener(mouseup, () { isMouseDown false; }); // 在动画循环中添加鼠标交互力 if (isMouseDown) { for (let particle of particles) { const dx particle.x - mouseX; const dy particle.y - mouseY; const distanceSq dx * dx dy * dy; const forceRadius 100; // 影响力半径 if (distanceSq forceRadius * forceRadius distanceSq 0) { const distance Math.sqrt(distanceSq); // 力的大小与距离成反比方向指向鼠标排斥或背离鼠标吸引 const force (forceRadius - distance) / forceRadius * mouseForce; particle.vx (dx / distance) * force; particle.vy (dy / distance) * force; } } } // 点击添加新沙粒 canvas.addEventListener(click, (e) { if (hourglass.contains(mouseX, mouseY)) { particles.push(new Particle(mouseX, mouseY, 3 Math.random() * 2)); } }); // 右键移除沙粒示例移除鼠标附近的沙粒 canvas.addEventListener(contextmenu, (e) { e.preventDefault(); const removalRadius 20; for (let i particles.length - 1; i 0; i--) { const p particles[i]; const dx p.x - mouseX; const dy p.y - mouseY; if (dx * dx dy * dy removalRadius * removalRadius) { particles.splice(i, 1); break; // 一次只移除一个 } } });4.3 视觉增强与参数调试面板一个专业的仿真项目离不开实时的参数调节和视觉反馈。绘制力场可视化可以绘制鼠标力场的范围或者绘制每个粒子的速度向量用短线表示方向和大小帮助理解粒子运动状态。添加统计信息在画面一角显示当前帧率FPS、粒子总数、上半部分/下半部分粒子数量等。集成调试面板使用dat.GUI这样的轻量级库可以快速创建滑块来实时调整重力、摩擦系数、反弹系数、排斥力强度等所有关键物理参数。这是调出理想沙漏行为的最快方式。// 使用dat.GUI的示例需引入dat.gui.min.js import * as dat from dat.gui; // 假设使用ES模块 const gui new dat.GUI(); const params { gravity: 0.2, friction: 0.95, restitution: 0.3, repelForce: 0.5, neckRadius: 10 }; gui.add(params, gravity, 0, 1).onChange(v GRAVITY v); gui.add(params, friction, 0.8, 1).onChange(v { /* 更新全局摩擦系数 */ }); gui.add(params, neckRadius, 5, 30).onChange(v hourglass.neckR v); // ... 添加更多控制器5. 性能优化与进阶挑战当粒子数量上升到数千时性能问题就会凸显。除了前面提到的空间网格划分还有以下优化策略5.1 计算性能优化使用Web Workers将粒子间作用力的计算O(N²)复杂度部分放到后台线程避免阻塞UI渲染。降低渲染精度在粒子非常多时可以每两帧更新一次物理计算固定时间步长或者对远处的粒子使用更简化的物理模型。使用TypedArray将粒子属性x, y, vx, vy存储在Float32Array中利用CPU缓存和SIMD单指令多数据优势可以大幅提升JavaScript数值计算性能。GPU加速终极方案是使用WebGL。通过GLSL着色器可以将每个粒子的物理计算并行化在GPU的数百个核心上轻松处理数万甚至百万粒子。ParticleSystem可以完全在片段着色器中实现。5.2 物理真实感进阶多尺度颗粒引入不同大小的沙粒。大颗粒会卡在颈部影响流速这需要更复杂的碰撞检测和力计算。非球形颗粒将圆形颗粒改为椭圆或多边形模拟片状或长条状颗粒这会使堆积和流动特性发生巨大变化需要用到分离轴定理SAT进行碰撞检测。颗粒材质属性为颗粒添加“湿度”、“电荷”等属性模拟湿沙的粘结性或静电效应。环境流体耦合模拟沙粒在空气中下落受到的拖曳力斯托克斯定律或者更进一步模拟沙粒在水中的沉降流体-颗粒双向耦合这需要引入计算流体动力学CFD。5.3 从仿真到应用创意延伸“掌控电子沙漏”的终点不只是一个仿真程序。你可以将其作为基础拓展出有趣的应用物理教学工具可视化展示颗粒物质的堵塞、拱效应、巴西果效应等。交互艺术装置连接Leap Motion或Kinect用手势控制沙漏的翻转、流速创造动态视觉艺术。游戏机制核心比如一个以沙漏为核心解谜元素的游戏玩家通过控制沙流来触发机关、填充容器、绘制图案。ASMR或冥想工具搭配舒缓的音效沙沙声制作一个极简的、可交互的数字沙漏用于放松。在我实现这个项目的过程中最大的教训是不要过早追求物理完美。最初我试图实现一个完整的DEM结果在500个粒子时就卡顿不已且参数极难调试。后来退回到粒子系统关键物理碰撞、摩擦的混合模型先让整个系统以60FPS流畅运行起来获得了即时的正反馈。然后再逐步加入粒子间力、优化碰撞检测、调整参数最终达到了视觉上足够真实、交互上足够流畅的效果。仿真开发很多时候是在真实感、性能和开发效率之间做权衡找到一个满足你当前目标的最佳平衡点比追求绝对的物理正确更重要。