基于Three.js的3D抽奖组件开发:从原理到实战

📅 2026/8/4 19:01:59
基于Three.js的3D抽奖组件开发:从原理到实战
最近在开发一个线上活动页面时需要实现一个能吸引用户眼球、提升参与感的抽奖环节。传统的静态抽奖列表或者简单的转盘动画总觉得互动性和视觉冲击力不够。于是我决定挑战一下用前端技术打造一个酷炫的3D抽奖效果。这个3D抽奖的核心思路是模拟一个旋转的“奖品球”或“立方体”用户点击后奖品会以3D旋转的方式随机定格最终展示中奖结果。整个过程不仅好玩视觉效果也拉满。本文将完整分享从零到一实现这个3D抽奖组件的全过程涵盖Three.js基础、3D场景搭建、交互逻辑、动画控制以及性能优化。无论你是想为个人项目增添趣味还是为商业活动开发吸睛组件都能从本文中找到可复用的代码和思路。1. 背景与核心概念在Web前端领域实现3D效果主要有两种主流方式CSS 3D Transform和WebGL。CSS 3D Transform通过transform: rotate3d(),translate3d(),perspective等属性可以在浏览器中创建简单的3D变换。它上手简单性能在简单场景下也不错非常适合制作卡片翻转、3D画廊等效果。但其能力有限难以构建复杂的3D模型和精细的光照、材质效果。WebGL是一种底层JavaScript API用于在HTML Canvas中渲染高性能的2D和3D图形。它功能强大能够创建极其复杂和逼真的3D场景是游戏和高级数据可视化的首选。但WebGL API本身较为复杂直接使用门槛很高。为了平衡开发效率与效果我们通常会使用基于WebGL封装的图形库。Three.js正是其中最流行的一个。它封装了WebGL的复杂细节提供了场景(Scene)、相机(Camera)、渲染器(Renderer)、灯光(Light)、几何体(Geometry)、材质(Material)等高层抽象概念让开发者能够以更直观的方式创建3D内容。我们的“动态3D抽奖”项目本质上是在一个3D场景中放置多个代表奖品的3D对象如立方体、球体让它们围绕一个中心轴旋转。用户点击“抽奖”按钮时通过数学计算控制旋转动画最终随机停止在某个奖品上。我们将使用Three.js来实现这个核心的3D效果。2. 环境准备与版本说明本项目是一个纯前端项目不需要后端服务。我们将使用现代前端开发中常见的工具链来管理依赖和构建。开发环境操作系统Windows 10/11, macOS, 或 Linux (本文演示环境为 macOS)Node.js版本 16.x 或更高 (用于包管理)包管理器npm 或 yarn (本文使用 npm)代码编辑器VS Code (推荐)核心依赖库及版本Three.js版本r158(这是一个长期支持版本API稳定。Three.js版本迭代较快建议锁定一个稳定版本以避免兼容性问题。)Vite版本5.x(一个现代化的前端构建工具启动快热更新友好比Webpack配置更简单)项目初始化我们使用 Vite 快速创建一个纯净的 Vanilla JS (原生JavaScript) 项目。打开终端执行以下命令创建项目npm create vitelatest 3d-lottery -- --template vanilla cd 3d-lottery这会在当前目录下创建一个名为3d-lottery的文件夹并初始化一个原生JS项目。进入项目目录安装 Three.jsnpm install three0.158.0安装开发依赖Vite已自带此步可验证并启动开发服务器npm install npm run dev执行npm run dev后终端会输出一个本地服务器地址通常是http://localhost:5173在浏览器中打开它你会看到一个简单的“Hello Vite!”页面。项目结构预览创建完成后你的项目结构大致如下。我们需要修改的主要是index.html和main.js。3d-lottery/ ├── node_modules/ # 项目依赖包 ├── public/ # 静态资源 ├── index.html # 主HTML文件 ├── main.js # 主JavaScript文件 (我们将在这里编写3D代码) ├── style.css # 样式文件 ├── package.json # 项目配置和依赖声明 └── vite.config.js # Vite配置文件 (通常无需修改)3. 核心原理与 Three.js 基础拆解在动手编码前理解 Three.js 的几个核心对象至关重要。你可以把它们想象成拍电影所需的部门。场景 (Scene)相当于整个舞台或世界。所有要显示的3D物体物体、灯光都需要添加到场景中。相机 (Camera)相当于观众的眼睛或摄影机。它决定了我们从哪个角度、以何种方式观看场景。最常用的是透视相机 (PerspectiveCamera)它模拟人眼的视觉效果物体近大远小。渲染器 (Renderer)相当于电影拍摄和放映团队。它负责将场景和相机所构成的“视图”绘制到HTML页面的一个容器通常是canvas画布中。物体 (Mesh)舞台上的演员或道具。一个物体由两部分组成几何体 (Geometry)定义物体的形状例如立方体、球体、圆柱体的顶点数据。材质 (Material)定义物体的外观例如颜色、纹理、光滑度、是否透明。灯光 (Light)舞台上的灯光师。没有光场景就是一片漆黑。常用的有环境光均匀照亮所有物体、平行光模拟太阳光、点光源模拟灯泡。3D抽奖的动画原理我们将创建多个奖品物体将它们均匀排列在一个圆周上。通过不断修改这些物体或者整个场景的旋转角度并请求渲染器重绘就能产生旋转动画。requestAnimationFrameAPI 是实现流畅动画的关键它会告诉浏览器在下次重绘前执行我们的更新函数。交互逻辑开始旋转用户点击按钮启动一个动画循环持续增加旋转角度。停止与判定经过一段随机或固定的时间后开始减速。减速停止后根据最终旋转角度计算出正对相机或某个特定方向的奖品索引即为中奖奖品。4. 完整实战构建3D抽奖场景接下来我们一步步实现完整的抽奖功能。请打开main.js文件清空原有内容跟随以下步骤编写代码。4.1 初始化 Three.js 基础环境首先引入 Three.js并创建场景、相机、渲染器这三个核心对象。// main.js import * as THREE from three; // 1. 创建场景 const scene new THREE.Scene(); scene.background new THREE.Color(0x000000); // 设置场景背景为黑色 // 2. 创建透视相机 // 参数视野角度(FOV)宽高比近裁剪面远裁剪面 const camera new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.z 15; // 将相机沿Z轴向后移动以便能看到场景中的物体 // 3. 创建WebGL渲染器 const renderer new THREE.WebGLRenderer({ antialias: true }); // antialias开启抗锯齿画面更平滑 renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染器输出尺寸为窗口大小 // 将渲染器生成的canvas元素添加到body中 document.body.appendChild(renderer.domElement); // 4. 添加光源 - 没有光物体是黑色的 // 环境光提供均匀的基础照明 const ambientLight new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); // 平行光提供方向性的强光产生明暗对比 const directionalLight new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(10, 10, 5); scene.add(directionalLight);4.2 创建奖品物体并环形排列我们假设有8个奖品。用立方体(BoxGeometry)来代表每个奖品并为它们设置不同的颜色和位置。// 奖品配置数据 const prizes [ { name: 一等奖, color: 0xff0000 }, // 红色 { name: 二等奖, color: 0x00ff00 }, // 绿色 { name: 三等奖, color: 0x0000ff }, // 蓝色 { name: 谢谢参与, color: 0xffff00 }, // 黄色 { name: 优惠券, color: 0xff00ff }, // 粉色 { name: 积分, color: 0x00ffff }, // 青色 { name: 纪念品, color: 0xffa500 }, // 橙色 { name: 再来一次, color: 0x800080 }, // 紫色 ]; const prizeMeshes []; // 用于存放所有奖品物体的数组 const radius 8; // 奖品排列的圆形半径 const totalPrizes prizes.length; // 创建并排列奖品 prizes.forEach((prize, index) { // 1. 创建几何体立方体宽高深分别为1.5 const geometry new THREE.BoxGeometry(1.5, 1.5, 1.5); // 2. 创建材质基础网格材质使用配置的颜色 const material new THREE.MeshLambertMaterial({ color: prize.color }); // 3. 创建物体网格 const cube new THREE.Mesh(geometry, material); // 4. 计算每个奖品在圆环上的位置 (x, y, z) // 将整个圆环2π平均分给每个奖品计算当前奖品的角度 const angle (index / totalPrizes) * Math.PI * 2; cube.position.x radius * Math.cos(angle); // X坐标 半径 * cos(角度) cube.position.z radius * Math.sin(angle); // Z坐标 半径 * sin(角度) // Y坐标可以稍微随机让布局更有层次感 cube.position.y (Math.random() - 0.5) * 2; // 5. 让奖品稍微朝向圆心相机方向看起来更自然 cube.lookAt(new THREE.Vector3(0, cube.position.y, 0)); // 6. 将物体添加到场景和数组中 scene.add(cube); prizeMeshes.push(cube); // 7. 可选为每个奖品添加文字标签需要引入额外的字体和TextGeometry此处为简化版思路 // 可以使用CSS2DRenderer或CSS3DRenderer或预渲染成纹理(Sprite)。 });4.3 实现旋转动画与控制逻辑这是最核心的部分。我们需要一个动画循环并控制旋转的开始、减速和停止。// 动画控制变量 let animationId null; // 用于存储动画帧ID便于取消动画 let rotateSpeed 0; // 当前旋转速度弧度/帧 let isRotating false; // 是否正在旋转 let targetRotateSpeed 0.05; // 目标旋转速度匀速旋转时的速度 let deceleration 0.001; // 减速时的减速度 let finalPrizeIndex -1; // 最终中奖奖品的索引 // 创建一个组(Group)将所有奖品添加进去然后旋转这个组比旋转每个物体更高效 const prizeGroup new THREE.Group(); prizeMeshes.forEach(mesh prizeGroup.add(mesh.clone())); // 注意这里需要将场景中的物体移除并添加到组为简化我们直接新建组并添加。 scene.add(prizeGroup); // 清空原场景中的单个物体避免重复实际开发中应在创建时就加入组 scene.children scene.children.filter(obj !prizeMeshes.includes(obj)); // 将原数组指向组内的物体 prizeMeshes.length 0; prizeGroup.children.forEach(child prizeMeshes.push(child)); // 动画循环函数 function animate() { animationId requestAnimationFrame(animate); // 请求下一帧动画 if (isRotating) { // 如果正在旋转就更新旋转速度 if (rotateSpeed targetRotateSpeed) { // 加速过程按下按钮后 rotateSpeed 0.001; } // 匀速旋转由 rotateSpeed 维持 prizeGroup.rotation.y rotateSpeed; // 绕Y轴旋转 } // 渲染场景 renderer.render(scene, camera); } // 启动动画循环 animate(); // 开始抽奖函数 function startLottery() { if (isRotating) return; // 防止重复点击 console.log(开始旋转); isRotating true; rotateSpeed 0; // 每次开始都从静止加速 finalPrizeIndex -1; // 模拟旋转3秒后开始减速并随机决定中奖者 setTimeout(() { console.log(开始减速...); // 减速逻辑每帧减少速度直到停止 const slowDown () { rotateSpeed - deceleration; if (rotateSpeed 0) { rotateSpeed 0; isRotating false; // 旋转停止计算中奖奖品 calculateWinner(); return; // 停止减速循环 } // 继续减速 requestAnimationFrame(slowDown); }; slowDown(); }, 3000); // 3秒后开始减速 } // 计算中奖者 function calculateWinner() { // 获取奖品组的当前旋转角度弧度 let currentAngle prizeGroup.rotation.y; // 将角度规范化到 [0, 2π) 区间 currentAngle currentAngle % (Math.PI * 2); if (currentAngle 0) currentAngle Math.PI * 2; // 计算哪个奖品正对“前方”例如Z轴负方向或相机方向 // 我们假设奖品初始位置时索引为0的奖品在 (radius, 0, 0) 位置即正右方。 // 当组旋转后需要找到现在转到“正面”比如Z轴负方向即角度为 -π/2的奖品。 // 简化计算找到与“正面”角度差最小的奖品。 const frontAngle -Math.PI / 2; // 定义“正面”的角度可以根据相机位置调整 let minDiff Infinity; let winnerIndex 0; prizeMeshes.forEach((mesh, index) { // 计算该奖品的原始角度在圆环上的位置 const originalAngle (index / totalPrizes) * Math.PI * 2; // 计算该奖品当前的世界角度原始角度 组的旋转角度 let worldAngle originalAngle currentAngle; worldAngle worldAngle % (Math.PI * 2); // 计算与正面的角度差取绝对值最小的 let diff Math.abs(worldAngle - frontAngle); // 处理角度差超过π的情况取更小的那个弧 if (diff Math.PI) diff Math.PI * 2 - diff; if (diff minDiff) { minDiff diff; winnerIndex index; } }); finalPrizeIndex winnerIndex; console.log(抽奖停止中奖者是${prizes[winnerIndex].name}); alert(恭喜你抽中了【${prizes[winnerIndex].name}】); } // 绑定开始按钮事件需要在HTML中添加按钮 document.getElementById(startBtn).addEventListener(click, startLottery);4.4 完善HTML与CSS界面修改index.html和style.css添加控制按钮和基本样式。!-- index.html -- !doctype html html langen head meta charsetUTF-8 / link relicon typeimage/svgxml href/vite.svg / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title3D 动态抽奖/title link relstylesheet href./style.css /head body !-- 添加一个开始抽奖的按钮 -- button idstartBtn开始抽奖/button !-- 添加一个显示中奖结果的区域 -- div idresultPanel classresult-panel h2中奖结果/h2 p idresultText等待抽奖.../p /div !-- Three.js渲染的canvas会由JS自动添加到这里 -- script typemodule src/main.js/script /body /html/* style.css */ * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; /* 隐藏滚动条让canvas全屏 */ font-family: sans-serif; } #startBtn { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); padding: 15px 30px; font-size: 18px; background: linear-gradient(45deg, #ff6b6b, #ffa726); color: white; border: none; border-radius: 50px; cursor: pointer; z-index: 100; /* 确保按钮在3D画布之上 */ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); transition: all 0.3s ease; } #startBtn:hover { background: linear-gradient(45deg, #ff4757, #ff7f00); transform: translateX(-50%) scale(1.05); } #startBtn:active { transform: translateX(-50%) scale(0.95); } .result-panel { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.7); color: white; padding: 20px 40px; border-radius: 15px; text-align: center; min-width: 300px; z-index: 100; backdrop-filter: blur(5px); } .result-panel h2 { margin-bottom: 10px; color: #4cd964; } #resultText { font-size: 24px; font-weight: bold; min-height: 30px; }同时更新main.js中的calculateWinner函数使其更新页面上的结果面板。// 在calculateWinner函数中替换alert改为更新DOM function calculateWinner() { // ... (前面的角度计算逻辑不变) ... finalPrizeIndex winnerIndex; const winnerName prizes[winnerIndex].name; console.log(抽奖停止中奖者是${winnerName}); // 更新页面上的结果文本 const resultTextEl document.getElementById(resultText); resultTextEl.textContent winnerName; resultTextEl.style.color #${prizes[winnerIndex].color.toString(16).padStart(6, 0)}; // 使用奖品颜色 // 也可以添加一些庆祝动画例如让中奖奖品闪烁 const winnerMesh prizeMeshes[winnerIndex]; const originalColor winnerMesh.material.color.clone(); let blinkCount 0; const blinkInterval setInterval(() { winnerMesh.material.color.set(blinkCount % 2 0 ? 0xffffff : originalColor); blinkCount; if (blinkCount 10) { // 闪烁5次 clearInterval(blinkInterval); winnerMesh.material.color.copy(originalColor); } }, 200); }4.5 处理窗口大小变化为了确保3D场景能自适应浏览器窗口大小需要监听resize事件。// 在 main.js 末尾添加 window.addEventListener(resize, onWindowResize); function onWindowResize() { // 更新相机宽高比 camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); // 相机参数改变后必须调用此方法 // 更新渲染器尺寸 renderer.setSize(window.innerWidth, window.innerHeight); }4.6 运行与验证现在回到终端确保你的开发服务器正在运行 (npm run dev)。打开浏览器访问http://localhost:5173。你应该能看到一个黑色的全屏区域中间环绕着一圈彩色立方体。顶部有一个醒目的“开始抽奖”按钮。底部有一个显示中奖结果的面板。点击“开始抽奖”按钮立方体环开始加速旋转。约3秒后开始减速最终停止底部面板会显示中奖的奖品名称并且对应的立方体会闪烁几次。至此一个基本的3D动态抽奖就完成了5. 常见问题与排查思路在实现过程中你可能会遇到以下问题问题现象可能原因解决思路页面一片空白控制台无报错1. Three.js库未正确引入。2. 渲染器的domElement未添加到页面。3. 相机位置不对物体在相机后面或视锥体外。1. 检查import语句和控制台网络请求。2. 检查document.body.appendChild(renderer.domElement)是否执行。3. 调整camera.position.z的值如设为5或10。物体是黑色的场景中没有添加光源或光源强度太低。确保添加了AmbientLight和DirectionalLight并检查光源的intensity强度参数。旋转动画卡顿或不流畅1. 动画逻辑计算过于复杂。2. 物体面数太多或材质复杂。3. 浏览器性能限制。1. 优化animate函数中的计算避免在每一帧进行重开销操作。2. 使用更简单的几何体如BoxGeometry代替SphereGeometry高细分和材质。3. 使用stats.js库监控帧率检查性能瓶颈。点击按钮无反应1. 按钮id与JS中getElementById不匹配。2. JS代码在DOM加载前执行找不到按钮元素。3. 事件监听函数有语法错误。1. 核对HTML中按钮的id和JS中的选择器。2. 将JS代码放在DOMContentLoaded事件中或使用defer属性。3. 打开浏览器开发者工具的“Console”面板查看错误。中奖结果计算不准calculateWinner函数中的角度计算逻辑有误。1. 添加console.log打印currentAngle和计算出的diff值进行调试。2. 理解prizeGroup.rotation.y与每个奖品原始位置角度的关系。可以画图辅助理解。3. 尝试简化先让奖品停止在固定角度验证逻辑。在移动设备上显示异常1. 未适配移动端视口。2. 触摸事件未处理。3. 性能问题。1. 确保HTML有meta nameviewport ...标签。2. 将click事件改为同时监听touchend。3. 考虑降低渲染分辨率renderer.setPixelRatio(window.devicePixelRatio)或简化场景。6. 最佳实践与进阶优化上面的示例实现了核心功能但要投入生产环境或追求更好体验还需要考虑以下方面6.1 性能优化重用几何体和材质如果多个物体形状颜色相同应共享同一个几何体和材质实例以减少内存占用和GPU调用。const sharedGeometry new THREE.BoxGeometry(1.5, 1.5, 1.5); const sharedMaterial new THREE.MeshLambertMaterial({ color: 0x00ff00 }); const mesh1 new THREE.Mesh(sharedGeometry, sharedMaterial); const mesh2 new THREE.Mesh(sharedGeometry, sharedMaterial); // 重用使用BufferGeometry对于复杂静态模型BufferGeometry比Geometry性能更高。适时销毁从场景中移除不再需要的物体时调用geometry.dispose()和material.dispose()释放资源。控制帧率对于非必要的高频更新可以使用setInterval或限制requestAnimationFrame的执行频率。6.2 用户体验增强添加加载状态如果使用纹理或复杂模型显示加载进度条。更丰富的奖品表现用THREE.Sprite或CSS2DRenderer为立方体添加文字标签。使用THREE.TextureLoader加载图片作为奖品纹理。动画曲线使用Tween.js或GSAP等动画库实现更平滑的加速、减速、弹性停止效果代替简单的线性减速。音效使用Howler.js或Web Audio API在开始、停止、中奖时添加音效。响应式设计根据屏幕尺寸调整相机位置(camera.position.z)、环形半径(radius)和奖品大小。6.3 代码结构优化模块化将场景创建、奖品生成、动画控制、工具函数拆分成独立的ES6模块。配置化将奖品列表、颜色、旋转速度、减速时间等抽奖参数提取为配置文件便于维护和动态修改。状态管理对于更复杂的交互可以考虑引入简单的状态机如isIdle,isAccelerating,isDecelerating,isStopped来管理抽奖流程使逻辑更清晰。6.4 生产环境注意事项错误边界对WebGL上下文丢失、资源加载失败等情况进行捕获和降级处理如提示用户刷新。降级方案通过能力检测在不支持WebGL的浏览器中回退到使用CSS 3D或2D Canvas实现的简单抽奖。与后端集成实际的抽奖逻辑如奖品概率、库存扣减、中奖记录应在后端进行。前端只负责展示动画最终中奖结果应由后端接口返回前端仅作展示防止作弊。安全性确保抽奖结果不可被前端代码预测或篡改。所有涉及概率和结果判定的逻辑必须放在服务端。7. 总结与扩展方向通过本文我们完成了一个完整的、具有视觉吸引力的3D动态抽奖组件。我们学习了如何使用Three.js创建3D场景、物体、动画并处理用户交互。关键在于理解3D空间中的坐标、旋转概念以及requestAnimationFrame动画循环。你可以在此基础上进行无限扩展更换3D模型使用Blender等软件制作精美的奖品模型如礼品盒、金币、宝石通过GLTFLoader导入Three.js。粒子特效在中奖时刻使用THREE.Points创建爆炸、星光等粒子效果。多角度相机添加相机动画在抽奖过程中切换视角增强视觉冲击力。虚拟摇杆在移动端实现通过拖拽屏幕来控制旋转速度和方向。接入真实数据从后端API获取奖品列表和用户抽奖结果打造一个完整的线上营销活动页面。希望这个项目能为你打开Web 3D图形开发的大门。动手尝试修改参数、添加新功能是学习Three.js最好的方式。如果在实践中遇到问题欢迎在评论区交流探讨。