如何让旋转的CSS 3D立方体平滑停在朝向镜头的面?
问题描述
我使用CSS、JavaScript/Angular实现了一个小型3D立方体,通过transform: rotateX方法实现单轴旋转。立方体支持点击后随鼠标方向上下旋转,此功能运行良好,旋转由扭矩控制与旋转值驱动。但松开鼠标后,我希望它能平滑停在朝向镜头的面上,目前该效果不稳定,有时能平滑停止,有时会跳转到立方体的下一个面。当前我采用硬编码方式检测朝向镜头的面,这可能是问题根源,请问基于现有代码,是否有更优的停止效果实现方法?
代码实现
HTML
<div class="dice" (mousedown)="mouseDown($event)" (dblclick)="doubleClick()" > <div class="cube" [ngStyle]="{'transform': 'rotateX(' + rotation + 'deg)'}"> <div #pane class="face front">4</div> <div #pane class="face bottom">3</div> <div #pane class="face back">2</div> <div #pane class="face top">1</div> </div>
JavaScript
import { Component, OnInit, HostListener, ElementRef, ViewChildren, QueryList } from '@angular/core'; @Component({ selector: 'roller', templateUrl: './roller.component.html', styleUrls: ['./roller.component.scss'] }) export class RollerComponent implements OnInit { //@ts-ignore @ViewChildren("pane", { static: false }) diePanels: QueryList<ElementRef>; controlDown: boolean = false; rotationSpeed: number = 2; friction: number = 0.1; frictionCutoff: number = 0.93; mouseY: number = 0; lastY: number = 0; delta: number = 0; torque: number = 0; rotation: number = 0; lastSide: number = 0; currentSide: number = 0; targetMovement: number = 0; torqueTarget: number = 0; torqueMaxSpeed: number = 8; stopAt: number = 0; // 存储所有骰子面元素 panels: ElementRef[] = []; constructor() { } ngOnInit(): void { // 生命周期占位 } ngAfterViewInit() { this.panels = this.diePanels.toArray(); } mouseDown(e: any) { this.controlDown = true; this.mouseY = e.pageY; this.lastY = this.mouseY; } @HostListener('document:mouseup', ['$event']) mouseUp() { this.controlDown = false; } @HostListener('document:mousemove', ['$event']) onMouseMove(e) { if (this.controlDown) { this.mouseY = e.pageY; } } doubleClick() { console.log(this.controlDown); } animate(timeDelta: number) { this.delta = this.mouseY - this.lastY; // 角度钳制到0-360° if(this.rotation > 360) { this.rotation = 0; } else if(this.rotation < 0) { this.rotation = 360; } // 鼠标按下时控制旋转 if (this.controlDown) { this.torque = this.torque * this.frictionCutoff + (this.delta * this.rotationSpeed - this.torque) * this.friction; // 限制扭矩最大值 if (this.torque >= this.torqueMaxSpeed) { this.torque = this.torqueMaxSpeed; } else if (this.torque <= -this.torqueMaxSpeed) { this.torque = -this.torqueMaxSpeed; } this.rotation -= this.torque; } else { // 松开鼠标后停止逻辑 this.delta = 0; this.lastY = 0; this.torque = 0; let rot = Math.floor(this.rotation); // 硬编码判断当前朝向面 if (rot > 0 && rot < 90) { this.torqueTarget = 90; this.currentSide = 0; } else if (rot > 90 && rot < 180) { this.torqueTarget = 180; this.currentSide = 1; } else if (rot > 180 && rot < 270) { this.torqueTarget = 270; this.currentSide = 2; } else if (rot > 270 && rot < 360) { this.torqueTarget = 360; this.currentSide = 3; } this.rotation = this.lerp(this.rotation, this.torqueTarget, 0.5); } } lerp(start: number, end: number, time: number) { return (1-time)*start+time*end; } }
SCSS
$diceSize: 55px; .dice { width: $diceSize; height: $diceSize; margin: 335px auto 0; cursor: pointer; box-shadow: 4px 5px 6px -1px rgba(0,0,0,0.94); -webkit-box-shadow: 4px 5px 6px -1px rgba(0,0,0,0.94); -moz-box-shadow: 4px 5px 6px -1px rgba(0,0,0,0.94); } .cube { transform-style: preserve-3d; width: 100%; height: 100%; position: relative; animation-fill-mode: backwards; } .face { display: flex; position: absolute; font-size: 2.5rem; align-items: center; justify-content: center; width: 100%; height: 100%; background: rgb(255, 255, 255); background-image: url('./../../../assets/DiceBackground.png'); background-size: auto 100%; border: rgb(68, 68, 68) inset 1px; pointer-events: none; user-select: none; } .top { transform: rotateX(90deg) translateZ(calc($diceSize / 2)); } .bottom { transform: rotateX(-90deg) translateZ(calc($diceSize / 2)); } .front { transform: rotateX(0deg) translateZ(calc($diceSize / 2)); } .back { transform: rotateX(-180deg) translateZ(calc($diceSize / 2)); }
优化方案
1. 替换硬编码区间,计算最近目标角度
硬编码的区间边界判断是跳变的核心问题,用数学计算直接获取最近的90°倍数目标值:
// 替换原硬编码的区间判断逻辑 const normalizedRot = ((this.rotation % 360) + 360) % 360; // 确保角度在0-360°范围内 const nearest90 = Math.round(normalizedRot / 90) * 90; this.torqueTarget = nearest90 === 0 ? 360 : nearest90; // 与原逻辑保持一致,用360替代0
该逻辑能精准匹配最近的正对角度,彻底避免边界跳变问题。
2. 保留扭矩衰减的自然减速
原代码松开鼠标后直接将torque置0,导致旋转突然失速。改为让扭矩逐渐衰减,配合lerp实现更自然的停止:
} else { // 扭矩逐渐衰减,而非直接置0 this.torque *= 0.9; if (Math.abs(this.torque) < 0.1) { this.torque = 0; } this.rotation -= this.torque; // 计算最近的目标角度 const normalizedRot = ((this.rotation % 360) + 360) % 360; const nearest90 = Math.round(normalizedRot / 90) * 90; this.torqueTarget = nearest90 === 0 ? 360 : nearest90; // 仅当扭矩几乎为0时,才开始平滑吸附到目标角度,避免运动冲突 if (Math.abs(this.torque) < 0.1) { this.rotation = this.lerp(this.rotation, this.torqueTarget, 0.1); // 调小lerp系数,提升平滑度 } }
先让立方体靠剩余扭矩自然减速,接近停止时再平滑吸附到目标角度,效果更符合物理直觉。
3. 修复角度归一化逻辑
原代码的角度钳制会导致跳变,用取模运算实现无缝循环:
// 替换原角度钳制逻辑 this.rotation = ((this.rotation % 360) + 360) % 360;
确保角度始终在0-360°之间循环,不会出现突然跳变的情况。
4. 调整Lerp系数
原代码用0.5作为lerp参数,吸附过快显得生硬,调至0.1-0.2之间,可获得更平缓的过渡效果。
内容的提问来源于stack exchange,提问作者Merlin
相关产品推荐
相关产品推荐

