glm-5.1 3d 魔方简要测试

2026-04-29 10:222阅读0评论SEO基础
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

从上面两篇帖子继续

Deepseek v4 pro 3d魔方简要测试 国产替代
使用 cherry studio 客户端, 未设置系统提示词, 思考等级:auto 提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 …
小米mimo-v2.5-pro 3d 魔方简要测试 开发调优
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB) 概要: 相当完美,鼠标操作底部那一排似乎有些转不…

测试环境 cherry studio 思考等级 : auto 上游 opencode-go
思考时间149.7s

提示词

你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。

https://imgbed.snemc.cn/i/76632ecccb36.gif(图片大于 4 MB)

概述:
没有任何问题,相当丝滑

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>高保真 3x3 魔方模拟器</title> <style> :root { --bg: #1a1a1a; --panel-bg: rgba(30, 30, 30, 0.85); --accent: #4fd1c5; --text: #e2e8f0; --btn-hover: #38b2ac; } body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: var(--text); } #canvas-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; } #ui-panel { position: absolute; top: 20px; left: 20px; z-index: 10; background: var(--panel-bg); padding: 15px 25px; border-radius: 12px; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 10px; } h1 { margin: 0 0 5px 0; font-size: 1.2rem; color: var(--accent); letter-spacing: 1px; } p { margin: 0; font-size: 0.85rem; color: #a0aec0; } .btn-group { display: flex; gap: 10px; margin-top: 5px; } button { background-color: var(--accent); color: #1a202c; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; } button:hover { background-color: var(--btn-hover); transform: translateY(-1px); box-shadow: 0 2px 10px rgba(79, 209, 197, 0.4); } button:active { transform: translateY(0); } #toast { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(100px); background: rgba(79, 209, 197, 0.9); color: #1a202c; padding: 10px 25px; border-radius: 20px; font-weight: 600; opacity: 0; transition: all 0.3s ease; z-index: 20; } #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } </style> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/", "@tweenjs/tween.js": "https://unpkg.com/@tweenjs/tween.js@23.1.3/dist/tween.esm.js" } } </script> </head> <body> <div id="ui-panel"> <h1>3x3 魔方模拟器</h1> <p>左键拖拽旋转层 | 右键拖拽旋转视角</p> <div class="btn-group"> <button id="btn-scramble">Scramble</button> <button id="btn-reset">Reset</button> </div> </div> <div id="toast"></div> <div id="canvas-container"></div> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import * as TWEEN from '@tweenjs/tween.js'; // ========================================== // 全局状态与配置 // ========================================== let cubies = []; // 存储所有 27 个小方块 let isAnimating = false; // 是否正在播放动画(打乱或磁吸回弹) let isDragging = false; // 是否正在拖拽旋转层 let hasChosenAxis = false; // 是否已经通过算法确定了旋转轴 let startMouse = new THREE.Vector2(); // 鼠标按下时的 NDC 坐标 let currentMouse = new THREE.Vector2(); // 当前鼠标的 NDC 坐标 let selectedCubie = null; // 射线检测到的初始方块 let faceNormal = new THREE.Vector3(); // 初始点击面的世界法线 let intersectPoint = new THREE.Vector3(); // 初始点击的交点 let currentAxis = ''; // 当前旋转的轴 'x', 'y', 'z' let targetLayerIndex = 0; // 当前旋转的层级 -1, 0, 1 let pivot; // 临时轴心对象 let projDelta = new THREE.Vector2(); // 投影算法计算出的 2D 方向单位向量 let startRotation = 0; // 拖拽开始时 pivot 的旋转角度 // 魔方尺寸配置 const SPACING = 1.02; // 物理间隙 const CUBIE_SIZE = 0.95; // 小方块尺寸 // ========================================== // 场景初始化 // ========================================== const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(4, 4, 6); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.getElementById('canvas-container').appendChild(renderer.domElement); // 右键拖拽旋转视角配置 const controls = new OrbitControls(camera, renderer.domElement); controls.mouseButtons = { LEFT: null, // 左键由我们自己的手势算法接管 MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; controls.enablePan = false; controls.minDistance = 5; controls.maxDistance = 15; // ========================================== // 光影环境 // ========================================== const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 50; dirLight.shadow.camera.left = -5; dirLight.shadow.camera.right = 5; dirLight.shadow.camera.top = 5; dirLight.shadow.camera.bottom = -5; dirLight.shadow.bias = -0.001; scene.add(dirLight); // ========================================== // Canvas API 程序化生成贴图 // ========================================== const FACE_COLORS = { right: '#B71234', // 红 left: '#FF5800', // 橙 up: '#FFFFFF', // 白 down: '#FFD500', // 黄 front: '#0046AD', // 蓝 back: '#009B48', // 绿 inside: '#111111' // 内部黑 }; function createFaceTexture(color) { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 绘制黑色塑料底色(带微小圆角模拟边缘) ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, size, size); // 如果不是内部面,绘制带圆角的贴纸和高光 if (color !== FACE_COLORS.inside) { const margin = 16; const radius = 30; const sx = margin, sy = margin, sw = size - margin * 2, sh = size - margin * 2; // 绘制圆角矩形贴纸路径 ctx.beginPath(); ctx.moveTo(sx + radius, sy); ctx.lineTo(sx + sw - radius, sy); ctx.quadraticCurveTo(sx + sw, sy, sx + sw, sy + radius); ctx.lineTo(sx + sw, sy + sh - radius); ctx.quadraticCurveTo(sx + sw, sy + sh, sx + sw - radius, sy + sh); ctx.lineTo(sx + radius, sy + sh); ctx.quadraticCurveTo(sx, sy + sh, sx, sy + sh - radius); ctx.lineTo(sx, sy + radius); ctx.quadraticCurveTo(sx, sy, sx + radius, sy); ctx.closePath(); // 填充贴纸底色 ctx.fillStyle = color; ctx.fill(); // 添加从左上到右下的高光渐变,模拟塑料质感 const gradient = ctx.createLinearGradient(sx, sy, sx + sw, sy + sh); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.05)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); ctx.fillStyle = gradient; ctx.fill(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // 预生成所有纹理 const textures = { right: createFaceTexture(FACE_COLORS.right), left: createFaceTexture(FACE_COLORS.left), up: createFaceTexture(FACE_COLORS.up), down: createFaceTexture(FACE_COLORS.down), front: createFaceTexture(FACE_COLORS.front), back: createFaceTexture(FACE_COLORS.back), inside: createFaceTexture(FACE_COLORS.inside) }; // ========================================== // 模型构建 & 动态层级 // ========================================== function buildCube() { // 清空旧方块 cubies.forEach(c => scene.remove(c)); cubies = []; const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { // 根据方块所在空间位置决定6个面的贴纸 const materials = [ new THREE.MeshStandardMaterial({ map: x === 1 ? textures.right : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: x === -1 ? textures.left : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === 1 ? textures.up : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === -1 ? textures.down : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === 1 ? textures.front : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === -1 ? textures.back : textures.inside, roughness: 0.4, metalness: 0.1 }), ]; const mesh = new THREE.Mesh(geometry, materials); mesh.position.set(x * SPACING, y * SPACING, z * SPACING); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); cubies.push(mesh); } } } } buildCube(); // ========================================== // 核心交互算法:手势投影与旋转轴确定 // ========================================== const raycaster = new THREE.Raycaster(); // 获取某3D轴投影到2D屏幕后的向量,以及对应的物理旋转方向 function getProjectionData(axisStr) { const axisVec = new THREE.Vector3(); axisVec[axisStr] = 1; // 构造 3D 轴单位向量 (1,0,0) 等 // 算法核心:3D旋转映射到2D屏幕 // 假设绕 axisStr 轴发生正向微小旋转,被点击面的法线方向将发生偏移。 // 偏移向量 deltaPos3D = cross(axisVec, faceNormal) const deltaPos3D = new THREE.Vector3().crossVectors(axisVec, faceNormal); // 将交点和偏移后的交点投影到屏幕空间,求差得到 2D 投影方向向量 const p0 = intersectPoint.clone().project(camera); const p1 = intersectPoint.clone().add(deltaPos3D).project(camera); // 返回 2D 屏幕空间中的方向向量 (NDC坐标系) return new THREE.Vector2(p1.x - p0.x, p1.y - p0.y); } function onPointerDown(event) { if (event.button !== 0 || isAnimating) return; // 仅处理左键,且不在动画中 startMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); raycaster.setFromCamera(startMouse, camera); const intersects = raycaster.intersectObjects(cubies); if (intersects.length > 0) { isDragging = true; controls.enabled = false; // 禁用视角控制,防止冲突 selectedCubie = intersects[0].object; intersectPoint = intersects[0].point.clone(); // 计算世界法线:将局部法线转换到世界空间,并四舍五入消除浮点误差 faceNormal = intersects[0].face.normal.clone() .transformDirection(selectedCubie.matrixWorld) .round(); hasChosenAxis = false; currentAxis = ''; startRotation = 0; } } function onPointerMove(event) { if (!isDragging || isAnimating) return; currentMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); const NDC_delta2D = currentMouse.clone().sub(startMouse); // 若还未确定旋转轴(手势刚开始),需要达到一定阈值才锁定 if (!hasChosenAxis) { if (NDC_delta2D.length() < 0.01) return; // 死区防抖 let axis1, axis2; // 排除法线方向的轴,锁定两个潜在旋转轴 if (Math.abs(faceNormal.x) > 0.5) { axis1 = 'y'; axis2 = 'z'; } else if (Math.abs(faceNormal.y) > 0.5) { axis1 = 'x'; axis2 = 'z'; } else { axis1 = 'x'; axis2 = 'y'; } // 基于投影向量的手势识别算法核心: // 将3D潜在轴的旋转效果投影到2D,与用户的2D滑动向量计算点积 const proj1 = getProjectionData(axis1); const proj2 = getProjectionData(axis2); const dot1 = NDC_delta2D.dot(proj1); const dot2 = NDC_delta2D.dot(proj2); // 选择匹配度最高的轴 if (Math.abs(dot1) > Math.abs(dot2)) { currentAxis = axis1; projDelta = proj1; } else { currentAxis = axis2; projDelta = proj2; } // 动态层级筛选:根据点击方块在当前轴的世界坐标确定层级 targetLayerIndex = Math.round(selectedCubie.position[currentAxis] / SPACING); // 创建临时 Pivot 并挂载相应层级的方块 pivot = new THREE.Object3D(); pivot.position[currentAxis] = targetLayerIndex * SPACING; scene.add(pivot); // Pivot 变换机制核心:使用 attach 保持世界矩阵 cubies.forEach(cubie => { if (Math.round(cubie.position[currentAxis] / SPACING) === targetLayerIndex) { pivot.attach(cubie); } }); hasChosenAxis = true; } // 实时跟手:1:1 计算旋转角度 // 公式:deltaAngle = dot(2D滑动距离, 2D投影方向) / dot(投影方向, 投影方向) // 这完美解决了方向反转的Bug,且从任何视角操作都直观! const deltaAngle = NDC_delta2D.dot(projDelta) / projDelta.dot(projDelta); pivot.rotation[currentAxis] = deltaAngle; } function onPointerUp(event) { if (!isDragging) return; isDragging = false; if (!hasChosenAxis) { controls.enabled = true; return; } // 磁吸效果:松开鼠标后,自动对齐到最近的 90 度倍数 const currentAngle = pivot.rotation[currentAxis]; const targetAngle = Math.round(currentAngle / (Math.PI / 2)) * (Math.PI / 2); isAnimating = true; // 使用 Tween.js 播放回弹动画 const tweenObj = { r: currentAngle }; new TWEEN.Tween(tweenObj) .to({ r: targetAngle }, 250) .easing(TWEEN.Easing.Back.Out) .onUpdate(() => { pivot.rotation[currentAxis] = tweenObj.r; }) .onComplete(() => { finishRotation(targetAngle); }) .start(); } function finishRotation(finalAngle) { pivot.rotation[currentAxis] = finalAngle; // 坐标清洗:消除浮点累积误差,防止魔方“散架” // 将方块从 pivot 放回 scene,利用 scene.attach 自动处理矩阵 const children = [...pivot.children]; children.forEach(child => { scene.attach(child); // 位置清洗:四舍五入到 Spacing 的整数倍 child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; // 旋转清洗:四舍五入到 90 度倍数 child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); // 清理临时轴心对象 scene.remove(pivot); isAnimating = false; controls.enabled = true; } // ========================================== // 打乱与重置功能 // ========================================== function scrambleCube() { if (isAnimating || isDragging) return; isAnimating = true; showToast("Scrambling..."); const axes = ['x', 'y', 'z']; const layers = [-1, 0, 1]; const moves = 20; let moveList = []; for (let i = 0; i < moves; i++) { moveList.push({ axis: axes[Math.floor(Math.random() * 3)], layer: layers[Math.floor(Math.random() * 3)], dir: Math.random() > 0.5 ? 1 : -1 }); } let index = 0; function doMove() { if (index >= moveList.length) { isAnimating = false; showToast("Ready!"); return; } const move = moveList[index]; const tempPivot = new THREE.Object3D(); tempPivot.position[move.axis] = move.layer * SPACING; scene.add(tempPivot); cubies.forEach(cubie => { if (Math.round(cubie.position[move.axis] / SPACING) === move.layer) { tempPivot.attach(cubie); } }); const targetAngle = move.dir * (Math.PI / 2); const tempTweenObj = { r: 0 }; new TWEEN.Tween(tempTweenObj) .to({ r: targetAngle }, 100) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(() => { tempPivot.rotation[move.axis] = tempTweenObj.r; }) .onComplete(() => { finishRotationForScramble(tempPivot, targetAngle); index++; doMove(); }) .start(); } doMove(); } function finishRotationForScramble(pivotObj) { const children = [...pivotObj.children]; children.forEach(child => { scene.attach(child); child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); scene.remove(pivotObj); } function resetCube() { if (isAnimating || isDragging) return; buildCube(); showToast("Reset Done!"); } // 提示弹窗 function showToast(msg) { const toast = document.getElementById('toast'); toast.innerText = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); } // ========================================== // 事件绑定与渲染循环 // ========================================== window.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); document.getElementById('btn-scramble').addEventListener('click', scrambleCube); document.getElementById('btn-reset').addEventListener('click', resetCube); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html> 网友解答:


--【壹】--:

从上面两篇帖子继续

Deepseek v4 pro 3d魔方简要测试 国产替代
使用 cherry studio 客户端, 未设置系统提示词, 思考等级:auto 提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 …
小米mimo-v2.5-pro 3d 魔方简要测试 开发调优
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB) 概要: 相当完美,鼠标操作底部那一排似乎有些转不…

