3D 魔方模拟器使用three.js开发:技术实现详解

3D 魔方模拟器:技术实现详解

一个基于 Three.js 的交互式 3D 魔方,支持鼠标拖拽观察、键盘/方向键旋转层、打乱与还原,附带粒子特效。


目录

  1. 项目概览
  2. 技术栈
  3. 场景搭建
  4. 魔方建模
  5. 旋转动画系统
  6. 交互设计
  7. 粒子特效
  8. 庆祝动画
  9. 完整流程与状态管理
  10. 选型与细节取舍

魔方运行效果

手动操作和还原

自动打乱

还原

项目概览

页面打开后呈现一个由 26 个圆角方块组成的标准 3x3 魔方,悬浮于星空背景中。用户可用鼠标旋转观察视角,并通过以下方式操作魔方:

  • 鼠标悬停:高亮当前面
  • 方向键:根据当前悬停块的面朝向,智能推断应旋转的层与方向
  • 键盘 U/D/R/L/F/B:直接旋转对应面
  • 「打乱」按钮:生成 22~27 步随机操作并执行
  • 「还原」按钮:反向执行历史操作,播放庆祝动画

技术栈

技术版本用途
Three.js0.128.0核心 3D 引擎
OrbitControls0.128.0鼠标拖拽旋转/缩放
RoundedBoxGeometry0.128.0圆角立方体几何体
WebGL底层渲染

全部通过 CDN 引入,零依赖打包。单文件 HTML 即开即用。


场景搭建

3.1 三大件:场景、相机、渲染器

constscene=newTHREE.Scene();constcamera=newTHREE.PerspectiveCamera(40,innerWidth/innerHeight,0.1,100);camera.position.set(4.8,3.6,4.8);constrenderer=newTHREE.WebGLRenderer({antialias:true});renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.2;
  • FOV 设为 40°(偏长焦),减少透视畸变,使魔方看起来更规整。
  • 启用ACES 电影级色调映射,让亮部更柔和、暗部更深邃,提升质感。

3.2 渐变背景

用 2×512 的小 Canvas 绘制垂直渐变,再作为场景背景贴图:

constbgCanvas=document.createElement('canvas');bgCanvas.width=2;bgCanvas.height=512;constbgGrad=bgCtx.createLinearGradient(0,0,0,512);bgGrad.addColorStop(0,'#0a0a2e');// 深蓝bgGrad.addColorStop(0.4,'#050518');bgGrad.addColorStop(1,'#000000');// 纯黑scene.background=newTHREE.CanvasTexture(bgCanvas);

2px 宽度极省内存,渐变效果与 2000px 完全一致。

3.3 三点布光

光源类型颜色强度位置作用
Ambient环境光0x4040600.6基础照明,避免暗面全黑
Key Light方向光0xffeedd2.2(6, 12, 8)主光源,暖色调,从右上方打光
Fill Light方向光0x4488ff0.6(-4, 2, -6)补光,冷蓝色,从左侧柔化阴影
Rim Light方向光0xffffff0.4(-2, -6, 4)轮廓光,从下方勾勒边缘

这套布光模拟了摄影棚经典的三点布光法,让魔方的每个面都有立体感。

3.4 星空粒子

在场景中散布 2000 个白色粒子:

conststarCount=2000;conststarPos=newFloat32Array(starCount*3);for(leti=0;i<starCount*3;i++)starPos[i]=(Math.random()-0.5)*100;
  • 使用BufferGeometry+Float32Array直接写入显存,性能优异。
  • AdditiveBlending叠加混合 +depthWrite: false禁用深度写入,粒子始终可见且叠加后更亮。

魔方建模

4.1 数据结构

