Three.js实现3D爱心动画:从数学建模到物理跳动全解析

📅 2026/8/24 1:40:00
Three.js实现3D爱心动画:从数学建模到物理跳动全解析
1. 从静态到心跳一个3D爱心动画的诞生记最近在逛一些创意社区和开发者论坛时经常能看到“3D跳动的爱心”这个主题它几乎成了一个检验图形编程和创意表达能力的“小作业”。很多人觉得不就是画个爱心让它动起来吗但当你真正上手想要做出那种有体积感、有弹性、仿佛拥有生命般“心跳”的动画时才会发现里面藏着不少门道。从最基础的几何体构建到赋予它物理般的弹性运动再到光影质感的打磨每一步都值得深究。今天我就以一个前端开发者和图形爱好者的视角带你从零开始手把手拆解如何实现一个“超高级”的3D跳动爱心。我们将不依赖任何复杂的建模软件纯粹用代码以Three.js为例来创造这个浪漫的几何生命体并深入探讨如何让它跳得真实、跳得好看。这个项目非常适合有一定Web前端基础并对3D图形、动画原理感兴趣的朋友。无论你是想为个人主页添加一个炫酷的装饰还是想深入学习3D图形编程的基本概念亦或是寻找一个有趣的创意编程切入点这个过程都能让你收获颇丰。我们将覆盖从数学建模、场景搭建、动画曲线设计到性能优化的完整链路目标是让你不仅“抄”出一个效果更能理解其背后的“所以然”。2. 构建爱心从数学方程到三维网格一个逼真的3D爱心是后续所有动画的基础。我们不能简单地用两个半圆拼凑那样缺乏立体感。这里我们将采用参数曲面Parametric Surface的方法来生成一个更接近真实心脏形状的网格。2.1 爱心曲面的数学原理一个在3D图形学中常用的爱心曲面参数方程如下x 16 * (sin(t))^3 y 13 * cos(t) - 5 * cos(2*t) - 2 * cos(3*t) - cos(4*t) z s * (某个缩放因子或另一个参数)这里的t和s是参数通常t在[0, 2π]范围内变化用于勾勒爱心在XY平面上的轮廓即我们熟悉的二维爱心形状。而s参数则用于在Z轴方向深度上拉伸形成一个有厚度的三维体。但直接使用这个方程生成的是一个“空心”的曲面壳。为了得到一个实心的、有体积的网格我们需要进行三角化处理。在Three.js中我们可以使用THREE.ParametricGeometry或更现代、更灵活的THREE.BufferGeometry配合手动计算顶点来构建。为了更精细地控制形状和后续的变形动画我推荐手动计算顶点和面。核心思路是将参数t和s离散化生成一个二维的采样点网格。对每个采样点(t_i, s_j)根据修改后的参数方程计算其在3D空间中的坐标(x, y, z)。其中z坐标可以与s线性相关形成厚度也可以引入一些非线性变化让爱心截面更圆润。将这些顶点连接成三角形面片Faces。一个简化的顶点计算示例概念代码function heartVertex(u, v) { // u, v 范围 [0, 1]映射到参数域 const t u * Math.PI * 2; // 对应轮廓参数 const s (v - 0.5) * 2; // 对应厚度参数范围[-1, 1] // 基础爱心轮廓 (x, y) const x 16 * Math.pow(Math.sin(t), 3); const y 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t); // 引入厚度并让顶部更薄、底部更厚更符合生物形态 const thicknessFactor 0.5 0.5 * Math.sin(v * Math.PI); // 使厚度在中间最厚 const z s * thicknessFactor * 3.0; // Z轴坐标控制爱心厚度 // 对Y轴进行缩放让爱心形状更修长或更圆润 const scaleY 0.8; return new THREE.Vector3(x * 0.05, y * 0.05 * scaleY, z * 0.05); // 整体缩放以适应场景 }注意上面的公式和系数需要经过多次微调才能得到一个视觉上舒适的比例和形状。系数0.05是为了将计算出的较大数值缩放到适合Three.js场景的单位尺度。thicknessFactor的引入是一个小技巧它让爱心的边缘薄、中间厚立体感更强。2.2 使用Three.js创建几何体与材质有了顶点生成函数我们就可以构建几何体了。为了提高性能我们直接操作BufferGeometry。import * as THREE from three; function createHeartGeometry(segments 32) { const geometry new THREE.BufferGeometry(); const vertices []; const indices []; const uvs []; const uSegments segments; const vSegments Math.floor(segments / 2); // 1. 生成顶点 for (let v 0; v vSegments; v) { const vNorm v / vSegments; // 归一化的v参数 for (let u 0; u uSegments; u) { const uNorm u / uSegments; // 归一化的u参数 const vertex heartVertex(uNorm, vNorm); vertices.push(vertex.x, vertex.y, vertex.z); // UV坐标用于贴图即使不用贴图也最好生成以备不时之需 uvs.push(uNorm, 1 - vNorm); // 注意V坐标通常翻转 } } // 2. 生成索引三角形面 for (let v 0; v vSegments; v) { for (let u 0; u uSegments; u) { const a v * (uSegments 1) u; const b a 1; const c a (uSegments 1); const d c 1; // 两个三角形组成一个四边形面片 indices.push(a, b, d); indices.push(a, d, c); } } // 3. 将数据存入BufferGeometry geometry.setAttribute(position, new THREE.Float32BufferAttribute(vertices, 3)); geometry.setAttribute(uv, new THREE.Float32BufferAttribute(uvs, 2)); geometry.setIndex(indices); geometry.computeVertexNormals(); // 关键计算法线用于光照 return geometry; }计算顶点法线 (computeVertexNormals) 这一步至关重要它决定了材质如何反射光线是模型看起来是否立体、圆润的关键。一个平滑的法线能让爱心表面呈现柔和的光泽。接下来是材质。为了达到“超高级”的视觉效果我们可以使用THREE.MeshStandardMaterial或THREE.MeshPhysicalMaterial它们支持基于物理的渲染PBR能对环境光、点光源做出非常真实的反应。function createHeartMaterial() { const material new THREE.MeshPhysicalMaterial({ color: 0xff3366, // 经典的爱心粉红色 roughness: 0.2, // 粗糙度低表面光滑 metalness: 0.1, // 略带一点金属感可以提升光泽 clearcoat: 0.5, // 清漆层让表面更有质感 clearcoatRoughness: 0.1, emissive: 0x220000, // 微弱的自发光增加通透感 emissiveIntensity: 0.2, }); return material; }将几何体和材质结合就得到了我们的爱心网格Meshconst heartGeometry createHeartGeometry(64); // 使用较多分段以获得平滑曲面 const heartMaterial createHeartMaterial(); const heartMesh new THREE.Mesh(heartGeometry, heartMaterial); scene.add(heartMesh);3. 注入生命设计逼真的心跳动画曲线静态的爱心模型已经完成接下来是核心环节——让它“跳动”。跳动的本质是顶点位置随时间发生周期性的形变。最朴素的想法是均匀地缩放整个模型但这会显得非常机械和虚假。真实的心脏跳动或我们想象中的弹性跳动包含几个特征快速收缩、短暂保持、缓慢恢复、以及可能伴随的轻微 overshoot过冲和衰减。3.1 基于正弦波的改进型动画函数我们可以用一个经过调制的时间函数pulse(t)来驱动一个缩放因子scale。这个函数应该输出一个在1.0原始大小附近波动的值。一个简单的实现是使用Math.sin但正弦波是对称的不符合“快速收缩、缓慢恢复”的特点。我们可以使用Math.pow或分段函数来塑造我们想要的曲线。这里介绍一个利用Math.sin配合Math.pow来塑造非对称波形的方法function heartbeatPulse(time) { const frequency 1.5; // 每秒跳动次数 const t time * Math.PI * 2 * frequency; // 将时间转换为角度 // 核心利用sin函数在[0, π]区间为非负的特性进行变换 let pulse Math.sin(t); // 将负半周翻转到正半周形成“砰砰”的脉冲效果 if (pulse 0) { pulse -pulse; } // 通过幂次方调整曲线形状值越大波峰越尖波谷越平缓 pulse Math.pow(pulse, 1.5); // 尝试1.5到3之间的值 // 将脉冲值映射到缩放范围例如 [0.85, 1.05] const minScale 0.88; const maxScale 1.08; const scale minScale pulse * (maxScale - minScale); return scale; }这个函数产生的跳动收缩期scale变小和扩张期scale变大的速度是相同的。为了更逼真我们需要让收缩更快。一个更高级的方法是使用自定义的缓动函数Easing Function例如模拟弹簧物理的函数。3.2 模拟弹簧物理的跳动我们可以将爱心的缩放模拟为一个附着在弹簧上的质点。目标缩放是1.0当前缩放是scale。每一帧根据胡克定律弹力与位移成正比和阻尼力来计算加速度进而更新速度和位置缩放值。这种方法能自然产生“快速回弹、缓慢恢复、伴有衰减振荡”的效果。class SpringAnimation { constructor(initialValue 1.0) { this.value initialValue; this.target initialValue; this.velocity 0; // 弹簧参数刚度、阻尼、质量可调 this.stiffness 180; this.damping 15; this.mass 1; } update(deltaTime) { if (!deltaTime || deltaTime 0.1) return; // 防止时间间隔过大导致不稳定 const force (this.target - this.value) * this.stiffness; // 胡克定律 F -k * x const dampingForce -this.velocity * this.damping; // 阻尼力 F -c * v const acceleration (force dampingForce) / this.mass; // a F / m this.velocity acceleration * deltaTime; this.value this.velocity * deltaTime; // 一个简单的触发心跳的方法定期将目标值设为一个冲击值 // 在实际应用中可以由外部事件触发 } setTarget(t) { this.target t; } impulse(impulseStrength) { // 施加一个瞬时速度冲击模拟被“敲击” this.velocity impulseStrength; } } // 在渲染循环中使用 const scaleSpring new SpringAnimation(1.0); let beatTimer 0; const beatInterval 0.8; // 每秒约1.25次心跳 function animate(time) { const deltaTime clock.getDelta(); // 获取上一帧到这一帧的时间差 requestAnimationFrame(animate); beatTimer deltaTime; if (beatTimer beatInterval) { beatTimer 0; // 触发一次心跳短暂地将目标值设小收缩然后立刻恢复 scaleSpring.setTarget(0.85); setTimeout(() scaleSpring.setTarget(1.0), 100); // 100ms后恢复目标为1.0 } scaleSpring.update(deltaTime); const currentScale scaleSpring.value; heartMesh.scale.setScalar(currentScale); renderer.render(scene, camera); }这种基于物理的方法效果极其出色跳动感非常自然且有“弹性”。你可以通过调整stiffness刚度、damping阻尼和mass质量来获得不同质感的跳动比如更Q弹或更沉重。3.3 非均匀缩放与顶点着色器动画全局均匀缩放仍然有些单调。真实的心脏跳动时不同部位的形变程度是不同的。我们可以将缩放因子与顶点的位置信息如Y坐标关联起来让爱心中间部分跳动幅度更大顶部和底部幅度小一些。这可以通过在顶点着色器Vertex Shader中实现性能最优也最灵活。但为了便于理解我们先在JavaScript中实现一个简化版function animateHeartWithDeformation(time, mesh) { const baseScale heartbeatPulse(time); // 获取基础缩放因子 const geometry mesh.geometry; const positionAttribute geometry.attributes.position; const originalPositions geometry.userData.originalPositions; // 需要预先保存原始顶点 if (!originalPositions) { // 第一次运行时保存原始顶点坐标 geometry.userData.originalPositions positionAttribute.array.slice(); return; } const positions positionAttribute.array; const orig geometry.userData.originalPositions; for (let i 0; i positions.length; i 3) { const x orig[i]; const y orig[i 1]; const z orig[i 2]; // 计算基于y坐标的缩放权重中间部分权重高两端权重低 // 假设爱心模型的y坐标范围大致是[-1, 1] const normalizedY (y 1) / 2; // 映射到[0,1] // 使用一个钟形曲线如高斯函数近似来计算权重 const center 0.5; const width 0.4; const weight Math.exp(-Math.pow((normalizedY - center) / width, 2)); // 最终缩放因子基础缩放 权重影响的部分 const deformationScale 1.0 (baseScale - 1.0) * weight * 1.5; // 1.5是形变增强系数 // 应用变形也可以分别对x, y, z应用不同的缩放模拟更复杂的形变 positions[i] x * deformationScale; // X轴缩放 positions[i 1] y * deformationScale; // Y轴缩放 positions[i 2] z * deformationScale; // Z轴缩放 } positionAttribute.needsUpdate true; // 必须标记属性需要更新 geometry.computeVertexNormals(); // 变形后需要重新计算法线 }关键提示在每一帧都更新所有顶点位置并重新计算法线对性能消耗较大。对于顶点数较多的模型这可能会成为性能瓶颈。因此在追求极致性能或复杂动画时将这部分逻辑移植到顶点着色器中是必须的。着色器在GPU中并行运行每个顶点效率极高。4. 氛围营造光影、后期与交互一个高级的3D作品模型和动画只占一半渲染和氛围同样重要。Three.js提供了强大的灯光系统和后期处理能力。4.1 灯光布置技巧不要只用一个简单的AmbientLight环境光了事它会使得模型非常平淡。建议使用经典的三点布光法并加入一些氛围光。主光 (Key Light)一个明亮的DirectionalLight平行光从侧上方如左上前方照射产生主要的明暗对比和阴影定义爱心的形状。const directionalLight new THREE.DirectionalLight(0xffffff, 1.0); directionalLight.position.set(10, 20, 5); scene.add(directionalLight); // 启用阴影如果场景需要 directionalLight.castShadow true;补光 (Fill Light)一个强度较弱的HemisphereLight半球光或另一个DirectionalLight。半球光非常适合模拟天空和地面的环境反射光能让阴影部分不至于死黑颜色也更丰富。const hemisphereLight new THREE.HemisphereLight(0xffffbb, 0x080820, 0.4); scene.add(hemisphereLight);背光/轮廓光 (Back Light/Rim Light)一个从模型后方照射的DirectionalLight或SpotLight强度中等。它能在爱心边缘勾勒出一条亮边极大地增强立体感和与背景的分离度这是让物体“跳”出画面的关键技巧。const rimLight new THREE.DirectionalLight(0x4466ff, 0.6); rimLight.position.set(-5, -10, -10); scene.add(rimLight);4.2 后期处理Post-processing这是实现“超高级”视觉感的秘密武器。Three.js的EffectComposer允许你串联多个后期处理通道。抗锯齿 (SMAA/FXAA)让边缘更平滑。辉光 (Bloom/Glow)让爱心的高光区域如边缘、中心产生光晕营造梦幻、发光的感觉。这对于半透明或高光材质尤其有效。色彩校正 (Color Correction)调整对比度、饱和度、色调让整体色调更符合“爱心”的浪漫或科技感主题。添加辉光效果的简要步骤import { EffectComposer } from three/examples/jsm/postprocessing/EffectComposer.js; import { RenderPass } from three/examples/jsm/postprocessing/RenderPass.js; import { UnrealBloomPass } from three/examples/jsm/postprocessing/UnrealBloomPass.js; // 创建后期处理器 const composer new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); // 创建辉光通道 const bloomPass new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, // 强度 0.4, // 半径 0.9 // 阈值 ); composer.addPass(bloomPass); // 在渲染循环中用 composer.render() 代替 renderer.render() function animate() { // ... 更新动画 ... composer.render(); }辉光的参数需要仔细调试。strength控制亮度radius控制光晕扩散范围threshold控制只有亮度超过此值的区域才会产生辉光。4.3 添加交互性交互能让作品从“展示”升级为“体验”。我们可以添加一些简单的交互鼠标悬停高亮当鼠标移动到爱心上时改变材质颜色或增加自发光强度。const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); function onMouseMove(event) { // 将鼠标位置归一化为设备坐标-1到1 mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObject(heartMesh); if (intersects.length 0) { heartMesh.material.emissive.setHex(0x553333); heartMesh.material.emissiveIntensity 0.5; } else { heartMesh.material.emissive.setHex(0x220000); heartMesh.material.emissiveIntensity 0.2; } } window.addEventListener(mousemove, onMouseMove);点击触发强力心跳点击爱心时给弹簧动画施加一个更强的速度冲击 (impulse)模拟被“戳”了一下后剧烈跳动的效果。function onMouseClick(event) { // ... 同样的射线检测逻辑 ... if (intersects.length 0) { scaleSpring.impulse(5); // 施加一个冲击 // 也可以短暂改变颜色 heartMesh.material.color.setHex(0xff0066); setTimeout(() heartMesh.material.color.setHex(0xff3366), 200); } } window.addEventListener(click, onMouseClick);5. 性能优化与部署要点当效果变得复杂时性能就需要被纳入考量。以下是几个关键的优化点几何体复杂度createHeartGeometry函数中的segments参数直接决定了顶点和面的数量。在保证视觉平滑的前提下尽量使用较低的分段数如32或48。可以在开发时用高模上线前切换为优化后的中低模。动画更新性能如前所述在JavaScript循环中逐顶点更新 (animateHeartWithDeformation) 是性能杀手。最终的优化方案一定是将其移至顶点着色器。在着色器中我们只需要传递一个统一的time变量和每个顶点的原始位置所有计算都在GPU中高效完成。灯光与阴影阴影计算非常昂贵。如果场景简单可以考虑不使用阴影或者只为最重要的主光启用阴影并仔细控制阴影贴图的分辨率和相机的远近裁切面。后期处理开销每个后期处理通道Pass都会增加一次全屏渲染。按需添加。如果只需要辉光可以只加BloomPass。SMAA抗锯齿比默认的FXAA质量更高但稍慢可根据设备性能选择。响应式与画质平衡监听窗口resize事件更新相机、渲染器和后期处理器的尺寸。同时可以根据设备的devicePixelRatio来设置渲染器的分辨率在Retina屏上获得清晰图像但要注意过高分辨率会严重影响性能。一个常见的策略是限制maxDevicePixelRatio为2。function onWindowResize() { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight); // 智能设置像素比 const dpr Math.min(window.devicePixelRatio, 2); renderer.setPixelRatio(dpr); }资源管理如果项目中有纹理、字体等资源确保使用Three.js的LoadingManager进行管理并在加载完成后才开始动画避免页面卡顿。经过以上五个步骤——从数学建模构建网格到设计基于物理的弹性动画再到添加非均匀形变、精心布置光影、运用后期特效最后点缀以交互和进行性能打磨——一个栩栩如生、质感高级的3D跳动爱心便跃然屏上。这个过程不仅仅是实现一个效果更是一次对3D图形编程核心流程的完整实践。你可以尝试更换不同的参数方程来创造其他形状修改弹簧常数来获得不同的跳动质感或者调整辉光参数来营造从科幻到梦幻的不同氛围。最重要的是享受这种用代码创造视觉生命的乐趣。