测试环境 cherry studio 思考等级 : auto 上游 opencode-go
思考时间149.7s

提示词

你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。

https://imgbed.snemc.cn/i/76632ecccb36.gif(图片大于 4 MB)

概述:
没有任何问题,相当丝滑

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>高保真 3x3 魔方模拟器</title> <style> :root { --bg: #1a1a1a; --panel-bg: rgba(30, 30, 30, 0.85); --accent: #4fd1c5; --text: #e2e8f0; --btn-hover: #38b2ac; } body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: var(--text); } #canvas-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; } #ui-panel { position: absolute; top: 20px; left: 20px; z-index: 10; background: var(--panel-bg); padding: 15px 25px; border-radius: 12px; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 10px; } h1 { margin: 0 0 5px 0; font-size: 1.2rem; color: var(--accent); letter-spacing: 1px; } p { margin: 0; font-size: 0.85rem; color: #a0aec0; } .btn-group { display: flex; gap: 10px; margin-top: 5px; } button { background-color: var(--accent); color: #1a202c; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; } button:hover { background-color: var(--btn-hover); transform: translateY(-1px); box-shadow: 0 2px 10px rgba(79, 209, 197, 0.4); } button:active { transform: translateY(0); } #toast { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(100px); background: rgba(79, 209, 197, 0.9); color: #1a202c; padding: 10px 25px; border-radius: 20px; font-weight: 600; opacity: 0; transition: all 0.3s ease; z-index: 20; } #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } </style> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/", "@tweenjs/tween.js": "https://unpkg.com/@tweenjs/tween.js@23.1.3/dist/tween.esm.js" } } </script> </head> <body> <div id="ui-panel"> <h1>3x3 魔方模拟器</h1> <p>左键拖拽旋转层 | 右键拖拽旋转视角</p> <div class="btn-group"> <button id="btn-scramble">Scramble</button> <button id="btn-reset">Reset</button> </div> </div> <div id="toast"></div> <div id="canvas-container"></div> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import * as TWEEN from '@tweenjs/tween.js'; // ========================================== // 全局状态与配置 // ========================================== let cubies = []; // 存储所有 27 个小方块 let isAnimating = false; // 是否正在播放动画(打乱或磁吸回弹) let isDragging = false; // 是否正在拖拽旋转层 let hasChosenAxis = false; // 是否已经通过算法确定了旋转轴 let startMouse = new THREE.Vector2(); // 鼠标按下时的 NDC 坐标 let currentMouse = new THREE.Vector2(); // 当前鼠标的 NDC 坐标 let selectedCubie = null; // 射线检测到的初始方块 let faceNormal = new THREE.Vector3(); // 初始点击面的世界法线 let intersectPoint = new THREE.Vector3(); // 初始点击的交点 let currentAxis = ''; // 当前旋转的轴 'x', 'y', 'z' let targetLayerIndex = 0; // 当前旋转的层级 -1, 0, 1 let pivot; // 临时轴心对象 let projDelta = new THREE.Vector2(); // 投影算法计算出的 2D 方向单位向量 let startRotation = 0; // 拖拽开始时 pivot 的旋转角度 // 魔方尺寸配置 const SPACING = 1.02; // 物理间隙 const CUBIE_SIZE = 0.95; // 小方块尺寸 // ========================================== // 场景初始化 // ========================================== const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(4, 4, 6); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.getElementById('canvas-container').appendChild(renderer.domElement); // 右键拖拽旋转视角配置 const controls = new OrbitControls(camera, renderer.domElement); controls.mouseButtons = { LEFT: null, // 左键由我们自己的手势算法接管 MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; controls.enablePan = false; controls.minDistance = 5; controls.maxDistance = 15; // ========================================== // 光影环境 // ========================================== const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 50; dirLight.shadow.camera.left = -5; dirLight.shadow.camera.right = 5; dirLight.shadow.camera.top = 5; dirLight.shadow.camera.bottom = -5; dirLight.shadow.bias = -0.001; scene.add(dirLight); // ========================================== // Canvas API 程序化生成贴图 // ========================================== const FACE_COLORS = { right: '#B71234', // 红 left: '#FF5800', // 橙 up: '#FFFFFF', // 白 down: '#FFD500', // 黄 front: '#0046AD', // 蓝 back: '#009B48', // 绿 inside: '#111111' // 内部黑 }; function createFaceTexture(color) { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 绘制黑色塑料底色(带微小圆角模拟边缘) ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, size, size); // 如果不是内部面,绘制带圆角的贴纸和高光 if (color !== FACE_COLORS.inside) { const margin = 16; const radius = 30; const sx = margin, sy = margin, sw = size - margin * 2, sh = size - margin * 2; // 绘制圆角矩形贴纸路径 ctx.beginPath(); ctx.moveTo(sx + radius, sy); ctx.lineTo(sx + sw - radius, sy); ctx.quadraticCurveTo(sx + sw, sy, sx + sw, sy + radius); ctx.lineTo(sx + sw, sy + sh - radius); ctx.quadraticCurveTo(sx + sw, sy + sh, sx + sw - radius, sy + sh); ctx.lineTo(sx + radius, sy + sh); ctx.quadraticCurveTo(sx, sy + sh, sx, sy + sh - radius); ctx.lineTo(sx, sy + radius); ctx.quadraticCurveTo(sx, sy, sx + radius, sy); ctx.closePath(); // 填充贴纸底色 ctx.fillStyle = color; ctx.fill(); // 添加从左上到右下的高光渐变,模拟塑料质感 const gradient = ctx.createLinearGradient(sx, sy, sx + sw, sy + sh); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.05)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); ctx.fillStyle = gradient; ctx.fill(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // 预生成所有纹理 const textures = { right: createFaceTexture(FACE_COLORS.right), left: createFaceTexture(FACE_COLORS.left), up: createFaceTexture(FACE_COLORS.up), down: createFaceTexture(FACE_COLORS.down), front: createFaceTexture(FACE_COLORS.front), back: createFaceTexture(FACE_COLORS.back), inside: createFaceTexture(FACE_COLORS.inside) }; // ========================================== // 模型构建 & 动态层级 // ========================================== function buildCube() { // 清空旧方块 cubies.forEach(c => scene.remove(c)); cubies = []; const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { // 根据方块所在空间位置决定6个面的贴纸 const materials = [ new THREE.MeshStandardMaterial({ map: x === 1 ? textures.right : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: x === -1 ? textures.left : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === 1 ? textures.up : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === -1 ? textures.down : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === 1 ? textures.front : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === -1 ? textures.back : textures.inside, roughness: 0.4, metalness: 0.1 }), ]; const mesh = new THREE.Mesh(geometry, materials); mesh.position.set(x * SPACING, y * SPACING, z * SPACING); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); cubies.push(mesh); } } } } buildCube(); // ========================================== // 核心交互算法:手势投影与旋转轴确定 // ========================================== const raycaster = new THREE.Raycaster(); // 获取某3D轴投影到2D屏幕后的向量,以及对应的物理旋转方向 function getProjectionData(axisStr) { const axisVec = new THREE.Vector3(); axisVec[axisStr] = 1; // 构造 3D 轴单位向量 (1,0,0) 等 // 算法核心:3D旋转映射到2D屏幕 // 假设绕 axisStr 轴发生正向微小旋转,被点击面的法线方向将发生偏移。 // 偏移向量 deltaPos3D = cross(axisVec, faceNormal) const deltaPos3D = new THREE.Vector3().crossVectors(axisVec, faceNormal); // 将交点和偏移后的交点投影到屏幕空间,求差得到 2D 投影方向向量 const p0 = intersectPoint.clone().project(camera); const p1 = intersectPoint.clone().add(deltaPos3D).project(camera); // 返回 2D 屏幕空间中的方向向量 (NDC坐标系) return new THREE.Vector2(p1.x - p0.x, p1.y - p0.y); } function onPointerDown(event) { if (event.button !== 0 || isAnimating) return; // 仅处理左键,且不在动画中 startMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); raycaster.setFromCamera(startMouse, camera); const intersects = raycaster.intersectObjects(cubies); if (intersects.length > 0) { isDragging = true; controls.enabled = false; // 禁用视角控制,防止冲突 selectedCubie = intersects[0].object; intersectPoint = intersects[0].point.clone(); // 计算世界法线:将局部法线转换到世界空间,并四舍五入消除浮点误差 faceNormal = intersects[0].face.normal.clone() .transformDirection(selectedCubie.matrixWorld) .round(); hasChosenAxis = false; currentAxis = ''; startRotation = 0; } } function onPointerMove(event) { if (!isDragging || isAnimating) return; currentMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); const NDC_delta2D = currentMouse.clone().sub(startMouse); // 若还未确定旋转轴(手势刚开始),需要达到一定阈值才锁定 if (!hasChosenAxis) { if (NDC_delta2D.length() < 0.01) return; // 死区防抖 let axis1, axis2; // 排除法线方向的轴,锁定两个潜在旋转轴 if (Math.abs(faceNormal.x) > 0.5) { axis1 = 'y'; axis2 = 'z'; } else if (Math.abs(faceNormal.y) > 0.5) { axis1 = 'x'; axis2 = 'z'; } else { axis1 = 'x'; axis2 = 'y'; } // 基于投影向量的手势识别算法核心: // 将3D潜在轴的旋转效果投影到2D,与用户的2D滑动向量计算点积 const proj1 = getProjectionData(axis1); const proj2 = getProjectionData(axis2); const dot1 = NDC_delta2D.dot(proj1); const dot2 = NDC_delta2D.dot(proj2); // 选择匹配度最高的轴 if (Math.abs(dot1) > Math.abs(dot2)) { currentAxis = axis1; projDelta = proj1; } else { currentAxis = axis2; projDelta = proj2; } // 动态层级筛选:根据点击方块在当前轴的世界坐标确定层级 targetLayerIndex = Math.round(selectedCubie.position[currentAxis] / SPACING); // 创建临时 Pivot 并挂载相应层级的方块 pivot = new THREE.Object3D(); pivot.position[currentAxis] = targetLayerIndex * SPACING; scene.add(pivot); // Pivot 变换机制核心:使用 attach 保持世界矩阵 cubies.forEach(cubie => { if (Math.round(cubie.position[currentAxis] / SPACING) === targetLayerIndex) { pivot.attach(cubie); } }); hasChosenAxis = true; } // 实时跟手:1:1 计算旋转角度 // 公式:deltaAngle = dot(2D滑动距离, 2D投影方向) / dot(投影方向, 投影方向) // 这完美解决了方向反转的Bug,且从任何视角操作都直观! const deltaAngle = NDC_delta2D.dot(projDelta) / projDelta.dot(projDelta); pivot.rotation[currentAxis] = deltaAngle; } function onPointerUp(event) { if (!isDragging) return; isDragging = false; if (!hasChosenAxis) { controls.enabled = true; return; } // 磁吸效果:松开鼠标后,自动对齐到最近的 90 度倍数 const currentAngle = pivot.rotation[currentAxis]; const targetAngle = Math.round(currentAngle / (Math.PI / 2)) * (Math.PI / 2); isAnimating = true; // 使用 Tween.js 播放回弹动画 const tweenObj = { r: currentAngle }; new TWEEN.Tween(tweenObj) .to({ r: targetAngle }, 250) .easing(TWEEN.Easing.Back.Out) .onUpdate(() => { pivot.rotation[currentAxis] = tweenObj.r; }) .onComplete(() => { finishRotation(targetAngle); }) .start(); } function finishRotation(finalAngle) { pivot.rotation[currentAxis] = finalAngle; // 坐标清洗:消除浮点累积误差,防止魔方“散架” // 将方块从 pivot 放回 scene,利用 scene.attach 自动处理矩阵 const children = [...pivot.children]; children.forEach(child => { scene.attach(child); // 位置清洗:四舍五入到 Spacing 的整数倍 child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; // 旋转清洗:四舍五入到 90 度倍数 child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); // 清理临时轴心对象 scene.remove(pivot); isAnimating = false; controls.enabled = true; } // ========================================== // 打乱与重置功能 // ========================================== function scrambleCube() { if (isAnimating || isDragging) return; isAnimating = true; showToast("Scrambling..."); const axes = ['x', 'y', 'z']; const layers = [-1, 0, 1]; const moves = 20; let moveList = []; for (let i = 0; i < moves; i++) { moveList.push({ axis: axes[Math.floor(Math.random() * 3)], layer: layers[Math.floor(Math.random() * 3)], dir: Math.random() > 0.5 ? 1 : -1 }); } let index = 0; function doMove() { if (index >= moveList.length) { isAnimating = false; showToast("Ready!"); return; } const move = moveList[index]; const tempPivot = new THREE.Object3D(); tempPivot.position[move.axis] = move.layer * SPACING; scene.add(tempPivot); cubies.forEach(cubie => { if (Math.round(cubie.position[move.axis] / SPACING) === move.layer) { tempPivot.attach(cubie); } }); const targetAngle = move.dir * (Math.PI / 2); const tempTweenObj = { r: 0 }; new TWEEN.Tween(tempTweenObj) .to({ r: targetAngle }, 100) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(() => { tempPivot.rotation[move.axis] = tempTweenObj.r; }) .onComplete(() => { finishRotationForScramble(tempPivot, targetAngle); index++; doMove(); }) .start(); } doMove(); } function finishRotationForScramble(pivotObj) { const children = [...pivotObj.children]; children.forEach(child => { scene.attach(child); child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); scene.remove(pivotObj); } function resetCube() { if (isAnimating || isDragging) return; buildCube(); showToast("Reset Done!"); } // 提示弹窗 function showToast(msg) { const toast = document.getElementById('toast'); toast.innerText = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); } // ========================================== // 事件绑定与渲染循环 // ========================================== window.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); document.getElementById('btn-scramble').addEventListener('click', scrambleCube); document.getElementById('btn-reset').addEventListener('click', resetCube); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html>

问题描述:

从上面两篇帖子继续

Deepseek v4 pro 3d魔方简要测试 国产替代
使用 cherry studio 客户端, 未设置系统提示词, 思考等级:auto 提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 …
小米mimo-v2.5-pro 3d 魔方简要测试 开发调优
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB) 概要: 相当完美,鼠标操作底部那一排似乎有些转不…

