You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让旋转的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 14:27:13