3D 魔方模拟器使用three.js开发:技术实现详解
3D 魔方模拟器:技术实现详解
一个基于 Three.js 的交互式 3D 魔方,支持鼠标拖拽观察、键盘/方向键旋转层、打乱与还原,附带粒子特效。
目录
- 项目概览
- 技术栈
- 场景搭建
- 魔方建模
- 旋转动画系统
- 交互设计
- 粒子特效
- 庆祝动画
- 完整流程与状态管理
- 选型与细节取舍
魔方运行效果
手动操作和还原
自动打乱
还原
项目概览
页面打开后呈现一个由 26 个圆角方块组成的标准 3x3 魔方,悬浮于星空背景中。用户可用鼠标旋转观察视角,并通过以下方式操作魔方:
- 鼠标悬停:高亮当前面
- 方向键:根据当前悬停块的面朝向,智能推断应旋转的层与方向
- 键盘 U/D/R/L/F/B:直接旋转对应面
- 「打乱」按钮:生成 22~27 步随机操作并执行
- 「还原」按钮:反向执行历史操作,播放庆祝动画
技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Three.js | 0.128.0 | 核心 3D 引擎 |
| OrbitControls | 0.128.0 | 鼠标拖拽旋转/缩放 |
| RoundedBoxGeometry | 0.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 | 环境光 | 0x404060 | 0.6 | — | 基础照明,避免暗面全黑 |
| Key Light | 方向光 | 0xffeedd | 2.2 | (6, 12, 8) | 主光源,暖色调,从右上方打光 |
| Fill Light | 方向光 | 0x4488ff | 0.6 | (-4, 2, -6) | 补光,冷蓝色,从左侧柔化阴影 |
| Rim Light | 方向光 | 0xffffff | 0.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));}}关键点:
Pivot 模式:创建一个临时 Group 作为旋转中心,将方块临时移入该 Group,旋转 Group,动画结束后归还方块。避免了直接操作每个方块的旋转四元数(容易累积误差),也避免了用固定坐标轴的旋转次数问题。
easeInOut 缓动:
t < 0.5 ? 2*t² : 1 - (-2t+2)²/2,使用二次 ease-in-out 曲线,启动平缓、停止干脆。动画后坐标还原:方块归还时,通过世界坐标 → 局部坐标的转换,保留方块经过多次旋转后的真实位置与朝向。
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/Vue | 3D 场景不需要 DOM diff,原生更轻量,代码更紧凑 |
| 架构 | 单文件 HTML | 零配置、双击即运行,便于分享与演示 |
| 几何体 | RoundedBoxGeometry而非BoxGeometry | 微小圆角(0.055 半径)让方块更接近真实魔方手感 |
| 旋转 | Pivot Group + slerp | 比直接操作四元数更简洁,避免万向节锁和累积误差 |
| 缓动 | 二次 easeInOut | 比线性动画更自然,比弹性缓动更干脆 |
| 图层判定 | 世界坐标 → 局部坐标 转换 | 方块可能已经历多次旋转,必须用实际位置判断 |
| 粒子 | THREE.Points+BufferGeometry | 300 粒子以下性能无压力,添加混合模式画面华丽 |
| 渲染器 | ACESFilmicToneMapping | 电影级色调映射,高光柔和、暗部有细节 |
| 像素比 | Math.min(devicePixelRatio, 2) | 高清屏不失真,同时限制 2 倍像素比防止 4K 屏卡顿 |
总结
这个项目虽小(约 600 行),但覆盖了 3D 开发中多个关键技术点:
- 场景搭建:布光、背景、调色
- 数据结构:26 个方块的坐标系统与面材质映射
- 动画系统:队列管理、Pivot 模式、slerp 插值、ease 缓动
- 交互设计:Raycaster 拾取、法线推断、方向键智能推断
- 粒子系统:实时生成/销毁、速度场、池式管理
- 状态管理:多标志位协调并发操作
整体架构简洁清晰,性能表现良好,适合作为 Three.js 互动场景开发的入门参考。
如需下载源码和运行,请搜索🔍绿泡泡 “码来的小朋友”。