测试环境 cherry studio 思考等级 : auto 上游 opencode-go
思考时间149.7s

提示词

你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。

https://imgbed.snemc.cn/i/76632ecccb36.gif(图片大于 4 MB)

概述:
没有任何问题,相当丝滑

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>高保真 3x3 魔方模拟器</title> <style> :root { --bg: #1a1a1a; --panel-bg: rgba(30, 30, 30, 0.85); --accent: #4fd1c5; --text: #e2e8f0; --btn-hover: #38b2ac; } body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: var(--text); } #canvas-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; } #ui-panel { position: absolute; top: 20px; left: 20px; z-index: 10; background: var(--panel-bg); padding: 15px 25px; border-radius: 12px; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 10px; } h1 { margin: 0 0 5px 0; font-size: 1.2rem; color: var(--accent); letter-spacing: 1px; } p { margin: 0; font-size: 0.85rem; color: #a0aec0; } .btn-group { display: flex; gap: 10px; margin-top: 5px; } button { background-color: var(--accent); color: #1a202c; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; } button:hover { background-color: var(--btn-hover); transform: translateY(-1px); box-shadow: 0 2px 10px rgba(79, 209, 197, 0.4); } button:active { transform: translateY(0); } #toast { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(100px); background: rgba(79, 209, 197, 0.9); color: #1a202c; padding: 10px 25px; border-radius: 20px; font-weight: 600; opacity: 0; transition: all 0.3s ease; z-index: 20; } #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } </style> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/", "@tweenjs/tween.js": "https://unpkg.com/@tweenjs/tween.js@23.1.3/dist/tween.esm.js" } } </script> </head> <body> <div id="ui-panel"> <h1>3x3 魔方模拟器</h1> <p>左键拖拽旋转层 | 右键拖拽旋转视角</p> <div class="btn-group"> <button id="btn-scramble">Scramble</button> <button id="btn-reset">Reset</button> </div> </div> <div id="toast"></div> <div id="canvas-container"></div> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import * as TWEEN from '@tweenjs/tween.js'; // ========================================== // 全局状态与配置 // ========================================== let cubies = []; // 存储所有 27 个小方块 let isAnimating = false; // 是否正在播放动画(打乱或磁吸回弹) let isDragging = false; // 是否正在拖拽旋转层 let hasChosenAxis = false; // 是否已经通过算法确定了旋转轴 let startMouse = new THREE.Vector2(); // 鼠标按下时的 NDC 坐标 let currentMouse = new THREE.Vector2(); // 当前鼠标的 NDC 坐标 let selectedCubie = null; // 射线检测到的初始方块 let faceNormal = new THREE.Vector3(); // 初始点击面的世界法线 let intersectPoint = new THREE.Vector3(); // 初始点击的交点 let currentAxis = ''; // 当前旋转的轴 'x', 'y', 'z' let targetLayerIndex = 0; // 当前旋转的层级 -1, 0, 1 let pivot; // 临时轴心对象 let projDelta = new THREE.Vector2(); // 投影算法计算出的 2D 方向单位向量 let startRotation = 0; // 拖拽开始时 pivot 的旋转角度 // 魔方尺寸配置 const SPACING = 1.02; // 物理间隙 const CUBIE_SIZE = 0.95; // 小方块尺寸 // ========================================== // 场景初始化 // ========================================== const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(4, 4, 6); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.getElementById('canvas-container').appendChild(renderer.domElement); // 右键拖拽旋转视角配置 const controls = new OrbitControls(camera, renderer.domElement); controls.mouseButtons = { LEFT: null, // 左键由我们自己的手势算法接管 MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; controls.enablePan = false; controls.minDistance = 5; controls.maxDistance = 15; // ========================================== // 光影环境 // ========================================== const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 50; dirLight.shadow.camera.left = -5; dirLight.shadow.camera.right = 5; dirLight.shadow.camera.top = 5; dirLight.shadow.camera.bottom = -5; dirLight.shadow.bias = -0.001; scene.add(dirLight); // ========================================== // Canvas API 程序化生成贴图 // ========================================== const FACE_COLORS = { right: '#B71234', // 红 left: '#FF5800', // 橙 up: '#FFFFFF', // 白 down: '#FFD500', // 黄 front: '#0046AD', // 蓝 back: '#009B48', // 绿 inside: '#111111' // 内部黑 }; function createFaceTexture(color) { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 绘制黑色塑料底色(带微小圆角模拟边缘) ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, size, size); // 如果不是内部面,绘制带圆角的贴纸和高光 if (color !== FACE_COLORS.inside) { const margin = 16; const radius = 30; const sx = margin, sy = margin, sw = size - margin * 2, sh = size - margin * 2; // 绘制圆角矩形贴纸路径 ctx.beginPath(); ctx.moveTo(sx + radius, sy); ctx.lineTo(sx + sw - radius, sy); ctx.quadraticCurveTo(sx + sw, sy, sx + sw, sy + radius); ctx.lineTo(sx + sw, sy + sh - radius); ctx.quadraticCurveTo(sx + sw, sy + sh, sx + sw - radius, sy + sh); ctx.lineTo(sx + radius, sy + sh); ctx.quadraticCurveTo(sx, sy + sh, sx, sy + sh - radius); ctx.lineTo(sx, sy + radius); ctx.quadraticCurveTo(sx, sy, sx + radius, sy); ctx.closePath(); // 填充贴纸底色 ctx.fillStyle = color; ctx.fill(); // 添加从左上到右下的高光渐变,模拟塑料质感 const gradient = ctx.createLinearGradient(sx, sy, sx + sw, sy + sh); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.05)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); ctx.fillStyle = gradient; ctx.fill(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // 预生成所有纹理 const textures = { right: createFaceTexture(FACE_COLORS.right), left: createFaceTexture(FACE_COLORS.left), up: createFaceTexture(FACE_COLORS.up), down: createFaceTexture(FACE_COLORS.down), front: createFaceTexture(FACE_COLORS.front), back: createFaceTexture(FACE_COLORS.back), inside: createFaceTexture(FACE_COLORS.inside) }; // ========================================== // 模型构建 & 动态层级 // ========================================== function buildCube() { // 清空旧方块 cubies.forEach(c => scene.remove(c)); cubies = []; const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { // 根据方块所在空间位置决定6个面的贴纸 const materials = [ new THREE.MeshStandardMaterial({ map: x === 1 ? textures.right : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: x === -1 ? textures.left : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === 1 ? textures.up : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === -1 ? textures.down : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === 1 ? textures.front : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === -1 ? textures.back : textures.inside, roughness: 0.4, metalness: 0.1 }), ]; const mesh = new THREE.Mesh(geometry, materials); mesh.position.set(x * SPACING, y * SPACING, z * SPACING); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); cubies.push(mesh); } } } } buildCube(); // ========================================== // 核心交互算法:手势投影与旋转轴确定 // ========================================== const raycaster = new THREE.Raycaster(); // 获取某3D轴投影到2D屏幕后的向量,以及对应的物理旋转方向 function getProjectionData(axisStr) { const axisVec = new THREE.Vector3(); axisVec[axisStr] = 1; // 构造 3D 轴单位向量 (1,0,0) 等 // 算法核心:3D旋转映射到2D屏幕 // 假设绕 axisStr 轴发生正向微小旋转,被点击面的法线方向将发生偏移。 // 偏移向量 deltaPos3D = cross(axisVec, faceNormal) const deltaPos3D = new THREE.Vector3().crossVectors(axisVec, faceNormal); // 将交点和偏移后的交点投影到屏幕空间,求差得到 2D 投影方向向量 const p0 = intersectPoint.clone().project(camera); const p1 = intersectPoint.clone().add(deltaPos3D).project(camera); // 返回 2D 屏幕空间中的方向向量 (NDC坐标系) return new THREE.Vector2(p1.x - p0.x, p1.y - p0.y); } function onPointerDown(event) { if (event.button !== 0 || isAnimating) return; // 仅处理左键,且不在动画中 startMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); raycaster.setFromCamera(startMouse, camera); const intersects = raycaster.intersectObjects(cubies); if (intersects.length > 0) { isDragging = true; controls.enabled = false; // 禁用视角控制,防止冲突 selectedCubie = intersects[0].object; intersectPoint = intersects[0].point.clone(); // 计算世界法线:将局部法线转换到世界空间,并四舍五入消除浮点误差 faceNormal = intersects[0].face.normal.clone() .transformDirection(selectedCubie.matrixWorld) .round(); hasChosenAxis = false; currentAxis = ''; startRotation = 0; } } function onPointerMove(event) { if (!isDragging || isAnimating) return; currentMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); const NDC_delta2D = currentMouse.clone().sub(startMouse); // 若还未确定旋转轴(手势刚开始),需要达到一定阈值才锁定 if (!hasChosenAxis) { if (NDC_delta2D.length() < 0.01) return; // 死区防抖 let axis1, axis2; // 排除法线方向的轴,锁定两个潜在旋转轴 if (Math.abs(faceNormal.x) > 0.5) { axis1 = 'y'; axis2 = 'z'; } else if (Math.abs(faceNormal.y) > 0.5) { axis1 = 'x'; axis2 = 'z'; } else { axis1 = 'x'; axis2 = 'y'; } // 基于投影向量的手势识别算法核心: // 将3D潜在轴的旋转效果投影到2D,与用户的2D滑动向量计算点积 const proj1 = getProjectionData(axis1); const proj2 = getProjectionData(axis2); const dot1 = NDC_delta2D.dot(proj1); const dot2 = NDC_delta2D.dot(proj2); // 选择匹配度最高的轴 if (Math.abs(dot1) > Math.abs(dot2)) { currentAxis = axis1; projDelta = proj1; } else { currentAxis = axis2; projDelta = proj2; } // 动态层级筛选:根据点击方块在当前轴的世界坐标确定层级 targetLayerIndex = Math.round(selectedCubie.position[currentAxis] / SPACING); // 创建临时 Pivot 并挂载相应层级的方块 pivot = new THREE.Object3D(); pivot.position[currentAxis] = targetLayerIndex * SPACING; scene.add(pivot); // Pivot 变换机制核心:使用 attach 保持世界矩阵 cubies.forEach(cubie => { if (Math.round(cubie.position[currentAxis] / SPACING) === targetLayerIndex) { pivot.attach(cubie); } }); hasChosenAxis = true; } // 实时跟手:1:1 计算旋转角度 // 公式:deltaAngle = dot(2D滑动距离, 2D投影方向) / dot(投影方向, 投影方向) // 这完美解决了方向反转的Bug,且从任何视角操作都直观! const deltaAngle = NDC_delta2D.dot(projDelta) / projDelta.dot(projDelta); pivot.rotation[currentAxis] = deltaAngle; } function onPointerUp(event) { if (!isDragging) return; isDragging = false; if (!hasChosenAxis) { controls.enabled = true; return; } // 磁吸效果:松开鼠标后,自动对齐到最近的 90 度倍数 const currentAngle = pivot.rotation[currentAxis]; const targetAngle = Math.round(currentAngle / (Math.PI / 2)) * (Math.PI / 2); isAnimating = true; // 使用 Tween.js 播放回弹动画 const tweenObj = { r: currentAngle }; new TWEEN.Tween(tweenObj) .to({ r: targetAngle }, 250) .easing(TWEEN.Easing.Back.Out) .onUpdate(() => { pivot.rotation[currentAxis] = tweenObj.r; }) .onComplete(() => { finishRotation(targetAngle); }) .start(); } function finishRotation(finalAngle) { pivot.rotation[currentAxis] = finalAngle; // 坐标清洗:消除浮点累积误差,防止魔方“散架” // 将方块从 pivot 放回 scene,利用 scene.attach 自动处理矩阵 const children = [...pivot.children]; children.forEach(child => { scene.attach(child); // 位置清洗:四舍五入到 Spacing 的整数倍 child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; // 旋转清洗:四舍五入到 90 度倍数 child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); // 清理临时轴心对象 scene.remove(pivot); isAnimating = false; controls.enabled = true; } // ========================================== // 打乱与重置功能 // ========================================== function scrambleCube() { if (isAnimating || isDragging) return; isAnimating = true; showToast("Scrambling..."); const axes = ['x', 'y', 'z']; const layers = [-1, 0, 1]; const moves = 20; let moveList = []; for (let i = 0; i < moves; i++) { moveList.push({ axis: axes[Math.floor(Math.random() * 3)], layer: layers[Math.floor(Math.random() * 3)], dir: Math.random() > 0.5 ? 1 : -1 }); } let index = 0; function doMove() { if (index >= moveList.length) { isAnimating = false; showToast("Ready!"); return; } const move = moveList[index]; const tempPivot = new THREE.Object3D(); tempPivot.position[move.axis] = move.layer * SPACING; scene.add(tempPivot); cubies.forEach(cubie => { if (Math.round(cubie.position[move.axis] / SPACING) === move.layer) { tempPivot.attach(cubie); } }); const targetAngle = move.dir * (Math.PI / 2); const tempTweenObj = { r: 0 }; new TWEEN.Tween(tempTweenObj) .to({ r: targetAngle }, 100) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(() => { tempPivot.rotation[move.axis] = tempTweenObj.r; }) .onComplete(() => { finishRotationForScramble(tempPivot, targetAngle); index++; doMove(); }) .start(); } doMove(); } function finishRotationForScramble(pivotObj) { const children = [...pivotObj.children]; children.forEach(child => { scene.attach(child); child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); scene.remove(pivotObj); } function resetCube() { if (isAnimating || isDragging) return; buildCube(); showToast("Reset Done!"); } // 提示弹窗 function showToast(msg) { const toast = document.getElementById('toast'); toast.innerText = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); } // ========================================== // 事件绑定与渲染循环 // ========================================== window.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); document.getElementById('btn-scramble').addEventListener('click', scrambleCube); document.getElementById('btn-reset').addEventListener('click', resetCube); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html> 网友解答:


--【壹】--:

从上面两篇帖子继续

Deepseek v4 pro 3d魔方简要测试 国产替代
使用 cherry studio 客户端, 未设置系统提示词, 思考等级:auto 提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 …
小米mimo-v2.5-pro 3d 魔方简要测试 开发调优
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB) 概要: 相当完美,鼠标操作底部那一排似乎有些转不…

