glm-5.1 3d 魔方简要测试
- 内容介绍
- 文章标签
- 相关推荐
从上面两篇帖子继续
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>

