小米mimo-v2.5-pro 3d 魔方简要测试
- 内容介绍
- 文章标签
- 相关推荐
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试
测试环境 cherry studio, 上游是opencode go, 思考模式默认
思考时间 937.6s
测试结果
https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB)
概要:
相当完美,鼠标操作底部那一排似乎有些转不过来
提示词:
你是一名精通图形学算法与 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(重置)功能按钮。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3×3 魔方 — Three.js Physical-Grade Rubik's Cube</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
overflow:hidden;
background:#0d0d1a;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
canvas{display:block}
#ui{
position:fixed;bottom:30px;left:50%;transform:translateX(-50%);
display:flex;gap:14px;z-index:10;
}
.btn{
padding:12px 30px;font-size:15px;font-weight:600;
border:2px solid rgba(255,255,255,.15);border-radius:14px;
cursor:pointer;color:#fff;
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
background:rgba(255,255,255,.08);
transition:all .3s cubic-bezier(.4,0,.2,1);
letter-spacing:.5px;user-select:none;
}
.btn:hover{
background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.35);
transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,.4);
}
.btn:active{transform:translateY(0);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.btn.scramble{border-color:rgba(245,87,108,.5)}
.btn.scramble:hover{background:rgba(245,87,108,.22)}
.btn.reset{border-color:rgba(78,205,196,.5)}
.btn.reset:hover{background:rgba(78,205,196,.22)}
.btn:disabled{opacity:.4;pointer-events:none}
#info{
position:fixed;top:20px;width:100%;text-align:center;
color:rgba(255,255,255,.45);font-size:13px;z-index:10;pointer-events:none;
}
</style>
</head>
<body>
<div id="info">🖱️ 左键拖拽旋转层 · 右键拖拽旋转视角 · 滚轮缩放</div>
<div id="ui">
<button class="btn scramble" id="btnScramble">🎲 打乱</button>
<button class="btn reset" id="btnReset">🔄 重置</button>
</div>
<!-- ═══════════ Import Map: Three.js + OrbitControls ═══════════ -->
<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/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ════════════════════════════════════════════════════════════════
Section 1 · 轻量 Tween 引擎(模拟 Tween.js API)
════════════════════════════════════════════════════════════════ */
const _tweens = [];
class Tween {
constructor(obj) {
this._o = obj; // 被驱动的对象
this._s = {}; // 起始值快照
this._e = {}; // 目标值
this._d = 300; // 持续时间 ms
this._ef = t => t; // 缓动函数
this._uc = null; // onUpdate 回调
this._cc = null; // onComplete 回调
this._t0 = 0; // 起始时间戳
this._r = false; // 是否运行中
}
to(target, dur) { this._e = target; if (dur != null) this._d = dur; return this; }
easing(fn) { this._ef = fn; return this; }
onUpdate(fn) { this._uc = fn; return this; }
onComplete(fn) { this._cc = fn; return this; }
start() {
for (const k in this._e) this._s[k] = this._o[k];
this._t0 = performance.now();
this._r = true;
_tweens.push(this);
return this;
}
_tick(now) {
if (!this._r) return false;
const t = Math.min((now - this._t0) / this._d, 1);
const e = this._ef(t);
for (const k in this._e)
this._o[k] = this._s[k] + (this._e[k] - this._s[k]) * e;
if (this._uc) this._uc(this._o);
if (t >= 1) { this._r = false; if (this._cc) this._cc(); return false; }
return true;
}
}
function tickTweens() {
const n = performance.now();
for (let i = _tweens.length - 1; i >= 0; i--)
if (!_tweens[i]._tick(n)) _tweens.splice(i, 1);
}
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const easeInOutCubic = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
/* ════════════════════════════════════════════════════════════════
Section 2 · 常量 & 配色
════════════════════════════════════════════════════════════════ */
const GAP = 1; // 方块中心距
const SPACING = 0.08; // 物理间隙
const SIZE = GAP - SPACING; // 小方块视觉边长
const EPS = 0.1; // 层判断容差
const HPI = Math.PI / 2; // 90°
const FACE_COLORS = {
R: '#B71234', // +X 红
L: '#FF5800', // -X 橙
U: '#FFFFFF', // +Y 白
D: '#FFD500', // -Y 黄
F: '#009B48', // +Z 绿
B: '#0046AD', // -Z 蓝
};
/* ════════════════════════════════════════════════════════════════
Section 3 · Canvas API 程序化纹理(零外部图片)
════════════════════════════════════════════════════════════════ */
/** 绘制圆角矩形路径 */
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* 生成贴纸纹理
* 层次:黑塑料底 → 阴影凹槽 → 彩色圆角贴纸 → 多层渐变高光
*/
function makeSticker(color) {
const S = 256;
const cvs = document.createElement('canvas');
cvs.width = cvs.height = S;
const c = cvs.getContext('2d');
// ① 黑色塑料底座
c.fillStyle = '#111';
c.fillRect(0, 0, S, S);
const m = 18, r = 24;
// ② 贴纸外缘阴影(模拟凹槽)
roundRect(c, m - 3, m - 3, S - 2*m + 6, S - 2*m + 6, r + 3);
c.fillStyle = 'rgba(0,0,0,0.35)';
c.fill();
// ③ 彩色圆角贴纸
roundRect(c, m, m, S - 2*m, S - 2*m, r);
c.fillStyle = color;
c.fill();
// ④ 对角线渐变高光(模拟真实光泽)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g1 = c.createLinearGradient(m, m, S - m, S - m);
g1.addColorStop(0, 'rgba(255,255,255,0.30)');
g1.addColorStop(0.4, 'rgba(255,255,255,0.06)');
g1.addColorStop(0.6, 'rgba(0,0,0,0)');
g1.addColorStop(1, 'rgba(0,0,0,0.20)');
c.fillStyle = g1;
c.fill();
// ⑤ 顶部高光条(模拟边缘反光)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g2 = c.createLinearGradient(m, m, m, m + 55);
g2.addColorStop(0, 'rgba(255,255,255,0.28)');
g2.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g2;
c.fill();
const tex = new THREE.CanvasTexture(cvs);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/** 纯黑内部纹理 */
function makeBlackTex() {
const c = document.createElement('canvas');
c.width = c.height = 8;
const ctx = c.getContext('2d');
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, 8, 8);
return new THREE.CanvasTexture(c);
}
// 预生成所有纹理
const TEX = {};
for (const [k, v] of Object.entries(FACE_COLORS)) TEX[k] = makeSticker(v);
const TEX_BLACK = makeBlackTex();
/* ════════════════════════════════════════════════════════════════
Section 4 · 场景 / 相机 / 渲染器 / 光照
════════════════════════════════════════════════════════════════ */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(5.5, 4.5, 5.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── 光照:环境光 + 主平行光(ShadowMap)+ 补光 ──
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 30;
sun.shadow.camera.left = -5;
sun.shadow.camera.right = 5;
sun.shadow.camera.top = 5;
sun.shadow.camera.bottom = -5;
sun.shadow.bias = -0.0001;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x8888ff, 0.3);
fill.position.set(-4, 3, -5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.2);
rim.position.set(0, -3, -6);
scene.add(rim);
// ── 地面(ShadowMaterial 接收阴影)──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.25 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.2;
ground.receiveShadow = true;
scene.add(ground);
/* ════════════════════════════════════════════════════════════════
Section 5 · OrbitControls(右键旋转视角)
════════════════════════════════════════════════════════════════ */
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.enablePan = false;
orbit.minDistance = 4;
orbit.maxDistance = 15;
// 左键 → undefined(交给魔方交互),中键 → 缩放,右键 → 轨道旋转
orbit.mouseButtons = { LEFT: undefined, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
/* ════════════════════════════════════════════════════════════════
Section 6 · 构建 27 个独立 Cubies
════════════════════════════════════════════════════════════════ */
const cubies = [];
const cubieGeo = new THREE.BoxGeometry(SIZE, SIZE, SIZE); // 共享几何体
/**
* 为指定 (x,y,z) 位置的 cubie 生成六面材质
* BoxGeometry 面序:[+X, -X, +Y, -Y, +Z, -Z]
*/
function cubieMaterials(x, y, z) {
return [
x === 1 ? TEX.R : TEX_BLACK,
x === -1 ? TEX.L : TEX_BLACK,
y === 1 ? TEX.U : TEX_BLACK,
y === -1 ? TEX.D : TEX_BLACK,
z === 1 ? TEX.F : TEX_BLACK,
z === -1 ? TEX.B : TEX_BLACK,
].map(t => new THREE.MeshStandardMaterial({ map: t, roughness: 0.35, metalness: 0 }));
}
// 生成 3×3×3 = 27 个方块
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const mesh = new THREE.Mesh(cubieGeo, cubieMaterials(x, y, z));
mesh.position.set(x * GAP, y * GAP, z * GAP);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
cubies.push(mesh);
}
/* ════════════════════════════════════════════════════════════════
Section 7 · 坐标清洗 — 消除浮点累积误差
════════════════════════════════════════════════════════════════ */
/**
* 每次旋转结束后调用:
* 1. 位置四舍五入到整数(GAP=1)
* 2. 旋转矩阵元素四舍五入到 {-1, 0, 1}
* → 保证后续层判断精确匹配
*/
function cleanCubies() {
const _mat = new THREE.Matrix4();
for (const c of cubies) {
// 位置取整
c.position.set(
Math.round(c.position.x),
Math.round(c.position.y),
Math.round(c.position.z)
);
// 旋转矩阵取整
c.updateMatrix();
_mat.copy(c.matrix);
const e = _mat.elements;
for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]);
c.quaternion.setFromRotationMatrix(_mat);
}
}
/* ════════════════════════════════════════════════════════════════
Section 8 · 工具函数
════════════════════════════════════════════════════════════════ */
/** 将方向向量吸附到最近的主轴 (±X, ±Y, ±Z) */
function snapToAxis(v) {
const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
if (ax >= ay && ax >= az) return new THREE.Vector3(Math.sign(v.x), 0, 0);
if (ay >= ax && ay >= az) return new THREE.Vector3(0, Math.sign(v.y), 0);
return new THREE.Vector3(0, 0, Math.sign(v.z));
}
/** 给定面法线 → 返回与其垂直的两个主轴 */
function perpAxes(n) {
if (Math.abs(n.x) > 0.5) return [new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)];
if (Math.abs(n.y) > 0.5) return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,1)];
return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0)];
}
/** 3D 轴向量 → 轴名字符 */
function axName(v) {
if (Math.abs(v.x) > 0.5) return 'x';
if (Math.abs(v.y) > 0.5) return 'y';
return 'z';
}
/** 3D 世界点 → 屏幕像素坐标 */
function toScreen(p) {
const v = p.clone().project(camera);
return new THREE.Vector2(
(v.x + 1) * 0.5 * innerWidth,
(1 - v.y) * 0.5 * innerHeight
);
}
/** 将 3D 方向向量投影为屏幕像素方向 */
function screenDir(dir3, origin3) {
const a = toScreen(origin3);
const b = toScreen(origin3.clone().add(dir3));
return new THREE.Vector2(b.x - a.x, b.y - a.y);
}
/* ════════════════════════════════════════════════════════════════
Section 9 · 旋转状态机
════════════════════════════════════════════════════════════════ */
let interacting = false; // 鼠标左键按下追踪中
let animating = false; // 补间动画播放中
let scrambling = false; // 打乱序列执行中
let pivot = null; // 临时轴心 Object3D
let rotAx = null; // 当前旋转轴 'x'|'y'|'z'
let rotLayer = 0; // 当前层号 -1|0|1
let angle = 0; // 累计旋转弧度
let mouse0 = { x: 0, y: 0 };
// ── 手势预计算缓存(初始化旋转时计算一次)──
let gDir = null; // 屏幕空间旋转方向(归一化 2D 向量)
let gRate = 1; // 灵敏度:弧度 / 像素
// ── 点击信息缓存 ──
let clickNormal = null; // 点击面法线(已吸附主轴)
let clickPoint = null; // 点击的世界坐标
let clickCubie = null; // 被点击的 cubie Mesh
const rc = new THREE.Raycaster();
/* ════════════════════════════════════════════════════════════════
Section 10 · 交互事件处理
════════════════════════════════════════════════════════════════ */
function ndc(e) {
return new THREE.Vector2(
(e.clientX / innerWidth) * 2 - 1,
-(e.clientY / innerHeight) * 2 + 1
);
}
// ─── pointerdown ───
renderer.domElement.addEventListener('pointerdown', e => {
if (animating || scrambling || e.button !== 0) return;
rc.setFromCamera(ndc(e), camera);
const hits = rc.intersectObjects(cubies);
if (!hits.length) return;
const hit = hits[0];
clickCubie = hit.object;
// 将局部法线转换到世界空间并吸附到主轴
clickNormal = snapToAxis(hit.face.normal.clone().transformDirection(clickCubie.matrixWorld));
clickPoint = hit.point.clone();
mouse0 = { x: e.clientX, y: e.clientY };
angle = 0;
pivot = null;
rotAx = null;
interacting = true;
orbit.enabled = false; // 暂时禁用轨道控制,避免冲突
});
// ─── pointermove ───
renderer.domElement.addEventListener('pointermove', e => {
if (!interacting || animating) return;
const dx = e.clientX - mouse0.x;
const dy = e.clientY - mouse0.y;
// 阈值防误触:拖动不足 10px 不触发旋转
if (!pivot && dx * dx + dy * dy < 100) return;
// 首次超过阈值 → 初始化层旋转
if (!pivot) initLayerRotation(dx, dy);
if (!pivot) return;
// ── 实时跟手:将鼠标总位移投影到旋转屏幕方向 ──
const proj = dx * gDir.x + dy * gDir.y; // 像素分量
angle = proj * gRate; // 转为弧度
pivot.rotation[rotAx] = angle;
});
// ─── pointerup ───
renderer.domElement.addEventListener('pointerup', e => {
if (e.button !== 0) return;
interacting = false;
if (pivot) {
// 磁吸效果:计算最近的 90° 倍数
const target = Math.round(angle / HPI) * HPI;
animating = true;
new Tween({ a: angle })
.to({ a: target }, 200)
.easing(easeOutCubic)
.onUpdate(o => { pivot.rotation[rotAx] = o.a; })
.onComplete(() => releasePivot())
.start();
} else {
orbit.enabled = true; // 未触发旋转,恢复轨道控制
}
});
/* ════════════════════════════════════════════════════════════════
Section 11 · 核心手势识别算法 & Pivot 变换机制
┌─────────────────────────────────────────────────────────────┐
│ 算法概览: │
│ 1. 由面法线确定两个潜在旋转轴(均与法线垂直) │
│ 2. 计算每个轴的「正旋转切线方向」 = axis × normal │
│ 3. 将两条切线投影到 2D 屏幕空间 │
│ 4. 鼠标拖拽方向与两条投影做点积 → 选匹配度最高的轴 │
│ 5. 用数值微分法计算该轴的屏幕灵敏度(像素/弧度) │
│ 6. 创建 Pivot → attach() 挂载层内方块 → 旋转 → detach │
└─────────────────────────────────────────────────────────────┘
════════════════════════════════════════════════════════════════ */
/**
* 初始化层旋转
* 在首次拖动超过阈值时调用,一次性完成:
* - 选择旋转轴和层
* - 预计算屏幕灵敏度
* - 创建 Pivot 并挂载 cubies
*/
function initLayerRotation(dx, dy) {
const [ax1, ax2] = perpAxes(clickNormal);
// 正旋转切线方向 = axis × faceNormal
// 物理含义:该方向是「正角速度旋转时,面表面的运动方向」
const tan1 = new THREE.Vector3().crossVectors(ax1, clickNormal);
const tan2 = new THREE.Vector3().crossVectors(ax2, clickNormal);
// 投影到屏幕像素空间
const sTan1 = screenDir(tan1, clickPoint);
const sTan2 = screenDir(tan2, clickPoint);
// 拖拽方向(归一化)
const drag = new THREE.Vector2(dx, dy).normalize();
// 点积比较:选择与拖拽方向更匹配的轴
const dot1 = Math.abs(drag.dot(sTan1.clone().normalize()));
const dot2 = Math.abs(drag.dot(sTan2.clone().normalize()));
const chosen = dot1 >= dot2 ? ax1 : ax2;
rotAx = axName(chosen);
// 动态计算层号:根据被点击 cubie 在旋转轴上的世界坐标
rotLayer = Math.round(clickCubie.position[rotAx] / GAP);
// ── 数值微分法:计算屏幕灵敏度(像素/弧度)──
// 对点击点做微小旋转,观察屏幕位移
const eps = 0.01;
const s0 = toScreen(clickPoint);
const sp = toScreen(clickPoint.clone().applyAxisAngle(chosen, eps));
const deriv = new THREE.Vector2(sp.x - s0.x, sp.y - s0.y).divideScalar(eps);
// deriv 的方向 = 正旋转在屏幕上的运动方向
// deriv 的长度 = 像素/弧度
gDir = deriv.clone().normalize();
gRate = 1 / deriv.length(); // 弧度/像素
// 轴几乎垂直于屏幕(看不到旋转)→ 不触发
if (deriv.length() < 0.5) return;
// ── Pivot 变换机制(关键考点)──
pivot = new THREE.Object3D(); // 临时轴心,位于世界原点
scene.add(pivot);
for (const c of cubies) {
// 基于世界坐标动态筛选层,不依赖固定索引
if (Math.abs(c.position[rotAx] / GAP - rotLayer) < EPS) {
pivot.attach(c);
// attach() 自动计算并保持 cubie 的世界变换
// cubie 成为 pivot 的子节点,后续随 pivot 旋转
}
}
}
/** 释放 Pivot:将方块归还场景 + 坐标清洗 */
function releasePivot() {
if (!pivot) return;
// 将所有子对象挂回场景(自动保持旋转后的世界变换)
const kids = [...pivot.children]; // 复制数组,因为 attach 会修改 children
for (const c of kids) scene.attach(c);
cleanCubies(); // 坐标清洗:消除浮点误差
scene.remove(pivot);
pivot = null;
rotAx = null;
animating = false;
orbit.enabled = true;
}
/* ════════════════════════════════════════════════════════════════
Section 12 · 打乱(Scramble)& 重置(Reset)
════════════════════════════════════════════════════════════════ */
/** 通用层旋转(供 scramble 调用) */
function doRotation(ax, layer, ang, dur) {
return new Promise(resolve => {
const p = new THREE.Object3D();
scene.add(p);
for (const c of cubies)
if (Math.abs(c.position[ax] / GAP - layer) < EPS) p.attach(c);
animating = true;
new Tween({ a: 0 })
.to({ a: ang }, dur)
.easing(easeInOutCubic)
.onUpdate(o => { p.rotation[ax] = o.a; })
.onComplete(() => {
for (const c of [...p.children]) scene.attach(c);
cleanCubies();
scene.remove(p);
animating = false;
resolve();
})
.start();
});
}
async function scrambleCube() {
if (animating || scrambling || interacting) return;
scrambling = true;
orbit.enabled = false;
document.getElementById('btnScramble').disabled = true;
document.getElementById('btnReset').disabled = true;
const AXES = ['x', 'y', 'z'];
const LAYERS = [-1, 0, 1];
let lastA = '', lastL = -99;
for (let i = 0; i < 20; i++) {
let a, l;
// 避免连续旋转同一层(无意义)
do { a = AXES[Math.random() * 3 | 0]; l = LAYERS[Math.random() * 3 | 0]; }
while (a === lastA && l === lastL);
const dir = (Math.random() < 0.5 ? 1 : -1) * HPI;
await doRotation(a, l, dir, Math.max(40, 100 - 3 * i));
lastA = a; lastL = l;
}
scrambling = false;
orbit.enabled = true;
document.getElementById('btnScramble').disabled = false;
document.getElementById('btnReset').disabled = false;
}
function resetCube() {
if (animating || scrambling || interacting) return;
let i = 0;
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const c = cubies[i++];
c.position.set(x * GAP, y * GAP, z * GAP);
c.quaternion.identity();
}
}
document.getElementById('btnScramble').addEventListener('click', scrambleCube);
document.getElementById('btnReset').addEventListener('click', resetCube);
/* ════════════════════════════════════════════════════════════════
Section 13 · 窗口自适应 & 渲染循环
════════════════════════════════════════════════════════════════ */
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
(function loop() {
requestAnimationFrame(loop);
tickTweens();
orbit.update();
renderer.render(scene, camera);
})();
</script>
</body>
</html>
模型的额外输出
image851×1068 77.8 KB
--【壹】--:
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试
测试环境 cherry studio, 上游是opencode go, 思考模式默认
思考时间 937.6s
测试结果
https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB)
概要:
相当完美,鼠标操作底部那一排似乎有些转不过来
提示词:
你是一名精通图形学算法与 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(重置)功能按钮。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3×3 魔方 — Three.js Physical-Grade Rubik's Cube</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
overflow:hidden;
background:#0d0d1a;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
canvas{display:block}
#ui{
position:fixed;bottom:30px;left:50%;transform:translateX(-50%);
display:flex;gap:14px;z-index:10;
}
.btn{
padding:12px 30px;font-size:15px;font-weight:600;
border:2px solid rgba(255,255,255,.15);border-radius:14px;
cursor:pointer;color:#fff;
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
background:rgba(255,255,255,.08);
transition:all .3s cubic-bezier(.4,0,.2,1);
letter-spacing:.5px;user-select:none;
}
.btn:hover{
background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.35);
transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,.4);
}
.btn:active{transform:translateY(0);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.btn.scramble{border-color:rgba(245,87,108,.5)}
.btn.scramble:hover{background:rgba(245,87,108,.22)}
.btn.reset{border-color:rgba(78,205,196,.5)}
.btn.reset:hover{background:rgba(78,205,196,.22)}
.btn:disabled{opacity:.4;pointer-events:none}
#info{
position:fixed;top:20px;width:100%;text-align:center;
color:rgba(255,255,255,.45);font-size:13px;z-index:10;pointer-events:none;
}
</style>
</head>
<body>
<div id="info">🖱️ 左键拖拽旋转层 · 右键拖拽旋转视角 · 滚轮缩放</div>
<div id="ui">
<button class="btn scramble" id="btnScramble">🎲 打乱</button>
<button class="btn reset" id="btnReset">🔄 重置</button>
</div>
<!-- ═══════════ Import Map: Three.js + OrbitControls ═══════════ -->
<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/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ════════════════════════════════════════════════════════════════
Section 1 · 轻量 Tween 引擎(模拟 Tween.js API)
════════════════════════════════════════════════════════════════ */
const _tweens = [];
class Tween {
constructor(obj) {
this._o = obj; // 被驱动的对象
this._s = {}; // 起始值快照
this._e = {}; // 目标值
this._d = 300; // 持续时间 ms
this._ef = t => t; // 缓动函数
this._uc = null; // onUpdate 回调
this._cc = null; // onComplete 回调
this._t0 = 0; // 起始时间戳
this._r = false; // 是否运行中
}
to(target, dur) { this._e = target; if (dur != null) this._d = dur; return this; }
easing(fn) { this._ef = fn; return this; }
onUpdate(fn) { this._uc = fn; return this; }
onComplete(fn) { this._cc = fn; return this; }
start() {
for (const k in this._e) this._s[k] = this._o[k];
this._t0 = performance.now();
this._r = true;
_tweens.push(this);
return this;
}
_tick(now) {
if (!this._r) return false;
const t = Math.min((now - this._t0) / this._d, 1);
const e = this._ef(t);
for (const k in this._e)
this._o[k] = this._s[k] + (this._e[k] - this._s[k]) * e;
if (this._uc) this._uc(this._o);
if (t >= 1) { this._r = false; if (this._cc) this._cc(); return false; }
return true;
}
}
function tickTweens() {
const n = performance.now();
for (let i = _tweens.length - 1; i >= 0; i--)
if (!_tweens[i]._tick(n)) _tweens.splice(i, 1);
}
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const easeInOutCubic = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
/* ════════════════════════════════════════════════════════════════
Section 2 · 常量 & 配色
════════════════════════════════════════════════════════════════ */
const GAP = 1; // 方块中心距
const SPACING = 0.08; // 物理间隙
const SIZE = GAP - SPACING; // 小方块视觉边长
const EPS = 0.1; // 层判断容差
const HPI = Math.PI / 2; // 90°
const FACE_COLORS = {
R: '#B71234', // +X 红
L: '#FF5800', // -X 橙
U: '#FFFFFF', // +Y 白
D: '#FFD500', // -Y 黄
F: '#009B48', // +Z 绿
B: '#0046AD', // -Z 蓝
};
/* ════════════════════════════════════════════════════════════════
Section 3 · Canvas API 程序化纹理(零外部图片)
════════════════════════════════════════════════════════════════ */
/** 绘制圆角矩形路径 */
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* 生成贴纸纹理
* 层次:黑塑料底 → 阴影凹槽 → 彩色圆角贴纸 → 多层渐变高光
*/
function makeSticker(color) {
const S = 256;
const cvs = document.createElement('canvas');
cvs.width = cvs.height = S;
const c = cvs.getContext('2d');
// ① 黑色塑料底座
c.fillStyle = '#111';
c.fillRect(0, 0, S, S);
const m = 18, r = 24;
// ② 贴纸外缘阴影(模拟凹槽)
roundRect(c, m - 3, m - 3, S - 2*m + 6, S - 2*m + 6, r + 3);
c.fillStyle = 'rgba(0,0,0,0.35)';
c.fill();
// ③ 彩色圆角贴纸
roundRect(c, m, m, S - 2*m, S - 2*m, r);
c.fillStyle = color;
c.fill();
// ④ 对角线渐变高光(模拟真实光泽)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g1 = c.createLinearGradient(m, m, S - m, S - m);
g1.addColorStop(0, 'rgba(255,255,255,0.30)');
g1.addColorStop(0.4, 'rgba(255,255,255,0.06)');
g1.addColorStop(0.6, 'rgba(0,0,0,0)');
g1.addColorStop(1, 'rgba(0,0,0,0.20)');
c.fillStyle = g1;
c.fill();
// ⑤ 顶部高光条(模拟边缘反光)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g2 = c.createLinearGradient(m, m, m, m + 55);
g2.addColorStop(0, 'rgba(255,255,255,0.28)');
g2.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g2;
c.fill();
const tex = new THREE.CanvasTexture(cvs);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/** 纯黑内部纹理 */
function makeBlackTex() {
const c = document.createElement('canvas');
c.width = c.height = 8;
const ctx = c.getContext('2d');
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, 8, 8);
return new THREE.CanvasTexture(c);
}
// 预生成所有纹理
const TEX = {};
for (const [k, v] of Object.entries(FACE_COLORS)) TEX[k] = makeSticker(v);
const TEX_BLACK = makeBlackTex();
/* ════════════════════════════════════════════════════════════════
Section 4 · 场景 / 相机 / 渲染器 / 光照
════════════════════════════════════════════════════════════════ */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(5.5, 4.5, 5.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── 光照:环境光 + 主平行光(ShadowMap)+ 补光 ──
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 30;
sun.shadow.camera.left = -5;
sun.shadow.camera.right = 5;
sun.shadow.camera.top = 5;
sun.shadow.camera.bottom = -5;
sun.shadow.bias = -0.0001;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x8888ff, 0.3);
fill.position.set(-4, 3, -5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.2);
rim.position.set(0, -3, -6);
scene.add(rim);
// ── 地面(ShadowMaterial 接收阴影)──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.25 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.2;
ground.receiveShadow = true;
scene.add(ground);
/* ════════════════════════════════════════════════════════════════
Section 5 · OrbitControls(右键旋转视角)
════════════════════════════════════════════════════════════════ */
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.enablePan = false;
orbit.minDistance = 4;
orbit.maxDistance = 15;
// 左键 → undefined(交给魔方交互),中键 → 缩放,右键 → 轨道旋转
orbit.mouseButtons = { LEFT: undefined, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
/* ════════════════════════════════════════════════════════════════
Section 6 · 构建 27 个独立 Cubies
════════════════════════════════════════════════════════════════ */
const cubies = [];
const cubieGeo = new THREE.BoxGeometry(SIZE, SIZE, SIZE); // 共享几何体
/**
* 为指定 (x,y,z) 位置的 cubie 生成六面材质
* BoxGeometry 面序:[+X, -X, +Y, -Y, +Z, -Z]
*/
function cubieMaterials(x, y, z) {
return [
x === 1 ? TEX.R : TEX_BLACK,
x === -1 ? TEX.L : TEX_BLACK,
y === 1 ? TEX.U : TEX_BLACK,
y === -1 ? TEX.D : TEX_BLACK,
z === 1 ? TEX.F : TEX_BLACK,
z === -1 ? TEX.B : TEX_BLACK,
].map(t => new THREE.MeshStandardMaterial({ map: t, roughness: 0.35, metalness: 0 }));
}
// 生成 3×3×3 = 27 个方块
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const mesh = new THREE.Mesh(cubieGeo, cubieMaterials(x, y, z));
mesh.position.set(x * GAP, y * GAP, z * GAP);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
cubies.push(mesh);
}
/* ════════════════════════════════════════════════════════════════
Section 7 · 坐标清洗 — 消除浮点累积误差
════════════════════════════════════════════════════════════════ */
/**
* 每次旋转结束后调用:
* 1. 位置四舍五入到整数(GAP=1)
* 2. 旋转矩阵元素四舍五入到 {-1, 0, 1}
* → 保证后续层判断精确匹配
*/
function cleanCubies() {
const _mat = new THREE.Matrix4();
for (const c of cubies) {
// 位置取整
c.position.set(
Math.round(c.position.x),
Math.round(c.position.y),
Math.round(c.position.z)
);
// 旋转矩阵取整
c.updateMatrix();
_mat.copy(c.matrix);
const e = _mat.elements;
for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]);
c.quaternion.setFromRotationMatrix(_mat);
}
}
/* ════════════════════════════════════════════════════════════════
Section 8 · 工具函数
════════════════════════════════════════════════════════════════ */
/** 将方向向量吸附到最近的主轴 (±X, ±Y, ±Z) */
function snapToAxis(v) {
const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
if (ax >= ay && ax >= az) return new THREE.Vector3(Math.sign(v.x), 0, 0);
if (ay >= ax && ay >= az) return new THREE.Vector3(0, Math.sign(v.y), 0);
return new THREE.Vector3(0, 0, Math.sign(v.z));
}
/** 给定面法线 → 返回与其垂直的两个主轴 */
function perpAxes(n) {
if (Math.abs(n.x) > 0.5) return [new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)];
if (Math.abs(n.y) > 0.5) return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,1)];
return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0)];
}
/** 3D 轴向量 → 轴名字符 */
function axName(v) {
if (Math.abs(v.x) > 0.5) return 'x';
if (Math.abs(v.y) > 0.5) return 'y';
return 'z';
}
/** 3D 世界点 → 屏幕像素坐标 */
function toScreen(p) {
const v = p.clone().project(camera);
return new THREE.Vector2(
(v.x + 1) * 0.5 * innerWidth,
(1 - v.y) * 0.5 * innerHeight
);
}
/** 将 3D 方向向量投影为屏幕像素方向 */
function screenDir(dir3, origin3) {
const a = toScreen(origin3);
const b = toScreen(origin3.clone().add(dir3));
return new THREE.Vector2(b.x - a.x, b.y - a.y);
}
/* ════════════════════════════════════════════════════════════════
Section 9 · 旋转状态机
════════════════════════════════════════════════════════════════ */
let interacting = false; // 鼠标左键按下追踪中
let animating = false; // 补间动画播放中
let scrambling = false; // 打乱序列执行中
let pivot = null; // 临时轴心 Object3D
let rotAx = null; // 当前旋转轴 'x'|'y'|'z'
let rotLayer = 0; // 当前层号 -1|0|1
let angle = 0; // 累计旋转弧度
let mouse0 = { x: 0, y: 0 };
// ── 手势预计算缓存(初始化旋转时计算一次)──
let gDir = null; // 屏幕空间旋转方向(归一化 2D 向量)
let gRate = 1; // 灵敏度:弧度 / 像素
// ── 点击信息缓存 ──
let clickNormal = null; // 点击面法线(已吸附主轴)
let clickPoint = null; // 点击的世界坐标
let clickCubie = null; // 被点击的 cubie Mesh
const rc = new THREE.Raycaster();
/* ════════════════════════════════════════════════════════════════
Section 10 · 交互事件处理
════════════════════════════════════════════════════════════════ */
function ndc(e) {
return new THREE.Vector2(
(e.clientX / innerWidth) * 2 - 1,
-(e.clientY / innerHeight) * 2 + 1
);
}
// ─── pointerdown ───
renderer.domElement.addEventListener('pointerdown', e => {
if (animating || scrambling || e.button !== 0) return;
rc.setFromCamera(ndc(e), camera);
const hits = rc.intersectObjects(cubies);
if (!hits.length) return;
const hit = hits[0];
clickCubie = hit.object;
// 将局部法线转换到世界空间并吸附到主轴
clickNormal = snapToAxis(hit.face.normal.clone().transformDirection(clickCubie.matrixWorld));
clickPoint = hit.point.clone();
mouse0 = { x: e.clientX, y: e.clientY };
angle = 0;
pivot = null;
rotAx = null;
interacting = true;
orbit.enabled = false; // 暂时禁用轨道控制,避免冲突
});
// ─── pointermove ───
renderer.domElement.addEventListener('pointermove', e => {
if (!interacting || animating) return;
const dx = e.clientX - mouse0.x;
const dy = e.clientY - mouse0.y;
// 阈值防误触:拖动不足 10px 不触发旋转
if (!pivot && dx * dx + dy * dy < 100) return;
// 首次超过阈值 → 初始化层旋转
if (!pivot) initLayerRotation(dx, dy);
if (!pivot) return;
// ── 实时跟手:将鼠标总位移投影到旋转屏幕方向 ──
const proj = dx * gDir.x + dy * gDir.y; // 像素分量
angle = proj * gRate; // 转为弧度
pivot.rotation[rotAx] = angle;
});
// ─── pointerup ───
renderer.domElement.addEventListener('pointerup', e => {
if (e.button !== 0) return;
interacting = false;
if (pivot) {
// 磁吸效果:计算最近的 90° 倍数
const target = Math.round(angle / HPI) * HPI;
animating = true;
new Tween({ a: angle })
.to({ a: target }, 200)
.easing(easeOutCubic)
.onUpdate(o => { pivot.rotation[rotAx] = o.a; })
.onComplete(() => releasePivot())
.start();
} else {
orbit.enabled = true; // 未触发旋转,恢复轨道控制
}
});
/* ════════════════════════════════════════════════════════════════
Section 11 · 核心手势识别算法 & Pivot 变换机制
┌─────────────────────────────────────────────────────────────┐
│ 算法概览: │
│ 1. 由面法线确定两个潜在旋转轴(均与法线垂直) │
│ 2. 计算每个轴的「正旋转切线方向」 = axis × normal │
│ 3. 将两条切线投影到 2D 屏幕空间 │
│ 4. 鼠标拖拽方向与两条投影做点积 → 选匹配度最高的轴 │
│ 5. 用数值微分法计算该轴的屏幕灵敏度(像素/弧度) │
│ 6. 创建 Pivot → attach() 挂载层内方块 → 旋转 → detach │
└─────────────────────────────────────────────────────────────┘
════════════════════════════════════════════════════════════════ */
/**
* 初始化层旋转
* 在首次拖动超过阈值时调用,一次性完成:
* - 选择旋转轴和层
* - 预计算屏幕灵敏度
* - 创建 Pivot 并挂载 cubies
*/
function initLayerRotation(dx, dy) {
const [ax1, ax2] = perpAxes(clickNormal);
// 正旋转切线方向 = axis × faceNormal
// 物理含义:该方向是「正角速度旋转时,面表面的运动方向」
const tan1 = new THREE.Vector3().crossVectors(ax1, clickNormal);
const tan2 = new THREE.Vector3().crossVectors(ax2, clickNormal);
// 投影到屏幕像素空间
const sTan1 = screenDir(tan1, clickPoint);
const sTan2 = screenDir(tan2, clickPoint);
// 拖拽方向(归一化)
const drag = new THREE.Vector2(dx, dy).normalize();
// 点积比较:选择与拖拽方向更匹配的轴
const dot1 = Math.abs(drag.dot(sTan1.clone().normalize()));
const dot2 = Math.abs(drag.dot(sTan2.clone().normalize()));
const chosen = dot1 >= dot2 ? ax1 : ax2;
rotAx = axName(chosen);
// 动态计算层号:根据被点击 cubie 在旋转轴上的世界坐标
rotLayer = Math.round(clickCubie.position[rotAx] / GAP);
// ── 数值微分法:计算屏幕灵敏度(像素/弧度)──
// 对点击点做微小旋转,观察屏幕位移
const eps = 0.01;
const s0 = toScreen(clickPoint);
const sp = toScreen(clickPoint.clone().applyAxisAngle(chosen, eps));
const deriv = new THREE.Vector2(sp.x - s0.x, sp.y - s0.y).divideScalar(eps);
// deriv 的方向 = 正旋转在屏幕上的运动方向
// deriv 的长度 = 像素/弧度
gDir = deriv.clone().normalize();
gRate = 1 / deriv.length(); // 弧度/像素
// 轴几乎垂直于屏幕(看不到旋转)→ 不触发
if (deriv.length() < 0.5) return;
// ── Pivot 变换机制(关键考点)──
pivot = new THREE.Object3D(); // 临时轴心,位于世界原点
scene.add(pivot);
for (const c of cubies) {
// 基于世界坐标动态筛选层,不依赖固定索引
if (Math.abs(c.position[rotAx] / GAP - rotLayer) < EPS) {
pivot.attach(c);
// attach() 自动计算并保持 cubie 的世界变换
// cubie 成为 pivot 的子节点,后续随 pivot 旋转
}
}
}
/** 释放 Pivot:将方块归还场景 + 坐标清洗 */
function releasePivot() {
if (!pivot) return;
// 将所有子对象挂回场景(自动保持旋转后的世界变换)
const kids = [...pivot.children]; // 复制数组,因为 attach 会修改 children
for (const c of kids) scene.attach(c);
cleanCubies(); // 坐标清洗:消除浮点误差
scene.remove(pivot);
pivot = null;
rotAx = null;
animating = false;
orbit.enabled = true;
}
/* ════════════════════════════════════════════════════════════════
Section 12 · 打乱(Scramble)& 重置(Reset)
════════════════════════════════════════════════════════════════ */
/** 通用层旋转(供 scramble 调用) */
function doRotation(ax, layer, ang, dur) {
return new Promise(resolve => {
const p = new THREE.Object3D();
scene.add(p);
for (const c of cubies)
if (Math.abs(c.position[ax] / GAP - layer) < EPS) p.attach(c);
animating = true;
new Tween({ a: 0 })
.to({ a: ang }, dur)
.easing(easeInOutCubic)
.onUpdate(o => { p.rotation[ax] = o.a; })
.onComplete(() => {
for (const c of [...p.children]) scene.attach(c);
cleanCubies();
scene.remove(p);
animating = false;
resolve();
})
.start();
});
}
async function scrambleCube() {
if (animating || scrambling || interacting) return;
scrambling = true;
orbit.enabled = false;
document.getElementById('btnScramble').disabled = true;
document.getElementById('btnReset').disabled = true;
const AXES = ['x', 'y', 'z'];
const LAYERS = [-1, 0, 1];
let lastA = '', lastL = -99;
for (let i = 0; i < 20; i++) {
let a, l;
// 避免连续旋转同一层(无意义)
do { a = AXES[Math.random() * 3 | 0]; l = LAYERS[Math.random() * 3 | 0]; }
while (a === lastA && l === lastL);
const dir = (Math.random() < 0.5 ? 1 : -1) * HPI;
await doRotation(a, l, dir, Math.max(40, 100 - 3 * i));
lastA = a; lastL = l;
}
scrambling = false;
orbit.enabled = true;
document.getElementById('btnScramble').disabled = false;
document.getElementById('btnReset').disabled = false;
}
function resetCube() {
if (animating || scrambling || interacting) return;
let i = 0;
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const c = cubies[i++];
c.position.set(x * GAP, y * GAP, z * GAP);
c.quaternion.identity();
}
}
document.getElementById('btnScramble').addEventListener('click', scrambleCube);
document.getElementById('btnReset').addEventListener('click', resetCube);
/* ════════════════════════════════════════════════════════════════
Section 13 · 窗口自适应 & 渲染循环
════════════════════════════════════════════════════════════════ */
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
(function loop() {
requestAnimationFrame(loop);
tickTweens();
orbit.update();
renderer.render(scene, camera);
})();
</script>
</body>
</html>
模型的额外输出
image851×1068 77.8 KB
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试
测试环境 cherry studio, 上游是opencode go, 思考模式默认
思考时间 937.6s
测试结果
https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB)
概要:
相当完美,鼠标操作底部那一排似乎有些转不过来
提示词:
你是一名精通图形学算法与 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(重置)功能按钮。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3×3 魔方 — Three.js Physical-Grade Rubik's Cube</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
overflow:hidden;
background:#0d0d1a;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
canvas{display:block}
#ui{
position:fixed;bottom:30px;left:50%;transform:translateX(-50%);
display:flex;gap:14px;z-index:10;
}
.btn{
padding:12px 30px;font-size:15px;font-weight:600;
border:2px solid rgba(255,255,255,.15);border-radius:14px;
cursor:pointer;color:#fff;
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
background:rgba(255,255,255,.08);
transition:all .3s cubic-bezier(.4,0,.2,1);
letter-spacing:.5px;user-select:none;
}
.btn:hover{
background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.35);
transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,.4);
}
.btn:active{transform:translateY(0);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.btn.scramble{border-color:rgba(245,87,108,.5)}
.btn.scramble:hover{background:rgba(245,87,108,.22)}
.btn.reset{border-color:rgba(78,205,196,.5)}
.btn.reset:hover{background:rgba(78,205,196,.22)}
.btn:disabled{opacity:.4;pointer-events:none}
#info{
position:fixed;top:20px;width:100%;text-align:center;
color:rgba(255,255,255,.45);font-size:13px;z-index:10;pointer-events:none;
}
</style>
</head>
<body>
<div id="info">🖱️ 左键拖拽旋转层 · 右键拖拽旋转视角 · 滚轮缩放</div>
<div id="ui">
<button class="btn scramble" id="btnScramble">🎲 打乱</button>
<button class="btn reset" id="btnReset">🔄 重置</button>
</div>
<!-- ═══════════ Import Map: Three.js + OrbitControls ═══════════ -->
<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/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ════════════════════════════════════════════════════════════════
Section 1 · 轻量 Tween 引擎(模拟 Tween.js API)
════════════════════════════════════════════════════════════════ */
const _tweens = [];
class Tween {
constructor(obj) {
this._o = obj; // 被驱动的对象
this._s = {}; // 起始值快照
this._e = {}; // 目标值
this._d = 300; // 持续时间 ms
this._ef = t => t; // 缓动函数
this._uc = null; // onUpdate 回调
this._cc = null; // onComplete 回调
this._t0 = 0; // 起始时间戳
this._r = false; // 是否运行中
}
to(target, dur) { this._e = target; if (dur != null) this._d = dur; return this; }
easing(fn) { this._ef = fn; return this; }
onUpdate(fn) { this._uc = fn; return this; }
onComplete(fn) { this._cc = fn; return this; }
start() {
for (const k in this._e) this._s[k] = this._o[k];
this._t0 = performance.now();
this._r = true;
_tweens.push(this);
return this;
}
_tick(now) {
if (!this._r) return false;
const t = Math.min((now - this._t0) / this._d, 1);
const e = this._ef(t);
for (const k in this._e)
this._o[k] = this._s[k] + (this._e[k] - this._s[k]) * e;
if (this._uc) this._uc(this._o);
if (t >= 1) { this._r = false; if (this._cc) this._cc(); return false; }
return true;
}
}
function tickTweens() {
const n = performance.now();
for (let i = _tweens.length - 1; i >= 0; i--)
if (!_tweens[i]._tick(n)) _tweens.splice(i, 1);
}
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const easeInOutCubic = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
/* ════════════════════════════════════════════════════════════════
Section 2 · 常量 & 配色
════════════════════════════════════════════════════════════════ */
const GAP = 1; // 方块中心距
const SPACING = 0.08; // 物理间隙
const SIZE = GAP - SPACING; // 小方块视觉边长
const EPS = 0.1; // 层判断容差
const HPI = Math.PI / 2; // 90°
const FACE_COLORS = {
R: '#B71234', // +X 红
L: '#FF5800', // -X 橙
U: '#FFFFFF', // +Y 白
D: '#FFD500', // -Y 黄
F: '#009B48', // +Z 绿
B: '#0046AD', // -Z 蓝
};
/* ════════════════════════════════════════════════════════════════
Section 3 · Canvas API 程序化纹理(零外部图片)
════════════════════════════════════════════════════════════════ */
/** 绘制圆角矩形路径 */
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* 生成贴纸纹理
* 层次:黑塑料底 → 阴影凹槽 → 彩色圆角贴纸 → 多层渐变高光
*/
function makeSticker(color) {
const S = 256;
const cvs = document.createElement('canvas');
cvs.width = cvs.height = S;
const c = cvs.getContext('2d');
// ① 黑色塑料底座
c.fillStyle = '#111';
c.fillRect(0, 0, S, S);
const m = 18, r = 24;
// ② 贴纸外缘阴影(模拟凹槽)
roundRect(c, m - 3, m - 3, S - 2*m + 6, S - 2*m + 6, r + 3);
c.fillStyle = 'rgba(0,0,0,0.35)';
c.fill();
// ③ 彩色圆角贴纸
roundRect(c, m, m, S - 2*m, S - 2*m, r);
c.fillStyle = color;
c.fill();
// ④ 对角线渐变高光(模拟真实光泽)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g1 = c.createLinearGradient(m, m, S - m, S - m);
g1.addColorStop(0, 'rgba(255,255,255,0.30)');
g1.addColorStop(0.4, 'rgba(255,255,255,0.06)');
g1.addColorStop(0.6, 'rgba(0,0,0,0)');
g1.addColorStop(1, 'rgba(0,0,0,0.20)');
c.fillStyle = g1;
c.fill();
// ⑤ 顶部高光条(模拟边缘反光)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g2 = c.createLinearGradient(m, m, m, m + 55);
g2.addColorStop(0, 'rgba(255,255,255,0.28)');
g2.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g2;
c.fill();
const tex = new THREE.CanvasTexture(cvs);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/** 纯黑内部纹理 */
function makeBlackTex() {
const c = document.createElement('canvas');
c.width = c.height = 8;
const ctx = c.getContext('2d');
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, 8, 8);
return new THREE.CanvasTexture(c);
}
// 预生成所有纹理
const TEX = {};
for (const [k, v] of Object.entries(FACE_COLORS)) TEX[k] = makeSticker(v);
const TEX_BLACK = makeBlackTex();
/* ════════════════════════════════════════════════════════════════
Section 4 · 场景 / 相机 / 渲染器 / 光照
════════════════════════════════════════════════════════════════ */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(5.5, 4.5, 5.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── 光照:环境光 + 主平行光(ShadowMap)+ 补光 ──
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 30;
sun.shadow.camera.left = -5;
sun.shadow.camera.right = 5;
sun.shadow.camera.top = 5;
sun.shadow.camera.bottom = -5;
sun.shadow.bias = -0.0001;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x8888ff, 0.3);
fill.position.set(-4, 3, -5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.2);
rim.position.set(0, -3, -6);
scene.add(rim);
// ── 地面(ShadowMaterial 接收阴影)──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.25 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.2;
ground.receiveShadow = true;
scene.add(ground);
/* ════════════════════════════════════════════════════════════════
Section 5 · OrbitControls(右键旋转视角)
════════════════════════════════════════════════════════════════ */
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.enablePan = false;
orbit.minDistance = 4;
orbit.maxDistance = 15;
// 左键 → undefined(交给魔方交互),中键 → 缩放,右键 → 轨道旋转
orbit.mouseButtons = { LEFT: undefined, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
/* ════════════════════════════════════════════════════════════════
Section 6 · 构建 27 个独立 Cubies
════════════════════════════════════════════════════════════════ */
const cubies = [];
const cubieGeo = new THREE.BoxGeometry(SIZE, SIZE, SIZE); // 共享几何体
/**
* 为指定 (x,y,z) 位置的 cubie 生成六面材质
* BoxGeometry 面序:[+X, -X, +Y, -Y, +Z, -Z]
*/
function cubieMaterials(x, y, z) {
return [
x === 1 ? TEX.R : TEX_BLACK,
x === -1 ? TEX.L : TEX_BLACK,
y === 1 ? TEX.U : TEX_BLACK,
y === -1 ? TEX.D : TEX_BLACK,
z === 1 ? TEX.F : TEX_BLACK,
z === -1 ? TEX.B : TEX_BLACK,
].map(t => new THREE.MeshStandardMaterial({ map: t, roughness: 0.35, metalness: 0 }));
}
// 生成 3×3×3 = 27 个方块
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const mesh = new THREE.Mesh(cubieGeo, cubieMaterials(x, y, z));
mesh.position.set(x * GAP, y * GAP, z * GAP);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
cubies.push(mesh);
}
/* ════════════════════════════════════════════════════════════════
Section 7 · 坐标清洗 — 消除浮点累积误差
════════════════════════════════════════════════════════════════ */
/**
* 每次旋转结束后调用:
* 1. 位置四舍五入到整数(GAP=1)
* 2. 旋转矩阵元素四舍五入到 {-1, 0, 1}
* → 保证后续层判断精确匹配
*/
function cleanCubies() {
const _mat = new THREE.Matrix4();
for (const c of cubies) {
// 位置取整
c.position.set(
Math.round(c.position.x),
Math.round(c.position.y),
Math.round(c.position.z)
);
// 旋转矩阵取整
c.updateMatrix();
_mat.copy(c.matrix);
const e = _mat.elements;
for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]);
c.quaternion.setFromRotationMatrix(_mat);
}
}
/* ════════════════════════════════════════════════════════════════
Section 8 · 工具函数
════════════════════════════════════════════════════════════════ */
/** 将方向向量吸附到最近的主轴 (±X, ±Y, ±Z) */
function snapToAxis(v) {
const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
if (ax >= ay && ax >= az) return new THREE.Vector3(Math.sign(v.x), 0, 0);
if (ay >= ax && ay >= az) return new THREE.Vector3(0, Math.sign(v.y), 0);
return new THREE.Vector3(0, 0, Math.sign(v.z));
}
/** 给定面法线 → 返回与其垂直的两个主轴 */
function perpAxes(n) {
if (Math.abs(n.x) > 0.5) return [new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)];
if (Math.abs(n.y) > 0.5) return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,1)];
return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0)];
}
/** 3D 轴向量 → 轴名字符 */
function axName(v) {
if (Math.abs(v.x) > 0.5) return 'x';
if (Math.abs(v.y) > 0.5) return 'y';
return 'z';
}
/** 3D 世界点 → 屏幕像素坐标 */
function toScreen(p) {
const v = p.clone().project(camera);
return new THREE.Vector2(
(v.x + 1) * 0.5 * innerWidth,
(1 - v.y) * 0.5 * innerHeight
);
}
/** 将 3D 方向向量投影为屏幕像素方向 */
function screenDir(dir3, origin3) {
const a = toScreen(origin3);
const b = toScreen(origin3.clone().add(dir3));
return new THREE.Vector2(b.x - a.x, b.y - a.y);
}
/* ════════════════════════════════════════════════════════════════
Section 9 · 旋转状态机
════════════════════════════════════════════════════════════════ */
let interacting = false; // 鼠标左键按下追踪中
let animating = false; // 补间动画播放中
let scrambling = false; // 打乱序列执行中
let pivot = null; // 临时轴心 Object3D
let rotAx = null; // 当前旋转轴 'x'|'y'|'z'
let rotLayer = 0; // 当前层号 -1|0|1
let angle = 0; // 累计旋转弧度
let mouse0 = { x: 0, y: 0 };
// ── 手势预计算缓存(初始化旋转时计算一次)──
let gDir = null; // 屏幕空间旋转方向(归一化 2D 向量)
let gRate = 1; // 灵敏度:弧度 / 像素
// ── 点击信息缓存 ──
let clickNormal = null; // 点击面法线(已吸附主轴)
let clickPoint = null; // 点击的世界坐标
let clickCubie = null; // 被点击的 cubie Mesh
const rc = new THREE.Raycaster();
/* ════════════════════════════════════════════════════════════════
Section 10 · 交互事件处理
════════════════════════════════════════════════════════════════ */
function ndc(e) {
return new THREE.Vector2(
(e.clientX / innerWidth) * 2 - 1,
-(e.clientY / innerHeight) * 2 + 1
);
}
// ─── pointerdown ───
renderer.domElement.addEventListener('pointerdown', e => {
if (animating || scrambling || e.button !== 0) return;
rc.setFromCamera(ndc(e), camera);
const hits = rc.intersectObjects(cubies);
if (!hits.length) return;
const hit = hits[0];
clickCubie = hit.object;
// 将局部法线转换到世界空间并吸附到主轴
clickNormal = snapToAxis(hit.face.normal.clone().transformDirection(clickCubie.matrixWorld));
clickPoint = hit.point.clone();
mouse0 = { x: e.clientX, y: e.clientY };
angle = 0;
pivot = null;
rotAx = null;
interacting = true;
orbit.enabled = false; // 暂时禁用轨道控制,避免冲突
});
// ─── pointermove ───
renderer.domElement.addEventListener('pointermove', e => {
if (!interacting || animating) return;
const dx = e.clientX - mouse0.x;
const dy = e.clientY - mouse0.y;
// 阈值防误触:拖动不足 10px 不触发旋转
if (!pivot && dx * dx + dy * dy < 100) return;
// 首次超过阈值 → 初始化层旋转
if (!pivot) initLayerRotation(dx, dy);
if (!pivot) return;
// ── 实时跟手:将鼠标总位移投影到旋转屏幕方向 ──
const proj = dx * gDir.x + dy * gDir.y; // 像素分量
angle = proj * gRate; // 转为弧度
pivot.rotation[rotAx] = angle;
});
// ─── pointerup ───
renderer.domElement.addEventListener('pointerup', e => {
if (e.button !== 0) return;
interacting = false;
if (pivot) {
// 磁吸效果:计算最近的 90° 倍数
const target = Math.round(angle / HPI) * HPI;
animating = true;
new Tween({ a: angle })
.to({ a: target }, 200)
.easing(easeOutCubic)
.onUpdate(o => { pivot.rotation[rotAx] = o.a; })
.onComplete(() => releasePivot())
.start();
} else {
orbit.enabled = true; // 未触发旋转,恢复轨道控制
}
});
/* ════════════════════════════════════════════════════════════════
Section 11 · 核心手势识别算法 & Pivot 变换机制
┌─────────────────────────────────────────────────────────────┐
│ 算法概览: │
│ 1. 由面法线确定两个潜在旋转轴(均与法线垂直) │
│ 2. 计算每个轴的「正旋转切线方向」 = axis × normal │
│ 3. 将两条切线投影到 2D 屏幕空间 │
│ 4. 鼠标拖拽方向与两条投影做点积 → 选匹配度最高的轴 │
│ 5. 用数值微分法计算该轴的屏幕灵敏度(像素/弧度) │
│ 6. 创建 Pivot → attach() 挂载层内方块 → 旋转 → detach │
└─────────────────────────────────────────────────────────────┘
════════════════════════════════════════════════════════════════ */
/**
* 初始化层旋转
* 在首次拖动超过阈值时调用,一次性完成:
* - 选择旋转轴和层
* - 预计算屏幕灵敏度
* - 创建 Pivot 并挂载 cubies
*/
function initLayerRotation(dx, dy) {
const [ax1, ax2] = perpAxes(clickNormal);
// 正旋转切线方向 = axis × faceNormal
// 物理含义:该方向是「正角速度旋转时,面表面的运动方向」
const tan1 = new THREE.Vector3().crossVectors(ax1, clickNormal);
const tan2 = new THREE.Vector3().crossVectors(ax2, clickNormal);
// 投影到屏幕像素空间
const sTan1 = screenDir(tan1, clickPoint);
const sTan2 = screenDir(tan2, clickPoint);
// 拖拽方向(归一化)
const drag = new THREE.Vector2(dx, dy).normalize();
// 点积比较:选择与拖拽方向更匹配的轴
const dot1 = Math.abs(drag.dot(sTan1.clone().normalize()));
const dot2 = Math.abs(drag.dot(sTan2.clone().normalize()));
const chosen = dot1 >= dot2 ? ax1 : ax2;
rotAx = axName(chosen);
// 动态计算层号:根据被点击 cubie 在旋转轴上的世界坐标
rotLayer = Math.round(clickCubie.position[rotAx] / GAP);
// ── 数值微分法:计算屏幕灵敏度(像素/弧度)──
// 对点击点做微小旋转,观察屏幕位移
const eps = 0.01;
const s0 = toScreen(clickPoint);
const sp = toScreen(clickPoint.clone().applyAxisAngle(chosen, eps));
const deriv = new THREE.Vector2(sp.x - s0.x, sp.y - s0.y).divideScalar(eps);
// deriv 的方向 = 正旋转在屏幕上的运动方向
// deriv 的长度 = 像素/弧度
gDir = deriv.clone().normalize();
gRate = 1 / deriv.length(); // 弧度/像素
// 轴几乎垂直于屏幕(看不到旋转)→ 不触发
if (deriv.length() < 0.5) return;
// ── Pivot 变换机制(关键考点)──
pivot = new THREE.Object3D(); // 临时轴心,位于世界原点
scene.add(pivot);
for (const c of cubies) {
// 基于世界坐标动态筛选层,不依赖固定索引
if (Math.abs(c.position[rotAx] / GAP - rotLayer) < EPS) {
pivot.attach(c);
// attach() 自动计算并保持 cubie 的世界变换
// cubie 成为 pivot 的子节点,后续随 pivot 旋转
}
}
}
/** 释放 Pivot:将方块归还场景 + 坐标清洗 */
function releasePivot() {
if (!pivot) return;
// 将所有子对象挂回场景(自动保持旋转后的世界变换)
const kids = [...pivot.children]; // 复制数组,因为 attach 会修改 children
for (const c of kids) scene.attach(c);
cleanCubies(); // 坐标清洗:消除浮点误差
scene.remove(pivot);
pivot = null;
rotAx = null;
animating = false;
orbit.enabled = true;
}
/* ════════════════════════════════════════════════════════════════
Section 12 · 打乱(Scramble)& 重置(Reset)
════════════════════════════════════════════════════════════════ */
/** 通用层旋转(供 scramble 调用) */
function doRotation(ax, layer, ang, dur) {
return new Promise(resolve => {
const p = new THREE.Object3D();
scene.add(p);
for (const c of cubies)
if (Math.abs(c.position[ax] / GAP - layer) < EPS) p.attach(c);
animating = true;
new Tween({ a: 0 })
.to({ a: ang }, dur)
.easing(easeInOutCubic)
.onUpdate(o => { p.rotation[ax] = o.a; })
.onComplete(() => {
for (const c of [...p.children]) scene.attach(c);
cleanCubies();
scene.remove(p);
animating = false;
resolve();
})
.start();
});
}
async function scrambleCube() {
if (animating || scrambling || interacting) return;
scrambling = true;
orbit.enabled = false;
document.getElementById('btnScramble').disabled = true;
document.getElementById('btnReset').disabled = true;
const AXES = ['x', 'y', 'z'];
const LAYERS = [-1, 0, 1];
let lastA = '', lastL = -99;
for (let i = 0; i < 20; i++) {
let a, l;
// 避免连续旋转同一层(无意义)
do { a = AXES[Math.random() * 3 | 0]; l = LAYERS[Math.random() * 3 | 0]; }
while (a === lastA && l === lastL);
const dir = (Math.random() < 0.5 ? 1 : -1) * HPI;
await doRotation(a, l, dir, Math.max(40, 100 - 3 * i));
lastA = a; lastL = l;
}
scrambling = false;
orbit.enabled = true;
document.getElementById('btnScramble').disabled = false;
document.getElementById('btnReset').disabled = false;
}
function resetCube() {
if (animating || scrambling || interacting) return;
let i = 0;
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const c = cubies[i++];
c.position.set(x * GAP, y * GAP, z * GAP);
c.quaternion.identity();
}
}
document.getElementById('btnScramble').addEventListener('click', scrambleCube);
document.getElementById('btnReset').addEventListener('click', resetCube);
/* ════════════════════════════════════════════════════════════════
Section 13 · 窗口自适应 & 渲染循环
════════════════════════════════════════════════════════════════ */
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
(function loop() {
requestAnimationFrame(loop);
tickTweens();
orbit.update();
renderer.render(scene, camera);
})();
</script>
</body>
</html>
模型的额外输出
image851×1068 77.8 KB
--【壹】--:
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试
测试环境 cherry studio, 上游是opencode go, 思考模式默认
思考时间 937.6s
测试结果
https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB)
概要:
相当完美,鼠标操作底部那一排似乎有些转不过来
提示词:
你是一名精通图形学算法与 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(重置)功能按钮。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3×3 魔方 — Three.js Physical-Grade Rubik's Cube</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
overflow:hidden;
background:#0d0d1a;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
canvas{display:block}
#ui{
position:fixed;bottom:30px;left:50%;transform:translateX(-50%);
display:flex;gap:14px;z-index:10;
}
.btn{
padding:12px 30px;font-size:15px;font-weight:600;
border:2px solid rgba(255,255,255,.15);border-radius:14px;
cursor:pointer;color:#fff;
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
background:rgba(255,255,255,.08);
transition:all .3s cubic-bezier(.4,0,.2,1);
letter-spacing:.5px;user-select:none;
}
.btn:hover{
background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.35);
transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,.4);
}
.btn:active{transform:translateY(0);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.btn.scramble{border-color:rgba(245,87,108,.5)}
.btn.scramble:hover{background:rgba(245,87,108,.22)}
.btn.reset{border-color:rgba(78,205,196,.5)}
.btn.reset:hover{background:rgba(78,205,196,.22)}
.btn:disabled{opacity:.4;pointer-events:none}
#info{
position:fixed;top:20px;width:100%;text-align:center;
color:rgba(255,255,255,.45);font-size:13px;z-index:10;pointer-events:none;
}
</style>
</head>
<body>
<div id="info">🖱️ 左键拖拽旋转层 · 右键拖拽旋转视角 · 滚轮缩放</div>
<div id="ui">
<button class="btn scramble" id="btnScramble">🎲 打乱</button>
<button class="btn reset" id="btnReset">🔄 重置</button>
</div>
<!-- ═══════════ Import Map: Three.js + OrbitControls ═══════════ -->
<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/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ════════════════════════════════════════════════════════════════
Section 1 · 轻量 Tween 引擎(模拟 Tween.js API)
════════════════════════════════════════════════════════════════ */
const _tweens = [];
class Tween {
constructor(obj) {
this._o = obj; // 被驱动的对象
this._s = {}; // 起始值快照
this._e = {}; // 目标值
this._d = 300; // 持续时间 ms
this._ef = t => t; // 缓动函数
this._uc = null; // onUpdate 回调
this._cc = null; // onComplete 回调
this._t0 = 0; // 起始时间戳
this._r = false; // 是否运行中
}
to(target, dur) { this._e = target; if (dur != null) this._d = dur; return this; }
easing(fn) { this._ef = fn; return this; }
onUpdate(fn) { this._uc = fn; return this; }
onComplete(fn) { this._cc = fn; return this; }
start() {
for (const k in this._e) this._s[k] = this._o[k];
this._t0 = performance.now();
this._r = true;
_tweens.push(this);
return this;
}
_tick(now) {
if (!this._r) return false;
const t = Math.min((now - this._t0) / this._d, 1);
const e = this._ef(t);
for (const k in this._e)
this._o[k] = this._s[k] + (this._e[k] - this._s[k]) * e;
if (this._uc) this._uc(this._o);
if (t >= 1) { this._r = false; if (this._cc) this._cc(); return false; }
return true;
}
}
function tickTweens() {
const n = performance.now();
for (let i = _tweens.length - 1; i >= 0; i--)
if (!_tweens[i]._tick(n)) _tweens.splice(i, 1);
}
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const easeInOutCubic = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
/* ════════════════════════════════════════════════════════════════
Section 2 · 常量 & 配色
════════════════════════════════════════════════════════════════ */
const GAP = 1; // 方块中心距
const SPACING = 0.08; // 物理间隙
const SIZE = GAP - SPACING; // 小方块视觉边长
const EPS = 0.1; // 层判断容差
const HPI = Math.PI / 2; // 90°
const FACE_COLORS = {
R: '#B71234', // +X 红
L: '#FF5800', // -X 橙
U: '#FFFFFF', // +Y 白
D: '#FFD500', // -Y 黄
F: '#009B48', // +Z 绿
B: '#0046AD', // -Z 蓝
};
/* ════════════════════════════════════════════════════════════════
Section 3 · Canvas API 程序化纹理(零外部图片)
════════════════════════════════════════════════════════════════ */
/** 绘制圆角矩形路径 */
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* 生成贴纸纹理
* 层次:黑塑料底 → 阴影凹槽 → 彩色圆角贴纸 → 多层渐变高光
*/
function makeSticker(color) {
const S = 256;
const cvs = document.createElement('canvas');
cvs.width = cvs.height = S;
const c = cvs.getContext('2d');
// ① 黑色塑料底座
c.fillStyle = '#111';
c.fillRect(0, 0, S, S);
const m = 18, r = 24;
// ② 贴纸外缘阴影(模拟凹槽)
roundRect(c, m - 3, m - 3, S - 2*m + 6, S - 2*m + 6, r + 3);
c.fillStyle = 'rgba(0,0,0,0.35)';
c.fill();
// ③ 彩色圆角贴纸
roundRect(c, m, m, S - 2*m, S - 2*m, r);
c.fillStyle = color;
c.fill();
// ④ 对角线渐变高光(模拟真实光泽)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g1 = c.createLinearGradient(m, m, S - m, S - m);
g1.addColorStop(0, 'rgba(255,255,255,0.30)');
g1.addColorStop(0.4, 'rgba(255,255,255,0.06)');
g1.addColorStop(0.6, 'rgba(0,0,0,0)');
g1.addColorStop(1, 'rgba(0,0,0,0.20)');
c.fillStyle = g1;
c.fill();
// ⑤ 顶部高光条(模拟边缘反光)
roundRect(c, m, m, S - 2*m, S - 2*m, r);
const g2 = c.createLinearGradient(m, m, m, m + 55);
g2.addColorStop(0, 'rgba(255,255,255,0.28)');
g2.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g2;
c.fill();
const tex = new THREE.CanvasTexture(cvs);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/** 纯黑内部纹理 */
function makeBlackTex() {
const c = document.createElement('canvas');
c.width = c.height = 8;
const ctx = c.getContext('2d');
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, 8, 8);
return new THREE.CanvasTexture(c);
}
// 预生成所有纹理
const TEX = {};
for (const [k, v] of Object.entries(FACE_COLORS)) TEX[k] = makeSticker(v);
const TEX_BLACK = makeBlackTex();
/* ════════════════════════════════════════════════════════════════
Section 4 · 场景 / 相机 / 渲染器 / 光照
════════════════════════════════════════════════════════════════ */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(5.5, 4.5, 5.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
// ── 光照:环境光 + 主平行光(ShadowMap)+ 补光 ──
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 30;
sun.shadow.camera.left = -5;
sun.shadow.camera.right = 5;
sun.shadow.camera.top = 5;
sun.shadow.camera.bottom = -5;
sun.shadow.bias = -0.0001;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x8888ff, 0.3);
fill.position.set(-4, 3, -5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.2);
rim.position.set(0, -3, -6);
scene.add(rim);
// ── 地面(ShadowMaterial 接收阴影)──
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.ShadowMaterial({ opacity: 0.25 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.2;
ground.receiveShadow = true;
scene.add(ground);
/* ════════════════════════════════════════════════════════════════
Section 5 · OrbitControls(右键旋转视角)
════════════════════════════════════════════════════════════════ */
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.enablePan = false;
orbit.minDistance = 4;
orbit.maxDistance = 15;
// 左键 → undefined(交给魔方交互),中键 → 缩放,右键 → 轨道旋转
orbit.mouseButtons = { LEFT: undefined, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
/* ════════════════════════════════════════════════════════════════
Section 6 · 构建 27 个独立 Cubies
════════════════════════════════════════════════════════════════ */
const cubies = [];
const cubieGeo = new THREE.BoxGeometry(SIZE, SIZE, SIZE); // 共享几何体
/**
* 为指定 (x,y,z) 位置的 cubie 生成六面材质
* BoxGeometry 面序:[+X, -X, +Y, -Y, +Z, -Z]
*/
function cubieMaterials(x, y, z) {
return [
x === 1 ? TEX.R : TEX_BLACK,
x === -1 ? TEX.L : TEX_BLACK,
y === 1 ? TEX.U : TEX_BLACK,
y === -1 ? TEX.D : TEX_BLACK,
z === 1 ? TEX.F : TEX_BLACK,
z === -1 ? TEX.B : TEX_BLACK,
].map(t => new THREE.MeshStandardMaterial({ map: t, roughness: 0.35, metalness: 0 }));
}
// 生成 3×3×3 = 27 个方块
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const mesh = new THREE.Mesh(cubieGeo, cubieMaterials(x, y, z));
mesh.position.set(x * GAP, y * GAP, z * GAP);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
cubies.push(mesh);
}
/* ════════════════════════════════════════════════════════════════
Section 7 · 坐标清洗 — 消除浮点累积误差
════════════════════════════════════════════════════════════════ */
/**
* 每次旋转结束后调用:
* 1. 位置四舍五入到整数(GAP=1)
* 2. 旋转矩阵元素四舍五入到 {-1, 0, 1}
* → 保证后续层判断精确匹配
*/
function cleanCubies() {
const _mat = new THREE.Matrix4();
for (const c of cubies) {
// 位置取整
c.position.set(
Math.round(c.position.x),
Math.round(c.position.y),
Math.round(c.position.z)
);
// 旋转矩阵取整
c.updateMatrix();
_mat.copy(c.matrix);
const e = _mat.elements;
for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]);
c.quaternion.setFromRotationMatrix(_mat);
}
}
/* ════════════════════════════════════════════════════════════════
Section 8 · 工具函数
════════════════════════════════════════════════════════════════ */
/** 将方向向量吸附到最近的主轴 (±X, ±Y, ±Z) */
function snapToAxis(v) {
const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
if (ax >= ay && ax >= az) return new THREE.Vector3(Math.sign(v.x), 0, 0);
if (ay >= ax && ay >= az) return new THREE.Vector3(0, Math.sign(v.y), 0);
return new THREE.Vector3(0, 0, Math.sign(v.z));
}
/** 给定面法线 → 返回与其垂直的两个主轴 */
function perpAxes(n) {
if (Math.abs(n.x) > 0.5) return [new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)];
if (Math.abs(n.y) > 0.5) return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,1)];
return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0)];
}
/** 3D 轴向量 → 轴名字符 */
function axName(v) {
if (Math.abs(v.x) > 0.5) return 'x';
if (Math.abs(v.y) > 0.5) return 'y';
return 'z';
}
/** 3D 世界点 → 屏幕像素坐标 */
function toScreen(p) {
const v = p.clone().project(camera);
return new THREE.Vector2(
(v.x + 1) * 0.5 * innerWidth,
(1 - v.y) * 0.5 * innerHeight
);
}
/** 将 3D 方向向量投影为屏幕像素方向 */
function screenDir(dir3, origin3) {
const a = toScreen(origin3);
const b = toScreen(origin3.clone().add(dir3));
return new THREE.Vector2(b.x - a.x, b.y - a.y);
}
/* ════════════════════════════════════════════════════════════════
Section 9 · 旋转状态机
════════════════════════════════════════════════════════════════ */
let interacting = false; // 鼠标左键按下追踪中
let animating = false; // 补间动画播放中
let scrambling = false; // 打乱序列执行中
let pivot = null; // 临时轴心 Object3D
let rotAx = null; // 当前旋转轴 'x'|'y'|'z'
let rotLayer = 0; // 当前层号 -1|0|1
let angle = 0; // 累计旋转弧度
let mouse0 = { x: 0, y: 0 };
// ── 手势预计算缓存(初始化旋转时计算一次)──
let gDir = null; // 屏幕空间旋转方向(归一化 2D 向量)
let gRate = 1; // 灵敏度:弧度 / 像素
// ── 点击信息缓存 ──
let clickNormal = null; // 点击面法线(已吸附主轴)
let clickPoint = null; // 点击的世界坐标
let clickCubie = null; // 被点击的 cubie Mesh
const rc = new THREE.Raycaster();
/* ════════════════════════════════════════════════════════════════
Section 10 · 交互事件处理
════════════════════════════════════════════════════════════════ */
function ndc(e) {
return new THREE.Vector2(
(e.clientX / innerWidth) * 2 - 1,
-(e.clientY / innerHeight) * 2 + 1
);
}
// ─── pointerdown ───
renderer.domElement.addEventListener('pointerdown', e => {
if (animating || scrambling || e.button !== 0) return;
rc.setFromCamera(ndc(e), camera);
const hits = rc.intersectObjects(cubies);
if (!hits.length) return;
const hit = hits[0];
clickCubie = hit.object;
// 将局部法线转换到世界空间并吸附到主轴
clickNormal = snapToAxis(hit.face.normal.clone().transformDirection(clickCubie.matrixWorld));
clickPoint = hit.point.clone();
mouse0 = { x: e.clientX, y: e.clientY };
angle = 0;
pivot = null;
rotAx = null;
interacting = true;
orbit.enabled = false; // 暂时禁用轨道控制,避免冲突
});
// ─── pointermove ───
renderer.domElement.addEventListener('pointermove', e => {
if (!interacting || animating) return;
const dx = e.clientX - mouse0.x;
const dy = e.clientY - mouse0.y;
// 阈值防误触:拖动不足 10px 不触发旋转
if (!pivot && dx * dx + dy * dy < 100) return;
// 首次超过阈值 → 初始化层旋转
if (!pivot) initLayerRotation(dx, dy);
if (!pivot) return;
// ── 实时跟手:将鼠标总位移投影到旋转屏幕方向 ──
const proj = dx * gDir.x + dy * gDir.y; // 像素分量
angle = proj * gRate; // 转为弧度
pivot.rotation[rotAx] = angle;
});
// ─── pointerup ───
renderer.domElement.addEventListener('pointerup', e => {
if (e.button !== 0) return;
interacting = false;
if (pivot) {
// 磁吸效果:计算最近的 90° 倍数
const target = Math.round(angle / HPI) * HPI;
animating = true;
new Tween({ a: angle })
.to({ a: target }, 200)
.easing(easeOutCubic)
.onUpdate(o => { pivot.rotation[rotAx] = o.a; })
.onComplete(() => releasePivot())
.start();
} else {
orbit.enabled = true; // 未触发旋转,恢复轨道控制
}
});
/* ════════════════════════════════════════════════════════════════
Section 11 · 核心手势识别算法 & Pivot 变换机制
┌─────────────────────────────────────────────────────────────┐
│ 算法概览: │
│ 1. 由面法线确定两个潜在旋转轴(均与法线垂直) │
│ 2. 计算每个轴的「正旋转切线方向」 = axis × normal │
│ 3. 将两条切线投影到 2D 屏幕空间 │
│ 4. 鼠标拖拽方向与两条投影做点积 → 选匹配度最高的轴 │
│ 5. 用数值微分法计算该轴的屏幕灵敏度(像素/弧度) │
│ 6. 创建 Pivot → attach() 挂载层内方块 → 旋转 → detach │
└─────────────────────────────────────────────────────────────┘
════════════════════════════════════════════════════════════════ */
/**
* 初始化层旋转
* 在首次拖动超过阈值时调用,一次性完成:
* - 选择旋转轴和层
* - 预计算屏幕灵敏度
* - 创建 Pivot 并挂载 cubies
*/
function initLayerRotation(dx, dy) {
const [ax1, ax2] = perpAxes(clickNormal);
// 正旋转切线方向 = axis × faceNormal
// 物理含义:该方向是「正角速度旋转时,面表面的运动方向」
const tan1 = new THREE.Vector3().crossVectors(ax1, clickNormal);
const tan2 = new THREE.Vector3().crossVectors(ax2, clickNormal);
// 投影到屏幕像素空间
const sTan1 = screenDir(tan1, clickPoint);
const sTan2 = screenDir(tan2, clickPoint);
// 拖拽方向(归一化)
const drag = new THREE.Vector2(dx, dy).normalize();
// 点积比较:选择与拖拽方向更匹配的轴
const dot1 = Math.abs(drag.dot(sTan1.clone().normalize()));
const dot2 = Math.abs(drag.dot(sTan2.clone().normalize()));
const chosen = dot1 >= dot2 ? ax1 : ax2;
rotAx = axName(chosen);
// 动态计算层号:根据被点击 cubie 在旋转轴上的世界坐标
rotLayer = Math.round(clickCubie.position[rotAx] / GAP);
// ── 数值微分法:计算屏幕灵敏度(像素/弧度)──
// 对点击点做微小旋转,观察屏幕位移
const eps = 0.01;
const s0 = toScreen(clickPoint);
const sp = toScreen(clickPoint.clone().applyAxisAngle(chosen, eps));
const deriv = new THREE.Vector2(sp.x - s0.x, sp.y - s0.y).divideScalar(eps);
// deriv 的方向 = 正旋转在屏幕上的运动方向
// deriv 的长度 = 像素/弧度
gDir = deriv.clone().normalize();
gRate = 1 / deriv.length(); // 弧度/像素
// 轴几乎垂直于屏幕(看不到旋转)→ 不触发
if (deriv.length() < 0.5) return;
// ── Pivot 变换机制(关键考点)──
pivot = new THREE.Object3D(); // 临时轴心,位于世界原点
scene.add(pivot);
for (const c of cubies) {
// 基于世界坐标动态筛选层,不依赖固定索引
if (Math.abs(c.position[rotAx] / GAP - rotLayer) < EPS) {
pivot.attach(c);
// attach() 自动计算并保持 cubie 的世界变换
// cubie 成为 pivot 的子节点,后续随 pivot 旋转
}
}
}
/** 释放 Pivot:将方块归还场景 + 坐标清洗 */
function releasePivot() {
if (!pivot) return;
// 将所有子对象挂回场景(自动保持旋转后的世界变换)
const kids = [...pivot.children]; // 复制数组,因为 attach 会修改 children
for (const c of kids) scene.attach(c);
cleanCubies(); // 坐标清洗:消除浮点误差
scene.remove(pivot);
pivot = null;
rotAx = null;
animating = false;
orbit.enabled = true;
}
/* ════════════════════════════════════════════════════════════════
Section 12 · 打乱(Scramble)& 重置(Reset)
════════════════════════════════════════════════════════════════ */
/** 通用层旋转(供 scramble 调用) */
function doRotation(ax, layer, ang, dur) {
return new Promise(resolve => {
const p = new THREE.Object3D();
scene.add(p);
for (const c of cubies)
if (Math.abs(c.position[ax] / GAP - layer) < EPS) p.attach(c);
animating = true;
new Tween({ a: 0 })
.to({ a: ang }, dur)
.easing(easeInOutCubic)
.onUpdate(o => { p.rotation[ax] = o.a; })
.onComplete(() => {
for (const c of [...p.children]) scene.attach(c);
cleanCubies();
scene.remove(p);
animating = false;
resolve();
})
.start();
});
}
async function scrambleCube() {
if (animating || scrambling || interacting) return;
scrambling = true;
orbit.enabled = false;
document.getElementById('btnScramble').disabled = true;
document.getElementById('btnReset').disabled = true;
const AXES = ['x', 'y', 'z'];
const LAYERS = [-1, 0, 1];
let lastA = '', lastL = -99;
for (let i = 0; i < 20; i++) {
let a, l;
// 避免连续旋转同一层(无意义)
do { a = AXES[Math.random() * 3 | 0]; l = LAYERS[Math.random() * 3 | 0]; }
while (a === lastA && l === lastL);
const dir = (Math.random() < 0.5 ? 1 : -1) * HPI;
await doRotation(a, l, dir, Math.max(40, 100 - 3 * i));
lastA = a; lastL = l;
}
scrambling = false;
orbit.enabled = true;
document.getElementById('btnScramble').disabled = false;
document.getElementById('btnReset').disabled = false;
}
function resetCube() {
if (animating || scrambling || interacting) return;
let i = 0;
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const c = cubies[i++];
c.position.set(x * GAP, y * GAP, z * GAP);
c.quaternion.identity();
}
}
document.getElementById('btnScramble').addEventListener('click', scrambleCube);
document.getElementById('btnReset').addEventListener('click', resetCube);
/* ════════════════════════════════════════════════════════════════
Section 13 · 窗口自适应 & 渲染循环
════════════════════════════════════════════════════════════════ */
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
(function loop() {
requestAnimationFrame(loop);
tickTweens();
orbit.update();
renderer.render(scene, camera);
})();
</script>
</body>
</html>
模型的额外输出
image851×1068 77.8 KB