测试环境 cherry studio 思考等级 : auto 上游 opencode-go
思考时间149.7s

提示词

你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。

https://imgbed.snemc.cn/i/76632ecccb36.gif(图片大于 4 MB)

概述:
没有任何问题,相当丝滑

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>高保真 3x3 魔方模拟器</title> <style> :root { --bg: #1a1a1a; --panel-bg: rgba(30, 30, 30, 0.85); --accent: #4fd1c5; --text: #e2e8f0; --btn-hover: #38b2ac; } body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: var(--text); } #canvas-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; } #ui-panel { position: absolute; top: 20px; left: 20px; z-index: 10; background: var(--panel-bg); padding: 15px 25px; border-radius: 12px; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 10px; } h1 { margin: 0 0 5px 0; font-size: 1.2rem; color: var(--accent); letter-spacing: 1px; } p { margin: 0; font-size: 0.85rem; color: #a0aec0; } .btn-group { display: flex; gap: 10px; margin-top: 5px; } button { background-color: var(--accent); color: #1a202c; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; } button:hover { background-color: var(--btn-hover); transform: translateY(-1px); box-shadow: 0 2px 10px rgba(79, 209, 197, 0.4); } button:active { transform: translateY(0); } #toast { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(100px); background: rgba(79, 209, 197, 0.9); color: #1a202c; padding: 10px 25px; border-radius: 20px; font-weight: 600; opacity: 0; transition: all 0.3s ease; z-index: 20; } #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } </style> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/", "@tweenjs/tween.js": "https://unpkg.com/@tweenjs/tween.js@23.1.3/dist/tween.esm.js" } } </script> </head> <body> <div id="ui-panel"> <h1>3x3 魔方模拟器</h1> <p>左键拖拽旋转层 | 右键拖拽旋转视角</p> <div class="btn-group"> <button id="btn-scramble">Scramble</button> <button id="btn-reset">Reset</button> </div> </div> <div id="toast"></div> <div id="canvas-container"></div> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import * as TWEEN from '@tweenjs/tween.js'; // ========================================== // 全局状态与配置 // ========================================== let cubies = []; // 存储所有 27 个小方块 let isAnimating = false; // 是否正在播放动画(打乱或磁吸回弹) let isDragging = false; // 是否正在拖拽旋转层 let hasChosenAxis = false; // 是否已经通过算法确定了旋转轴 let startMouse = new THREE.Vector2(); // 鼠标按下时的 NDC 坐标 let currentMouse = new THREE.Vector2(); // 当前鼠标的 NDC 坐标 let selectedCubie = null; // 射线检测到的初始方块 let faceNormal = new THREE.Vector3(); // 初始点击面的世界法线 let intersectPoint = new THREE.Vector3(); // 初始点击的交点 let currentAxis = ''; // 当前旋转的轴 'x', 'y', 'z' let targetLayerIndex = 0; // 当前旋转的层级 -1, 0, 1 let pivot; // 临时轴心对象 let projDelta = new THREE.Vector2(); // 投影算法计算出的 2D 方向单位向量 let startRotation = 0; // 拖拽开始时 pivot 的旋转角度 // 魔方尺寸配置 const SPACING = 1.02; // 物理间隙 const CUBIE_SIZE = 0.95; // 小方块尺寸 // ========================================== // 场景初始化 // ========================================== const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(4, 4, 6); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.getElementById('canvas-container').appendChild(renderer.domElement); // 右键拖拽旋转视角配置 const controls = new OrbitControls(camera, renderer.domElement); controls.mouseButtons = { LEFT: null, // 左键由我们自己的手势算法接管 MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; controls.enablePan = false; controls.minDistance = 5; controls.maxDistance = 15; // ========================================== // 光影环境 // ========================================== const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 50; dirLight.shadow.camera.left = -5; dirLight.shadow.camera.right = 5; dirLight.shadow.camera.top = 5; dirLight.shadow.camera.bottom = -5; dirLight.shadow.bias = -0.001; scene.add(dirLight); // ========================================== // Canvas API 程序化生成贴图 // ========================================== const FACE_COLORS = { right: '#B71234', // 红 left: '#FF5800', // 橙 up: '#FFFFFF', // 白 down: '#FFD500', // 黄 front: '#0046AD', // 蓝 back: '#009B48', // 绿 inside: '#111111' // 内部黑 }; function createFaceTexture(color) { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 绘制黑色塑料底色(带微小圆角模拟边缘) ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, size, size); // 如果不是内部面,绘制带圆角的贴纸和高光 if (color !== FACE_COLORS.inside) { const margin = 16; const radius = 30; const sx = margin, sy = margin, sw = size - margin * 2, sh = size - margin * 2; // 绘制圆角矩形贴纸路径 ctx.beginPath(); ctx.moveTo(sx + radius, sy); ctx.lineTo(sx + sw - radius, sy); ctx.quadraticCurveTo(sx + sw, sy, sx + sw, sy + radius); ctx.lineTo(sx + sw, sy + sh - radius); ctx.quadraticCurveTo(sx + sw, sy + sh, sx + sw - radius, sy + sh); ctx.lineTo(sx + radius, sy + sh); ctx.quadraticCurveTo(sx, sy + sh, sx, sy + sh - radius); ctx.lineTo(sx, sy + radius); ctx.quadraticCurveTo(sx, sy, sx + radius, sy); ctx.closePath(); // 填充贴纸底色 ctx.fillStyle = color; ctx.fill(); // 添加从左上到右下的高光渐变,模拟塑料质感 const gradient = ctx.createLinearGradient(sx, sy, sx + sw, sy + sh); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.05)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); ctx.fillStyle = gradient; ctx.fill(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // 预生成所有纹理 const textures = { right: createFaceTexture(FACE_COLORS.right), left: createFaceTexture(FACE_COLORS.left), up: createFaceTexture(FACE_COLORS.up), down: createFaceTexture(FACE_COLORS.down), front: createFaceTexture(FACE_COLORS.front), back: createFaceTexture(FACE_COLORS.back), inside: createFaceTexture(FACE_COLORS.inside) }; // ========================================== // 模型构建 & 动态层级 // ========================================== function buildCube() { // 清空旧方块 cubies.forEach(c => scene.remove(c)); cubies = []; const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { // 根据方块所在空间位置决定6个面的贴纸 const materials = [ new THREE.MeshStandardMaterial({ map: x === 1 ? textures.right : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: x === -1 ? textures.left : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === 1 ? textures.up : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === -1 ? textures.down : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === 1 ? textures.front : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === -1 ? textures.back : textures.inside, roughness: 0.4, metalness: 0.1 }), ]; const mesh = new THREE.Mesh(geometry, materials); mesh.position.set(x * SPACING, y * SPACING, z * SPACING); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); cubies.push(mesh); } } } } buildCube(); // ========================================== // 核心交互算法:手势投影与旋转轴确定 // ========================================== const raycaster = new THREE.Raycaster(); // 获取某3D轴投影到2D屏幕后的向量,以及对应的物理旋转方向 function getProjectionData(axisStr) { const axisVec = new THREE.Vector3(); axisVec[axisStr] = 1; // 构造 3D 轴单位向量 (1,0,0) 等 // 算法核心:3D旋转映射到2D屏幕 // 假设绕 axisStr 轴发生正向微小旋转,被点击面的法线方向将发生偏移。 // 偏移向量 deltaPos3D = cross(axisVec, faceNormal) const deltaPos3D = new THREE.Vector3().crossVectors(axisVec, faceNormal); // 将交点和偏移后的交点投影到屏幕空间,求差得到 2D 投影方向向量 const p0 = intersectPoint.clone().project(camera); const p1 = intersectPoint.clone().add(deltaPos3D).project(camera); // 返回 2D 屏幕空间中的方向向量 (NDC坐标系) return new THREE.Vector2(p1.x - p0.x, p1.y - p0.y); } function onPointerDown(event) { if (event.button !== 0 || isAnimating) return; // 仅处理左键,且不在动画中 startMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); raycaster.setFromCamera(startMouse, camera); const intersects = raycaster.intersectObjects(cubies); if (intersects.length > 0) { isDragging = true; controls.enabled = false; // 禁用视角控制,防止冲突 selectedCubie = intersects[0].object; intersectPoint = intersects[0].point.clone(); // 计算世界法线:将局部法线转换到世界空间,并四舍五入消除浮点误差 faceNormal = intersects[0].face.normal.clone() .transformDirection(selectedCubie.matrixWorld) .round(); hasChosenAxis = false; currentAxis = ''; startRotation = 0; } } function onPointerMove(event) { if (!isDragging || isAnimating) return; currentMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); const NDC_delta2D = currentMouse.clone().sub(startMouse); // 若还未确定旋转轴(手势刚开始),需要达到一定阈值才锁定 if (!hasChosenAxis) { if (NDC_delta2D.length() < 0.01) return; // 死区防抖 let axis1, axis2; // 排除法线方向的轴,锁定两个潜在旋转轴 if (Math.abs(faceNormal.x) > 0.5) { axis1 = 'y'; axis2 = 'z'; } else if (Math.abs(faceNormal.y) > 0.5) { axis1 = 'x'; axis2 = 'z'; } else { axis1 = 'x'; axis2 = 'y'; } // 基于投影向量的手势识别算法核心: // 将3D潜在轴的旋转效果投影到2D,与用户的2D滑动向量计算点积 const proj1 = getProjectionData(axis1); const proj2 = getProjectionData(axis2); const dot1 = NDC_delta2D.dot(proj1); const dot2 = NDC_delta2D.dot(proj2); // 选择匹配度最高的轴 if (Math.abs(dot1) > Math.abs(dot2)) { currentAxis = axis1; projDelta = proj1; } else { currentAxis = axis2; projDelta = proj2; } // 动态层级筛选:根据点击方块在当前轴的世界坐标确定层级 targetLayerIndex = Math.round(selectedCubie.position[currentAxis] / SPACING); // 创建临时 Pivot 并挂载相应层级的方块 pivot = new THREE.Object3D(); pivot.position[currentAxis] = targetLayerIndex * SPACING; scene.add(pivot); // Pivot 变换机制核心:使用 attach 保持世界矩阵 cubies.forEach(cubie => { if (Math.round(cubie.position[currentAxis] / SPACING) === targetLayerIndex) { pivot.attach(cubie); } }); hasChosenAxis = true; } // 实时跟手:1:1 计算旋转角度 // 公式:deltaAngle = dot(2D滑动距离, 2D投影方向) / dot(投影方向, 投影方向) // 这完美解决了方向反转的Bug,且从任何视角操作都直观! const deltaAngle = NDC_delta2D.dot(projDelta) / projDelta.dot(projDelta); pivot.rotation[currentAxis] = deltaAngle; } function onPointerUp(event) { if (!isDragging) return; isDragging = false; if (!hasChosenAxis) { controls.enabled = true; return; } // 磁吸效果:松开鼠标后,自动对齐到最近的 90 度倍数 const currentAngle = pivot.rotation[currentAxis]; const targetAngle = Math.round(currentAngle / (Math.PI / 2)) * (Math.PI / 2); isAnimating = true; // 使用 Tween.js 播放回弹动画 const tweenObj = { r: currentAngle }; new TWEEN.Tween(tweenObj) .to({ r: targetAngle }, 250) .easing(TWEEN.Easing.Back.Out) .onUpdate(() => { pivot.rotation[currentAxis] = tweenObj.r; }) .onComplete(() => { finishRotation(targetAngle); }) .start(); } function finishRotation(finalAngle) { pivot.rotation[currentAxis] = finalAngle; // 坐标清洗:消除浮点累积误差,防止魔方“散架” // 将方块从 pivot 放回 scene,利用 scene.attach 自动处理矩阵 const children = [...pivot.children]; children.forEach(child => { scene.attach(child); // 位置清洗:四舍五入到 Spacing 的整数倍 child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; // 旋转清洗:四舍五入到 90 度倍数 child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); // 清理临时轴心对象 scene.remove(pivot); isAnimating = false; controls.enabled = true; } // ========================================== // 打乱与重置功能 // ========================================== function scrambleCube() { if (isAnimating || isDragging) return; isAnimating = true; showToast("Scrambling..."); const axes = ['x', 'y', 'z']; const layers = [-1, 0, 1]; const moves = 20; let moveList = []; for (let i = 0; i < moves; i++) { moveList.push({ axis: axes[Math.floor(Math.random() * 3)], layer: layers[Math.floor(Math.random() * 3)], dir: Math.random() > 0.5 ? 1 : -1 }); } let index = 0; function doMove() { if (index >= moveList.length) { isAnimating = false; showToast("Ready!"); return; } const move = moveList[index]; const tempPivot = new THREE.Object3D(); tempPivot.position[move.axis] = move.layer * SPACING; scene.add(tempPivot); cubies.forEach(cubie => { if (Math.round(cubie.position[move.axis] / SPACING) === move.layer) { tempPivot.attach(cubie); } }); const targetAngle = move.dir * (Math.PI / 2); const tempTweenObj = { r: 0 }; new TWEEN.Tween(tempTweenObj) .to({ r: targetAngle }, 100) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(() => { tempPivot.rotation[move.axis] = tempTweenObj.r; }) .onComplete(() => { finishRotationForScramble(tempPivot, targetAngle); index++; doMove(); }) .start(); } doMove(); } function finishRotationForScramble(pivotObj) { const children = [...pivotObj.children]; children.forEach(child => { scene.attach(child); child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); scene.remove(pivotObj); } function resetCube() { if (isAnimating || isDragging) return; buildCube(); showToast("Reset Done!"); } // 提示弹窗 function showToast(msg) { const toast = document.getElementById('toast'); toast.innerText = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); } // ========================================== // 事件绑定与渲染循环 // ========================================== window.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); document.getElementById('btn-scramble').addEventListener('click', scrambleCube); document.getElementById('btn-reset').addEventListener('click', resetCube); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html>