☰
CSS 3D摇骰子实战:从旋转立方体到Three.js进阶
2026/10/14 4:02:46 网站建设 项目流程

先说明一下:这个标题里的“摇塞子”,按咱们做前端的习惯,其实就是“摇骰子”。我见过不少朋友把这个效果实现成简单的平面旋转,看起来像个风车;也有人做成六个面乱跳的“伪3D”。这篇我打算从零讲透一个真正可用的3D旋转立方体——用CSS 3D变换搭出骰子,再给它加上随机旋转、停靠判定、回弹手感,顺便聊聊为什么最后我建议你换Three.js来做更复杂的玩法。无论你是刚接触CSS 3D想做个小作品,还是想在项目里集成一个骰子决策器,这篇文章都适合你。

我做了个简单Demo:一个边长约200px的骰子,点击按钮后随机旋转若干整圈,最后停在某个面朝上的状态,并返回点数结果。整体只依赖CSS和原生JavaScript,没有任何第三方库。实测在Chrome、Firefox、Safari和主流安卓浏览器上都能跑,60帧流畅。下面我把整个实现过程、踩过的坑、以及怎么扩展成Three.js版本,全部拆开讲清楚。

1. 效果拆解与方案选型

1.1 核心需求解析

“3D旋转立方体效果”听起来很简单,但真做起来有几层需求要区分开:

第一层是“看起来是3D”。很多实现只是让一个正方形在平面上旋转,靠变换缩放模拟透视,这种我不推荐,因为骰子翻面时点数朝向会穿帮。

第二层是“六个面都真实存在”。每个面是独立的DOM元素,在三维空间里有自己的坐标和朝向,旋转整个容器时,六个面随容器一起做3D变换,这是CSS 3D真正能落地的场景。

第三层是“摇骰子”这个交互语义。它不只是旋转展示,而是要有一个随机结果(哪个点数朝上),并且这个结果要可读——要么从DOM状态里算出来,要么动画结束后通过回调返回。

我这里把目标定为:做一个纯前端、无依赖、桌面和移动端都能用的3D骰子组件。它解决的核心问题是“用最轻的方式,在网页里实现足够真实的骰子随机结果”,适合跑在活动页、小游戏、决策工具里。

1.2 技术方案怎么选:CSS 3D还是Canvas/WebGL

我先把市面上常见方案的取舍说清楚,避免你一上来就走弯路。

CSS 3D Transform:本质是把一堆DOM元素放进同一个3D渲染上下文,浏览器用GPU合成。优势是开发效率极高、调试方便(DevTools里直接看坐标),对骰子这种六个面的场景完全够用。劣势是复杂几何体(圆角骰子、带纹理的球体)做不了,也没法做真实的物理碰撞。

Canvas 2D手动画透视:需要自己维护相机、投影矩阵,六个面的深度排序也要手工处理。除非你有特殊需求,否则不推荐。

Three.js:真正的WebGL渲染。能加载3D模型、处理光照阴影、做刚体物理。如果你要做“骰子在碗里翻滚后停下”那种效果,CSS方案基本崩溃,这时候必须上Three.js。

我的建议很直接:只想快速实现、页面里只是点缀,用CSS 3D;要做成游戏核心玩法、需要物理反馈,直接上Three.js。两者不是替代关系,是量级不同。后面我会分别给出实现。

2. 核心原理:CSS 3D变换的数学基础

2.1 transform-style、perspective和rotateX/Y/Z到底怎么配合

很多人做3D效果时把transform-style: preserve-3d和perspective放在同一个元素上,结果发现子元素完全没有3D效果。这里有一个关键细节:perspective必须放在父级(观察者位置),preserve-3d放在3D容器的本身上。更准确地说,perspective是设置在父元素上的,给子元素提供“近大远小”的透视投影;transform-style: preserve-3d让子元素保留自己的三维位置,而不是被扁平化到父元素的平面里。

用语言描述可能有点绕,我写一个结构模板:

<div class="scene"> <!-- 这个元素提供perspective视距 --> <div class="cube"> <!-- 这个元素设置preserve-3d,是旋转主体 --> <div class="face">1</div> <div class="face">2</div> <!-- ... --> </div> </div>

perspective的取值决定了透视强度。我实测的规律是:值越小,变形越夸张,比如perspective: 200px时骰子转起来像鱼眼镜头拍的;值越大,越接近正交投影,比如perspective: 1000px以上时旋转几乎看不出纵深变化。建议骰子边长200px时,视距取600~800px,最接近人眼看实体骰子的感觉。