constCOLORS={right:0xb71234,// 红left:0xff5800,// 橙up:0xffffff,// 白down:0xffd500,// 黄front:0x009b48,// 绿back:0x0046ad,// 蓝inner:0x111111,// 内部面(深灰)};

颜色方案遵循国际标准魔方配色。

4.2 方块生成

遍历三维空间[-1, 0, 1]³,跳过原点(0,0,0)得到 26 个方块:

constSIZE=0.87;// 方块尺寸constGAP=0.065;// 方块间距for(letx=-1;x<=1;x++){for(lety=-1;y<=1;y++){for(letz=-1;z<=1;z++){if(x===0&&y===0&&z===0)continue;constmats=makeMaterials(x,y,z);constgeo=newTHREE.RoundedBoxGeometry(SIZE,SIZE,SIZE,2,0.055);constmesh=newTHREE.Mesh(geo,mats);mesh.position.set(x*(SIZE+GAP),y*(SIZE+GAP),z*(SIZE+GAP));mesh.userData.gridPos=newTHREE.Vector3(x,y,z);cubeGroup.add(mesh);cubies.push(mesh);}}}

关键设计

  • SIZE + GAP = 0.935决定了方块中心间距,留出 0.065 的缝隙让每个方块清晰可辨。
  • userData.gridPos保存初始网格坐标(x, y, z),用于后续判断方块属于哪个层。
  • 所有方块放在cubeGroup中,方便后续整体旋转(如庆祝动画中旋转整个魔方)。

4.3 材质生成

每个方块有 6 个面,根据它处于魔方的哪一侧决定面颜色:

functionmakeMaterials(x,y,z){return[newMeshStandardMaterial({color:x===1?COLORS.right:COLORS.inner}),// +X → 红newMeshStandardMaterial({color:x===-1?COLORS.left:COLORS.inner}),// -X → 橙newMeshStandardMaterial({color:y===1?COLORS.up:COLORS.inner}),// +Y → 白newMeshStandardMaterial({color:y===-1?COLORS.down:COLORS.inner}),// -Y → 黄newMeshStandardMaterial({color:z===1?COLORS.front:COLORS.inner}),// +Z → 绿newMeshStandardMaterial({color:z===-1?COLORS.back:COLORS.inner}),// -Z → 蓝];}
  • 处于魔方外表面的面赋予对应颜色,内部面统一为深灰色0x111111
  • MeshStandardMaterial支持粗糙度 (roughness: 0.35) 和金属度 (metalness: 0.08),呈现哑光塑料质感。
  • 特别处理(0,0,0)位置的中心块(不存在),因为 26 个方块规则完美覆盖 3×3×3 的所有表面。

旋转动画系统

这是整个项目的核心技术难点。

5.1 获取层的方块

functiongetLayerCubies(axis,layer){constresult=[];constthreshold=(SIZE+GAP)*0.4;for(constcofcubies){constpos=newTHREE.Vector3();c.getWorldPosition(pos);cubeGroup.worldToLocal(pos);constcomp=pos.getComponent(axis);if(Math.abs(comp-layer*(SIZE+GAP))<threshold)result.push(c);}returnresult;}

核心思路

  • 取方块的世界坐标,转换回cubeGroup局部坐标。
  • 检查目标轴的分量是否在该层 ± 阈值范围内。
  • 阈值(SIZE + GAP) * 0.4 ≈ 0.37确保方块被正确归类到最近的层。

为什么用世界坐标再转局部坐标而不是直接用position?因为方块可能已经历多次旋转,初始 position 不再准确。转回局部坐标后,坐标值是准确的层位置。

5.2 旋转执行流程

┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ 选中方块 │ ──► │ 创建临时 pivot │ ──► │ slerp 插值动画 │ ──► │ 拆解回 scene │ │ 移入 pivot │ │ Group 挂载方块 │ │ 280ms ease-out │ │ 更新坐标&旋转 │ └─────────────┘ └──────────────────┘ └─────────────────┘ └──────────────────┘
functionrotateLayer(axis,layer,angle,callback){// 1. 创建临时 Group 作为旋转轴心constpivot=newTHREE.Group();cubeGroup.add(pivot);// 2. 将目标方块从 cubeGroup 移入 pivotfor(constcoflayerCubies){cubeGroup.remove(c);pivot.add(c);}// 3. slerp 球面线性插值动画consttarget=newTHREE.Quaternion().setFromAxisAngle(axVec,angle);functiontick(){constt=Math.min((performance.now()-t0)/duration,1);conste=t<0.5?2*t*t:1-(-2*t+2)**2/2;// ease in-outpivot.quaternion.copy(start).slerp(target,e);// ...}// 4. 动画结束后,方块回归 cubeGroupfor(constkofkids){constwp=newTHREE.Vector3();k.getWorldPosition(wp);constwq=newTHREE.Quaternion();k.getWorldQuaternion(wq);pivot.remove(k);cubeGroup.add(k);k.position.copy(cubeGroup.worldToLocal(wp));k.quaternion.copy(cubeGroup.quaternion.clone().invert().multiply(wq));}}

关键点

  1. Pivot 模式:创建一个临时 Group 作为旋转中心,将方块临时移入该 Group,旋转 Group,动画结束后归还方块。避免了直接操作每个方块的旋转四元数(容易累积误差),也避免了用固定坐标轴的旋转次数问题。

  2. easeInOut 缓动t < 0.5 ? 2*t² : 1 - (-2t+2)²/2,使用二次 ease-in-out 曲线,启动平缓、停止干脆。

  3. 动画后坐标还原:方块归还时,通过世界坐标 → 局部坐标的转换,保留方块经过多次旋转后的真实位置与朝向。

5.3 动画队列

functionqueueMove(axis,layer,angle){animQueue.push({axis,layer,angle});if(animQueue.length===1)processQueue();}functionprocessQueue(){constmove=animQueue[0];rotateLayer(move.axis,move.layer,move.angle,function(){animQueue.shift();processQueue();});}
  • 队列逐帧串行执行,确保每次旋转互不干扰。
  • 打乱操作一口气推入 22~27 个动作,队列按顺序逐个执行。
  • isAnimating标志位全局阻止并发旋转。

交互设计

6.1 鼠标悬停高亮

使用Raycaster检测鼠标是否指向方块:

functiongetCubieAtScreen(clientX,clientY){constrect=renderer.domElement.getBoundingClientRect();pointer.x=((clientX-rect.left)/rect.width)*2-1;pointer.y=-((clientY-rect.top)/rect.height)*2+1;raycaster.setFromCamera(pointer,camera);returnraycaster.intersectObjects(cubies,false);}

高亮逻辑的精妙之处在面法线推断

// 将 raycast 返回的面法线从模型空间转换到 cubeGroup 空间varworldNormal=hit.face.normal.clone().transformDirection(hoveredCubie.matrixWorld);varlocalNormal=worldNormal.applyQuaternion(cubeWorldRotation.invert());hoverFaceAxis=absNormal.indexOf(Math.max(...absNormal));

直接使用 raycast 的face.normal,经过矩阵变换后得到它在cubeGroup局部空间中的朝向。这避免了通过相机视角猜测面的方向——无论魔方经过多少次旋转,法线推断始终准确。

6.2 方向键智能推断

这是本项目最具创新性的交互设计。用户将鼠标悬停在某个方块面上,按下方向键后,系统会自动推断:

  • 哪两条轴是候选(排除当前面法线所在的轴)
  • 旋转哪个方向,能让悬停面在屏幕上向按键方向移动
varcandidates=[];for(vara=0;a<3;a++){if(a!==hoverFaceAxis)candidates.push(a);}// 投影到屏幕坐标,计算旋转前后屏幕坐标的移动方向varsp=wp.clone().project(camera);// 当前屏幕坐标for(varci=0;ci<2;ci++){varax=candidates[ci];for(varsi=0;si<2;si++){varang2=(si===0?1:-1)*Math.PI/2;varep=wp.clone().applyQuaternion(newQuaternion().setFromAxisAngle(axVec,ang2)).project(camera);// 旋转后的屏幕坐标// 计算屏幕移动方向是否匹配按键方向if(key==='ArrowUp')score=ep.y-sp.y;if(key==='ArrowDown')score=sp.y-ep.y;if(key==='ArrowRight')score=ep.x-sp.x;if(key==='ArrowLeft')score=sp.x-ep.x;}}

实际做法是模拟每种可能的旋转,将旋转后的 3D 坐标投影到屏幕,比较屏幕坐标的变化方向与用户按下方向键的匹配度,取最优解。


粒子特效

旋转每一层时触发两段粒子特效:

特效时机粒子数扩散模式持续
emitBurst旋转开始40沿旋转平面法向扩散(环形)400ms
emitSnap旋转到位50球体均匀散开300ms

技术实现

functionemitBurst(center,axis){// 确定垂直于旋转轴的两个方向向量// 粒子在旋转平面上环形扩散vara=(i/count)*Math.PI*2+Math.random()*0.3;varr=0.4+Math.random()*0.6;vel.push({x:(perp1.x*cos(a)+perp2.x*sin(a))*r,y:(perp1.y*cos(a)+perp2.y*sin(a))*r,z:(perp1.z*cos(a)+perp2.z*sin(a))*r,});}

每帧更新粒子位置并逐渐淡出:

functionupdateEffects(){for(consteofactiveEffects){constt=(now-e.start)/e.dur;if(t>=1){/* 销毁 */continue;}for(varj=0;j<e.count;j++){p[j*3]+=e.vel[j].x*0.016;p[j*3+1]+=e.vel[j].y*0.016;p[j*3+2]+=e.vel[j].z*0.016;}e.pts.material.opacity=1-t;}}
  • 粒子系统使用BufferGeometry+Float32Array,可直接操作 GPU 端 buffer。
  • AdditiveBlending让重叠粒子更亮,视觉上更华丽。

庆祝动画

还原完成后触发 3 秒庆祝动画:

functionstartCelebration(){celebrationActive=true;// 所有方块发蓝光setLayerGlow(allCubies,0.6);// 生成 300 个彩色粒子createParticles();}functionupdateCelebration(){constprogress=Math.min(elapsed/3000,1);// 粒子渐隐 + 膨胀particleSystem.material.opacity=max(0,1-progress*1.2);particleSystem.material.size=0.15+progress*0.3;// 蓝光渐弱setLayerGlow(allCubies,(1-progress*1.5)*0.6);// 整个魔方绕 Y 轴旋转一周cubeGroup.rotation.y=progress*Math.PI*2;}

动画时间线:蓝光 + 粒子同时出现 → 粒子逐渐淡出并变大 → 蓝光消退 → 魔方旋转一周回到原位。


完整流程与状态管理

状态变量: isAnimating — 当前是否有旋转动画播放中 animQueue — 动画队列 moveHistory — 操作历史(用于还原) celebrationActive — 庆祝动画播放中 hoveredCubie — 当前鼠标悬停方块 hoverFaceAxis — 悬停面的法线轴

打乱流程

scramble() → 生成 22~27 步随机操作 → 推入 animQueue → 推入 moveHistory(记录反向操作) → processQueue() 逐个执行

随机性保证:相邻操作不旋转同一轴(a !== last),避免无意义的来回拧。

还原流程

solve() → moveHistory 取反转为动画队列 → 清空 moveHistory → processQueue() 逐个执行 → 队列清空后触发 celebrate()

选型与细节取舍

决策方案理由
框架原生 Three.js,无 React/Vue3D 场景不需要 DOM diff,原生更轻量,代码更紧凑
架构单文件 HTML零配置、双击即运行,便于分享与演示
几何体RoundedBoxGeometry而非BoxGeometry微小圆角(0.055 半径)让方块更接近真实魔方手感
旋转Pivot Group + slerp比直接操作四元数更简洁,避免万向节锁和累积误差
缓动二次 easeInOut比线性动画更自然,比弹性缓动更干脆
图层判定世界坐标 → 局部坐标 转换方块可能已经历多次旋转,必须用实际位置判断
粒子THREE.Points+BufferGeometry300 粒子以下性能无压力,添加混合模式画面华丽
渲染器ACESFilmicToneMapping电影级色调映射,高光柔和、暗部有细节
像素比Math.min(devicePixelRatio, 2)高清屏不失真,同时限制 2 倍像素比防止 4K 屏卡顿

总结

这个项目虽小(约 600 行),但覆盖了 3D 开发中多个关键技术点:

  • 场景搭建:布光、背景、调色
  • 数据结构:26 个方块的坐标系统与面材质映射
  • 动画系统:队列管理、Pivot 模式、slerp 插值、ease 缓动
  • 交互设计:Raycaster 拾取、法线推断、方向键智能推断
  • 粒子系统:实时生成/销毁、速度场、池式管理
  • 状态管理:多标志位协调并发操作

整体架构简洁清晰,性能表现良好,适合作为 Three.js 互动场景开发的入门参考。

如需下载源码和运行,请搜索🔍绿泡泡 “码来的小朋友”。