再说rotateX/rotateY/rotateZ的配合。这三个函数不是简单的“三个角度相加”,它们的内部是旋转矩阵的乘积。在CSS里,变换是从右往左应用的,比如:

transform: rotateX(90deg) rotateY(45deg);

实际上会先做rotateY(45deg),再做rotateX(90deg)。这个顺序差异会导致非常反直觉的结果——我调试时经常因为搞反顺序,骰子转到一半突然从一个奇怪的角度弹回来。所以最好记住:先转的面放在左边,后叠加的旋转放在右边。

2.2 立方体六个面的坐标怎么搭建

以立方体边长200px为例,中心点建立在坐标原点。六个面都是200px见方的div,默认都朝向屏幕(Z轴正方向)。要让它们组成立方体,需要分别位移和旋转,把每个面摆到对应位置:

  • 前面:rotateY(0deg) translateZ(100px)
  • 后面:rotateY(180deg) translateZ(100px)
  • 左面:rotateY(-90deg) translateZ(100px)
  • 右面:rotateY(90deg) translateZ(100px)
  • 上面:rotateX(90deg) translateZ(100px)
  • 下面:rotateX(-90deg) translateZ(100px)

这里的100px是边长的一半,因为页面元素默认在自身平面内,translateZ的位移量是从中心到面的距离。

注意一个顺序陷阱:translateZ(100px)必须在rotateY(180deg)的后面。如果写成translateZ(100px) rotateY(180deg),那面会先往前移动100px,再以已经移动后的位置为轴旋转,结果完全错位。CSS合成变换时,位移和旋转的顺序不是你眼睛看到的顺序,而是矩阵运算从右到左的顺序,这条规则我翻车了两次才彻底记住。

为了让这个结构更容易理解,可以想象你站在原点:往前一步是前面,转身180度再往前一步是后面,以此类推。每个面最终都贴在立方体的表面。

2.3 骰子点数布局:标准六面的正确画法

如果你只是做一个“看起来像骰子”的效果,点数随便摆没人在意;但如果你要真的读结果,那点数布局必须符合骰子的标准规则——这也是很多手写实现里最容易被忽略的细节。

标准骰子规则有两条:

  1. 对面之和等于7:1对面是6,2对面是5,3对面是4。
  2. 点数在面上的排列是固定的,不是随便放几个点就行。

以“1、2、3点共用顶点,按逆时针排列”为标准(实际市售骰子常见布局),常见的一种映射是:

  • 上面1、底面6、前面2、后面5、左面3、右面4

对应到CSS,我建议这样分配面朝向:

.face-front { transform: rotateY(0deg) translateZ(100px); /* 点数2 */ } .face-back { transform: rotateY(180deg) translateZ(100px); /* 点数5 */ } .face-left { transform: rotateY(-90deg) translateZ(100px); /* 点数3 */ } .face-right { transform: rotateY(90deg) translateZ(100px); /* 点数4 */ } .face-top { transform: rotateX(90deg) translateZ(100px); /* 点数1 */ } .face-bottom { transform: rotateX(-90deg) translateZ(100px); /* 点数6 */ }

点数“1”用一个小圆点在中心;“6”用三行两列六个圆点;“5”是四个角加中心;这些都可以用flex布局和伪元素来实现,不一定要塞图片。

3. 完整实现:从零搭建一个可交互的3D骰子

3.1 HTML结构与基础样式

先写干净的结构,把样式和逻辑分开:

<div class="scene"> <div class="cube" id="dice"> <div class="face face-front"></div> <div class="face face-back"></div> <div class="face face-left"></div> <div class="face face-right"></div> <div class="face face-top"></div> <div class="face face-bottom"></div> </div> </div> <button id="rollBtn">摇骰子</button> <p id="result">结果:-</p>

基础CSS:

.scene { width: 220px; height: 220px; perspective: 800px; /* 视距 */ margin: 60px auto; } .cube { width: 200px; height: 200px; transform-style: preserve-3d; position: relative; transition: transform 0.1s; /* 先预留给动画用 */ } .face { position: absolute; width: 200px; height: 200px; border: 2px solid #333; background: linear-gradient(135deg, #fafafa, #e0e0e0); border-radius: 12px; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; }

记得给每个面设置backface-visibility: hidden,否则旋转时透过背面能看到对面的内容,出现“穿帮”。这个属性是一个关键优化,也是很多人初次做3D效果时遇到的“面重叠”问题根源。

3.2 用伪元素画点数:Flex布局技巧

骰子点数的本质是在格子里放圆点。我用的方法是:把每个面内部当成一个三行三列的网格(用display: grid或flex-wrap),圆点用伪元素.pip表示。

以“6点”为例:

.face-six { display: grid; grid-template-columns: repeat(2, 60px); grid-template-rows: repeat(3, 60px); justify-content: center; align-content: center; } .face-six .pip { width: 24px; height: 24px; border-radius: 50%; background: #222; margin: auto; }

对于2、3这种对角点数的面,用nth-child控制位置。我封装了一个通用的pip样式,通过每个面的nth-child选择器来确定圆点位置:

.face-two .pip:nth-child(1) { grid-column: 1; grid-row: 1; } .face-two .pip:nth-child(2) { grid-column: 3; grid-row: 3; }

实际开发中,我不想给每个面写一套grid,所以干脆用了更粗暴的方式:每个面内部分为9个格子(三行三列),根据点数往格子里填圆点。这样代码更直观,语义也更清楚,虽然DOM数量稍微多一点,对性能无影响。

注意:骰子点数不能乱写“2点在中间”,一定要按标准布局。尤其是做小游戏要读结果时,位置决定玩家判断方向,标准布局是体验的基本盘。

3.3 摇骰子动画:随机旋转与结果读取

这是整个效果的核心。骰子要像真实摇动那样:旋转多圈、随机方向、最后停在某个面上。CSS实现的关键是:最终旋转结果 = 目标面朝上所需的角度 + 随机超出的整圈数。

我需要定义六个面分别朝上时,立方体在X、Y方向上的总旋转角。以上面1点为例,初始状态的cube旋转角度为(0, 0, 0),此时top面朝上。如果想要3点朝上——按照前面定义,3点在left面,就需要把立方体绕Y轴旋转-90deg(即rotateY(-90deg)),让原来的左侧面转上来。

搞明白思路后,最终旋转角可以用一个数组记录:

const faceAngles = { 1: { x: 0, y: 0 }, 2: { x: 0, y: 180 }, // front面朝上时,需要绕Y轴转180度 3: { x: 0, y: -90 }, // left面朝上 4: { x: 0, y: 90 }, // right面朝上 5: { x: -90, y: 0 }, // back面朝上 6: { x: 90, y: 0 }, // bottom面朝上 };

然后随机生成一个目标值 + 多圈旋转:

function rollDice() { const target = Math.floor(Math.random() * 6) + 1; const { x, y } = faceAngles[target]; const randomX = x + 360 * (Math.floor(Math.random() * 4) + 3); const randomY = y + 360 * (Math.floor(Math.random() * 4) + 3); cube.style.transform = `rotateX(${randomX}deg) rotateY(${randomY}deg)`; // 在transitionend事件里读取结果 }

这里我故意在随机旋转里加上了目标角度本身,以x/y作为基础偏移,再叠加多圈。这样无论随机转多少圈,最终停下的朝向一定是目标面对准观察者。

看似没问题,但实际有一个Bug:CSS动画的终点是字符串里的绝对角度。如果上一次停在rotateX(1080deg),下一次目标角度在字符串拼接时绕回了rotateX(90deg),浏览器会从1080deg直接转到90deg——也就是会倒着转回去,效果非常奇怪。

解决方法是做角度累加,而不是直接覆盖。我维护一个currentRotation变量,每次在目标角度上加上这一轮的增量,并记录绝对值:

let currentX = 0, currentY = 0; function rollDice() { const target = Math.floor(Math.random() * 6) + 1; const { x, y } = faceAngles[target]; // 加上随机整数圈(至少3圈,至多6圈) const addX = 360 * (Math.floor(Math.random() * 4) + 3); const addY = 360 * (Math.floor(Math.random() * 4) + 3); // 关键:把目标面的基础角度加上去,保证方向一致 currentX += x + addX; currentY += y + addY; cube.style.transform = `rotateX(${currentX}deg) rotateY(${currentY}deg)`; }

这样的效果是骰子每次往多圈方向继续转,而不是来回折返。加上transition的cubic-bezier缓动,转动看起来就像被手甩出去后逐渐停稳。

3.4 过渡动画的物理手感:缓动曲线调优

仅用默认的ease会让动画开始和结束都很“懒”,骰子不像被摇过的。我试过几组缓动曲线后,推荐用带回弹的:

.cube { transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); }

这组曲线的特征是:快速启动、中段减速、缓慢停在目标位,和骰子落桌后滑动停下的心理预期比较接近。如果你想要一点“撞到骰盅壁”的弹跳感,可以试着用cubic-bezier(0.25, 0.1, 0.25, 1.4),但注意回弹比例过大时骰子看起来会像果冻,很假。

时长也是有讲究的。0.3s太突兀,1s以上太拖沓。我实测0.6~0.8秒是常见的“摇骰子”节奏,刚好够玩家看清旋转过程又不觉得无聊。配合随机旋转圈数(3~6圈),整个动画在高速旋转和减速之间能找到比较舒服的平衡点。

实操现场记录一下:我调试时把transition-duration一路从0.3调到1.2,发现最影响手感的是最后30%的减速段。骰子必须在接近终点时有一个明显的滞涩感,这需要通过缓动曲线的末尾控制来实现。

4. 进阶:用Three.js做更真实的摇骰子

4.1 CSS方案解决不了的问题

CSS方案最难受的三个场景,我实际碰到过:

第一个是骰子形状。真正市售的骰子,每个角是切面的,不是正方体。CSS能通过clip-path做出切割效果,但每个面要重新画贴图和阴影,工作量大,而且旋转时GPU合成偶尔会闪。

第二个是阴影和光照。CSS 3D里没有“光”的概念,所有面都是平涂颜色。骰子在半空中旋转时,不同面的亮度没有变化,缺乏立体感。虽然可以用多个半透明遮罩面模拟AO阴影,但会显著增加DOM数量。

第三个是物理交互。想让骰子落地后弹跳、在碗里碰撞、最后滚进角落,CSS完全做不到——因为每次旋转都是预设的关键帧,不是实时的物理模拟。

如果你只是写在某个页面角落的小组件,上面三条都不成立,CSS方案足够。但如果是游戏场景,建议直接切Three.js。

4.2 Three.js版核心思路与代码骨架

Three.js实现的核心是:创建一个BoxGeometry的Mesh,给每个面赋予点数贴图(或者用Canvas动态生成贴图),再用Cannon.js或者简单的阻尼方程驱动旋转。

我一般用动态Canvas生成点数贴图,这样不用准备外部图片资源。核心代码骨架:

import * as THREE from 'three'; function createPipTexture(dots = 1) { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 256; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 256, 256); ctx.fillStyle = '#222'; const positions = getPipPositions(dots); // 根据点数返回圆点坐标 positions.forEach(([x, y]) => { ctx.beginPath(); ctx.arc(x, y, 20, 0, Math.PI * 2); ctx.fill(); }); return new THREE.CanvasTexture(canvas); } const materials = [1, 2, 3, 4, 5, 6].map(dots => new THREE.MeshStandardMaterial({ map: createPipTexture(dots), roughness: 0.4, metalness: 0.1, }) ); const dice = new THREE.Mesh( new THREE.BoxGeometry(2, 2, 2), materials ); dice.position.y = 5; dice.rotation.set(0.2, 0.5, 0);

相比CSS方案,Three.js的优势是灯光一加,骰子六面的立体感立刻出来了。我在场景里放了一个点光源和一个平行光,骰子在高光处有明显层次,旋转到暗面时阴影自然过渡。

对于“骰子在碗里翻滚”这类效果,你还需要引入物理引擎(比如Cannon.js或Ammo.js)。我之前在某跨平台系统项目里做过一版:给碗底创建了一个静态刚体,给骰子加球体碰撞体,初始给一个随机角速度,然后让物理引擎接管。骰子从上方掉落、碰撞、反弹、滚动,最终稳定的状态就是随机结果。这个方案明显比纯预设动画更像“真摇骰子”,但工程量也上了一个台阶。

4.3 随机结果的可靠读取

在CSS方案里,结果从faceAngles映射表算出;在Three.js物理方案里,结果要从骰子当前旋转矩阵推算。

我常用的一种简单可靠的做法是:在物理模拟结束后,取骰子Mesh的quaternion转换出的欧拉角,找出Y轴方向上最接近垂直的面法线。具体逻辑是:

  1. 事先记录每个面的局部法线向量(比如上面的局部法线是{x:0, y:1, z:0})。
  2. 用骰子的当前四元数旋转这些法线,得到世界空间法线。
  3. 比较哪个法线的y分量最大,那个面就朝上。

这个方案不管你用CSS手动旋转还是物理引擎模拟,逻辑都通用。反而是在CSS动画场景里,由于变换矩阵是显式字符串,直接读取映射表更快。

5. 实际开发中的常见问题与性能优化

5.1 骰子闪烁、面穿帮、层级错乱:三大经典问题

做CSS 3D效果,几乎绕不开这三个问题,我一个个说排查思路。

闪烁(face flickering):表现是旋转过程中面的边缘出现高亮闪线或瞬间消失又出现。90%的根因是backface-visibility没有设置,或者子元素有半透明背景导致浏览器合成错误。处理办法是给面统一加:

.face { backface-visibility: hidden; transform-style: preserve-3d; }

面穿帮:当你看到骰子的某个面“透明”了,能看到后面的另一个面,通常是Z轴位移方向和旋转顺序出了问题。排查时用一个临时样式,只保留当前面、去掉其他面的背景,逐个检查哪个面位置不对。

层级错乱:在父容器之外,Cube被其他元素遮挡时,可能出现“明明是前面的面却被后面的元素盖住”的错觉。这是因为CSS 3D的深度排序是根据3D上下文,一旦你给父元素加了overflow: hidden,3D变换会被剪裁或平面化。注意:不要在.cube或.scene上设置overflow: hidden,否则preserve-3d很可能失效。

5.2 性能优化:动画卡顿排查

我实测过,纯CSS的六个面旋转在主流手机上没什么压力,但如果你后续加了光影遮罩、每面内部又有大量子元素,就可能出现掉帧。优先检查两点:

第一,尽量避免在动画过程中触发Layout。骰子的六个面及其子元素,在动画全程都不要改变width、height、top、left。任何几何变化都会导致浏览器重新Layout,而3D效果每帧都在合成,Layout会直接让帧率崩掉。

第二,只对.cube做 transform,不要对六个面分别做独立 transform。六个面作为cube的子元素,只需要在初始化时设置一次坐标,后面跟着父容器一起转。如果对每个face做动画,浏览器的合成层数量会暴增。

移动端特别要注意:滚屏时骰子经常跑到视口外,浏览器会销毁它的合成层,回来时重新创建,这个过程可能产生短暂黑屏。可以给scene加一个空translateZ(0),强制提升为合成层,减少重建频率。

5.3 兼容性与边界Case清单

Safari对CSS 3D的支持一直有些小毛病,比如旧版Safari在不设置-webkit-transform-style: preserve-3d时会退化为平面。我建议在CSS里同时写上标准和前缀,而且给cube加上will-change: transform,帮助浏览器提前分配合成层。

另一个常见坑是perspective的单位:有的浏览器对perspective: 800这种无单位写法解析不一致,必须写perspective: 800px。

关于transition与随机目标值的配合,还有个小细节:连续点击摇骰子时,上一次动画还没有结束,下一次点击会覆盖transform值,导致骰子中途急转弯。解决方案是记录一个isRolling状态,动画运行中忽略新点击:

let isRolling = false; function rollDice() { if (isRolling) return; isRolling = true; // 触发动画... } cube.addEventListener('transitionend', () => { isRolling = false; // 读取结果并回显 });

这样既避免了动画中断的视觉Bug,也保证了随机结果和最终显示一致。

6. 扩展思路:从骰子到通用3D交互

完成骰子效果后,你会发现CSS 3D的基本功已经打通了。立方体、旋转视角、随机目标、动画过渡,这四个能力组合起来,可以做出不少有意思的扩展。

比如你可以把骰子换成带图标的方块,做成“抽签机”——随机转几圈后显示某个图标,用于抽奖或者决策;也可以改成带数字的色块,变成随机数生成器;甚至可以多个骰子排列,做一个简单的“德式桌游”开局掷骰界面。

我在做某图像处理Demo时,还试过把骰子与canvas结合:骰子停止后获取朝向结果,再用结果触发一张图片的滤镜切换。这种“3D选择器”的交互模式在H5活动页里蛮好用——比按钮点击更有仪式感,也更符合游戏化交互的调性。

Three.js方向的扩展就更大了。如果你想实现“掷骰子进碗里”,需要加上物理引擎和碰撞体;想做成多人同屏,要引入状态同步。这些超出本文范围了,但核心入口就是第4.2节那段代码。

最后再分享一个我在实际使用中踩过的坑:做骰子结果读取时,最好在动画结束前不要提前读值。CSStransitionend事件在不同浏览器上的触发时机会有几十毫秒偏差,如果你依赖它做下一步逻辑,最好再包一层setTimeout兜底,或者直接读变换矩阵。我的做法是:在transition结束后,先读取一个“准备就绪”的标记,然后通过requestAnimationFrame拿最终状态,这样最稳。

这个3D旋转立方体的玩法,到这里已经可以做得很完整了。从纯CSS的轻量实现,到Three.js的物理模拟,每一步都有它的适用场景。建议你先从CSS版本入手,把六个面的坐标、角度映射、缓动手感调明白,再决定是否上WebGL——先把基础吃透,换引擎只是时间问题,而不是能力问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询