最近在开发一个线上活动页面时需要一个既能吸引用户眼球又能保证公平性的抽奖环节。传统的静态抽奖列表或简单的转盘动画在视觉冲击力和互动体验上总觉得差了点意思。于是我决定动手实现一个更具沉浸感的3D 抽奖系统。这个系统不仅能让奖品在三维空间中旋转、跳动还能通过鼠标或触摸进行交互极大地提升了活动的趣味性和参与度。本文将完整分享从零构建这个 3D 抽奖系统的全过程。无论你是前端新手想学习 Three.js 的基础应用还是有一定经验的开发者希望为项目添加酷炫的互动效果都能从本文中找到清晰的步骤、可运行的代码以及避坑指南。我们将使用Three.js这个强大的 3D 库作为核心结合原生 JavaScript 和 CSS打造一个无需复杂后端、纯前端即可运行的动态抽奖应用。1. 核心概念与技术选型在开始编码之前我们先明确几个核心概念并了解为什么选择这些技术栈。1.1 什么是 3D 抽奖与传统 2D 抽奖如转盘、九宫格不同3D 抽奖将奖品、背景乃至整个抽奖场景置于一个三维坐标系中。用户可以多角度观察通过旋转场景从不同视角查看奖品。空间互动奖品可以有远近、大小、旋转等空间状态变化。增强沉浸感配合光影、材质和动画营造出更接近真实世界的视觉体验。其核心目标是利用 3D 技术提升用户的视觉享受和参与过程的趣味性。1.2 为什么选择 Three.js实现 Web 端的 3D 效果主要有 WebGL 原生 API、Three.js、Babylon.js 等方案。我们选择Three.js原因如下入门友好Three.js 对底层的 WebGL 进行了高度封装提供了更直观的相机Camera、场景Scene、物体Mesh、渲染器Renderer等概念大大降低了 3D 编程的门槛。生态丰富拥有庞大的社区和丰富的示例、插件遇到问题更容易找到解决方案。功能全面足以应对本次抽奖项目所需的光照、材质、几何体、动画、交互等所有功能。1.3 系统功能设计我们的 3D 抽奖系统将包含以下核心功能模块3D 场景搭建创建场景、相机、渲染器并添加光源。奖品建模与布局将每个奖品创建为一个 3D 物体如立方体、圆盘并均匀排列在一个圆形或球面上。动画系统待机动画奖品缓慢旋转或浮动吸引注意力。抽奖动画触发抽奖后所有奖品开始高速旋转速度逐渐减慢最终随机停止在一个奖品上。交互控制允许用户通过鼠标拖拽旋转场景从不同角度查看奖品。结果展示高亮显示中奖奖品并弹出提示信息。2. 环境准备与项目结构本项目为纯前端项目无需后端和复杂的构建工具。只需一个现代浏览器和一个代码编辑器。2.1 环境与版本说明操作系统Windows / macOS / Linux 均可。浏览器支持 WebGL 的现代浏览器Chrome 90 Firefox 88 Safari 14。开发工具VS Code、WebStorm 或任何你喜欢的编辑器。核心库Three.js (r128 或更高版本)。我们将通过 CDN 引入方便快捷。2.2 创建项目结构首先创建一个新的项目文件夹例如3d-lottery并在其中创建以下文件3d-lottery/ ├── index.html # 主页面 ├── style.css # 样式文件 └── main.js # Three.js 主逻辑代码3. 基础场景搭建我们从创建一个最基本的 3D 场景开始这是所有 3D 内容的容器。3.1 编写 HTML 骨架 (index.html)!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title3D 动态抽奖系统/title link relstylesheet hrefstyle.css !-- 引入 Three.js 核心库 -- script srchttps://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js/script !-- 引入轨道控制器用于鼠标拖拽旋转场景 -- script srchttps://cdn.jsdelivr.net/npm/three0.128.0/examples/js/controls/OrbitControls.min.js/script style body { margin: 0; overflow: hidden; } #info { position: absolute; top: 10px; left: 10px; color: white; font-family: monospace; } /style /head body !-- 用于显示FPS等信息的区域 -- div idinfo加载中.../div !-- 抽奖控制按钮 -- div idcontrolPanel button idstartBtn开始抽奖/button button idresetBtn重置/button div idresult/div /div !-- Three.js 渲染器将把画布注入到这里 -- script srcmain.js/script /body /html3.2 添加基础样式 (style.css)body { margin: 0; overflow: hidden; font-family: Arial, sans-serif; } #controlPanel { position: absolute; bottom: 20px; width: 100%; text-align: center; z-index: 100; } #controlPanel button { padding: 12px 24px; margin: 0 10px; font-size: 16px; border: none; border-radius: 6px; cursor: pointer; background-color: #4CAF50; color: white; transition: background-color 0.3s; } #controlPanel button:hover { background-color: #45a049; } #controlPanel button:disabled { background-color: #cccccc; cursor: not-allowed; } #result { margin-top: 15px; font-size: 20px; font-weight: bold; color: #FFD700; /* 金色 */ text-shadow: 1px 1px 2px rgba(0,0,0,0.8); min-height: 30px; }3.3 初始化 3D 场景 (main.js)这是最核心的一步我们初始化 Three.js 的三个核心对象场景、相机和渲染器。// main.js // 1. 初始化场景 const scene new THREE.Scene(); scene.background new THREE.Color(0x0a0a2a); // 设置深蓝色背景 // 2. 初始化相机 (透视相机模拟人眼) const camera new THREE.PerspectiveCamera( 75, // 视野角度 (FOV) window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近截面 1000 // 远截面 ); camera.position.set(0, 5, 15); // 设置相机位置 (x, y, z) // 3. 初始化渲染器 const renderer new THREE.WebGLRenderer({ antialias: true }); // 开启抗锯齿 renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled true; // 启用阴影 renderer.shadowMap.type THREE.PCFSoftShadowMap; // 使用软阴影 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, 20, 5); directionalLight.castShadow true; scene.add(directionalLight); // 5. 添加轨道控制器允许鼠标拖拽旋转、缩放场景 const controls new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping true; // 启用阻尼惯性效果使交互更平滑 controls.dampingFactor 0.05; // 6. 处理窗口大小变化 window.addEventListener(resize, onWindowResize); function onWindowResize() { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } // 7. 动画循环函数 function animate() { requestAnimationFrame(animate); // 请求下一帧 controls.update(); // 更新控制器如果启用了阻尼 renderer.render(scene, camera); // 渲染场景 } animate(); // 启动动画循环 // 至此一个基本的、可交互的3D场景已经搭建完毕。打开index.html你应该能看到一个深蓝色的 3D 空间并且可以用鼠标左键拖拽旋转视角滚轮缩放。虽然现在空无一物但基础框架已经就绪。4. 创建 3D 奖品与布局接下来我们要创建代表奖品的 3D 物体并将它们美观地排列在场景中。4.1 定义奖品数据我们用一个数组来定义奖品列表每个奖品包含名称、颜色和对应的 3D 物体。// 在 main.js 的顶部定义奖品数据 const prizes [ { name: 一等奖\niPhone, color: 0xFFD700 }, // 金色 { name: 二等奖\nAirPods, color: 0xC0C0C0 }, // 银色 { name: 三等奖\n键盘, color: 0xCD7F32 }, // 古铜色 { name: 幸运奖\n咖啡券, color: 0x8A2BE2 }, // 蓝紫色 { name: 参与奖\n积分, color: 0x1E90FF }, // 道奇蓝 { name: 谢谢参与, color: 0x808080 }, // 灰色 { name: 惊喜奖\n鼠标, color: 0xFF69B4 }, // 热粉色 { name: 阳光普照奖, color: 0x32CD32 }, // 酸橙绿 ]; const prizeObjects []; // 用于存放所有奖品3D对象的数组 const radius 8; // 奖品排列的圆形半径4.2 创建单个奖品模型函数我们将每个奖品创建为一个组THREE.Group包含一个立方体几何体和一个显示文字的精灵THREE.Sprite。// 创建单个奖品的函数 function createPrizeObject(prizeData, index, total) { const group new THREE.Group(); // 1. 创建立方体几何体作为奖品主体 const geometry new THREE.BoxGeometry(2, 2, 2); // 宽高深 const material new THREE.MeshPhongMaterial({ color: prizeData.color, shininess: 100, // 高光亮度 }); const cube new THREE.Mesh(geometry, material); cube.castShadow true; // 投射阴影 cube.receiveShadow true; // 接收阴影 group.add(cube); // 2. 创建文字精灵Sprite显示奖品名称 // 先创建一个Canvas来绘制文字 const canvas document.createElement(canvas); const context canvas.getContext(2d); canvas.width 256; canvas.height 128; // 绘制背景 context.fillStyle rgba(0, 0, 0, 0.7); context.fillRect(0, 0, canvas.width, canvas.height); // 绘制文字 context.font bold 28px Arial; context.textAlign center; context.textBaseline middle; context.fillStyle #${prizeData.color.toString(16).padStart(6, 0)}; const lines prizeData.name.split(\n); lines.forEach((line, i) { context.fillText(line, canvas.width / 2, canvas.height / 2 - 20 i * 40); }); const texture new THREE.CanvasTexture(canvas); const spriteMaterial new THREE.SpriteMaterial({ map: texture }); const sprite new THREE.Sprite(spriteMaterial); sprite.scale.set(4, 2, 1); // 设置精灵大小 sprite.position.y 2.5; // 将文字放置在立方体上方 group.add(sprite); // 3. 计算该奖品在圆形上的位置 const angle (index / total) * Math.PI * 2; // 角度 group.position.x Math.cos(angle) * radius; group.position.z Math.sin(angle) * radius; // 让奖品稍微朝向圆心 group.lookAt(0, group.position.y, 0); // 为组对象添加自定义属性方便后续使用 group.userData { prizeName: prizeData.name, originalColor: prizeData.color, index: index }; return group; }4.3 生成所有奖品并添加到场景// 在初始化场景和光源之后创建并添加所有奖品 prizes.forEach((prize, index) { const prizeObj createPrizeObject(prize, index, prizes.length); scene.add(prizeObj); prizeObjects.push(prizeObj); });刷新页面你应该能看到 8 个彩色立方体排列成一个圆圈每个立方体上方都有对应的奖品名称。场景已经初具规模5. 实现抽奖动画逻辑这是最有趣的部分。我们需要实现点击“开始抽奖”按钮所有奖品开始旋转速度由快变慢最后随机停止并高亮中奖奖品。5.1 定义动画状态变量// 在 main.js 顶部添加状态变量 let isDrawing false; // 是否正在抽奖中 let spinSpeed 0; // 当前旋转速度弧度/帧 let deceleration 0.005; // 减速度 let targetPrizeIndex -1; // 目标中奖奖品索引-1表示未设定 let finalAngle 0; // 最终停止时的角度5.2 编写抽奖开始函数// 绑定开始按钮事件 document.getElementById(startBtn).addEventListener(click, startLottery); function startLottery() { if (isDrawing) return; // 防止重复点击 isDrawing true; document.getElementById(startBtn).disabled true; document.getElementById(result).textContent 抽奖中...; // 1. 随机决定中奖奖品 targetPrizeIndex Math.floor(Math.random() * prizes.length); console.log(本次中奖索引: ${targetPrizeIndex}, 奖品: ${prizes[targetPrizeIndex].name}); // 2. 计算为了使该奖品停在正前方整个圆环需要旋转的最终角度 // 当前奖品在圆环上的角度 const prizeAngle (targetPrizeIndex / prizes.length) * Math.PI * 2; // 我们希望它停在 (0, 0, -radius) 这个位置即正前方相机看向的方向 // 所以需要旋转的角度 目标位置角度 - 当前奖品角度 额外的整数圈增加随机性 const extraRotations 5 Math.random() * 3; // 额外转5-8圈 finalAngle (Math.PI - prizeAngle) (extraRotations * Math.PI * 2); // 目标位置角度是PI180度 // 3. 初始化动画参数 spinSpeed 0.5; // 初始高速旋转 deceleration 0.005 Math.random() * 0.002; // 随机减速度让每次停止时间略有不同 }5.3 在动画循环中更新旋转我们需要修改animate函数在每一帧根据状态更新所有奖品的旋转。// 替换之前的 animate 函数 function animate() { requestAnimationFrame(animate); // --- 抽奖动画逻辑 --- if (isDrawing) { // 1. 应用当前旋转速度 prizeObjects.forEach(obj { obj.rotation.y spinSpeed; }); // 2. 计算并应用减速度 // 我们根据当前旋转角度与最终角度的差距来动态调整减速 const currentTotalRotation prizeObjects[0]?.rotation.y || 0; const angleToGo finalAngle - currentTotalRotation; // 当接近目标时开始更平滑地减速 if (Math.abs(angleToGo) 3) { deceleration Math.max(deceleration, Math.abs(angleToGo) / 300); } spinSpeed Math.max(spinSpeed - deceleration, 0); // 减速但不小于0 // 3. 判断是否停止 if (spinSpeed 0.001) { finishLottery(); } } // --- 待机浮动动画当不在抽奖时--- if (!isDrawing) { const time Date.now() * 0.001; // 获取时间秒 prizeObjects.forEach((obj, idx) { // 每个奖品上下浮动相位不同 obj.position.y Math.sin(time * 1.5 idx) * 0.3; // 缓慢自转 obj.rotation.y 0.005; }); } controls.update(); renderer.render(scene, camera); }5.4 编写抽奖结束函数function finishLottery() { isDrawing false; spinSpeed 0; // 1. 高亮显示中奖奖品 prizeObjects.forEach((obj, idx) { // 重置所有奖品颜色 obj.children[0].material.color.setHex(obj.userData.originalColor); // 如果是中奖奖品改变颜色并放大 if (idx targetPrizeIndex) { obj.children[0].material.color.setHex(0xFF4500); // 设置为橙色红色 obj.scale.set(1.5, 1.5, 1.5); // 放大1.5倍 // 添加一个脉动光效简单实现 const pulseLight new THREE.PointLight(0xFF4500, 2, 10); pulseLight.position.copy(obj.position); pulseLight.position.y 3; scene.add(pulseLight); setTimeout(() scene.remove(pulseLight), 2000); // 2秒后移除 } }); // 2. 在UI上显示结果 const resultDiv document.getElementById(result); resultDiv.textContent 恭喜您抽中了${prizes[targetPrizeIndex].name}; resultDiv.style.animation none; setTimeout(() { resultDiv.style.animation pulse 1s infinite; }, 10); // 触发CSS动画 // 3. 启用按钮 document.getElementById(startBtn).disabled false; }5.5 添加重置功能// 绑定重置按钮事件 document.getElementById(resetBtn).addEventListener(click, resetLottery); function resetLottery() { if (isDrawing) return; // 重置奖品状态 prizeObjects.forEach((obj) { obj.children[0].material.color.setHex(obj.userData.originalColor); obj.scale.set(1, 1, 1); // 重置位置防止因交互控制器移动 const angle (obj.userData.index / prizes.length) * Math.PI * 2; obj.position.x Math.cos(angle) * radius; obj.position.z Math.sin(angle) * radius; obj.lookAt(0, obj.position.y, 0); }); // 重置UI document.getElementById(result).textContent ; targetPrizeIndex -1; }5.6 添加 CSS 动画在style.css末尾添加一个脉动动画用于结果展示。keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } }现在一个功能完整的 3D 抽奖系统就实现了点击“开始抽奖”按钮观看奖品飞速旋转并最终停止中奖奖品会被高亮显示。6. 性能优化与增强体验基础功能完成后我们可以从性能和体验上进一步优化。6.1 添加加载管理器与进度提示当资源如字体、纹理较多时添加加载提示能提升用户体验。// 在 main.js 开头 const loadingManager new THREE.LoadingManager(); loadingManager.onStart (url, itemsLoaded, itemsTotal) { console.log(开始加载: ${url} 已加载 ${itemsLoaded} / ${itemsTotal} 个文件); }; loadingManager.onProgress (url, itemsLoaded, itemsTotal) { const progress (itemsLoaded / itemsTotal) * 100; document.getElementById(info).textContent 加载中... ${progress.toFixed(0)}%; }; loadingManager.onLoad () { document.getElementById(info).style.display none; console.log(所有资源加载完成); }; // 在创建纹理时可以传入 loadingManager // const texture new THREE.TextureLoader(loadingManager).load(path/to/image.jpg);6.2 优化渲染性能按需渲染当场景没有动画时可以停止渲染循环以节省性能。let renderRequestId null; function animate() { renderRequestId requestAnimationFrame(animate); // ... 原有的动画逻辑 ... renderer.render(scene, camera); } // 可以在抽奖结束后如果没有交互则停止渲染 // 但通常对于这种小型应用持续渲染开销不大。这里提供一个思路 function stopRendering() { if (renderRequestId) { cancelAnimationFrame(renderRequestId); renderRequestId null; } } function startRendering() { if (!renderRequestId) { animate(); } } // 可以通过监听用户交互如鼠标移动来调用 startRendering6.3 添加音效可选音效能极大增强沉浸感。我们可以为开始抽奖、停止、中奖添加音效。!-- 在 index.html 的 head 中添加 -- audio idspinSound srchttps://assets.mixkit.co/sfx/preview/mixkit-game-show-spin-904.mp3 preloadauto/audio audio idwinSound srchttps://assets.mixkit.co/sfx/preview/mixkit-winning-chimes-2015.mp3 preloadauto/audio// 修改 startLottery 和 finishLottery 函数 function startLottery() { // ... 原有代码 ... document.getElementById(spinSound).currentTime 0; document.getElementById(spinSound).play().catch(e console.log(音频播放失败:, e)); } function finishLottery() { // ... 原有代码 ... document.getElementById(spinSound).pause(); document.getElementById(winSound).currentTime 0; document.getElementById(winSound).play().catch(e console.log(音频播放失败:, e)); }注意使用网络音频需注意跨域和加载问题在实际项目中建议将音频文件放在自己服务器。7. 常见问题与排查思路在开发过程中你可能会遇到以下问题问题现象可能原因解决思路页面一片漆黑没有3D内容1. Three.js 库未正确加载。2. 相机位置不对在物体内部或背面。3. 没有添加光源。1. 检查浏览器控制台是否有THREEis not defined 错误。2. 调整camera.position.z为一个正数如 15。3. 确保scene.add(light)代码已执行。鼠标无法拖拽旋转场景1. OrbitControls 未正确引入或初始化。2. 控制器绑定的 DOM 元素错误。1. 检查控制台是否有OrbitControls未定义的错误。2. 确保new THREE.OrbitControls(camera, renderer.domElement)中的第二个参数是渲染器的 canvas 元素。物体没有阴影1. 渲染器未启用阴影映射。2. 光源未启用投射阴影。3. 物体未设置castShadow或receiveShadow。1. 设置renderer.shadowMap.enabled true。2. 设置light.castShadow true。3. 对网格Mesh设置mesh.castShadow true和mesh.receiveShadow true。抽奖动画卡顿1. 物体面数太多或材质太复杂。2. 动画循环中有性能瓶颈。3. 浏览器硬件加速未开启。1. 使用更简单的几何体如BoxGeometry代替SphereGeometry高细分。2. 在animate函数中避免复杂计算使用console.time定位耗时操作。3. 在浏览器设置中开启硬件加速。文字精灵Sprite模糊Canvas 绘制文字的分辨率canvas.width/height太低。增加canvas.width和canvas.height的值如 512x256并相应调整sprite.scale。在移动设备上无法触摸交互OrbitControls 默认支持触摸但可能需要设置controls.enablePan false来避免误触平移。确保渲染器的 canvas 元素覆盖了正确的触摸区域并检查是否有其他CSS样式阻止了触摸事件。8. 最佳实践与扩展方向8.1 代码组织最佳实践模块化对于更复杂的项目应将场景创建、奖品管理、动画逻辑、UI控制拆分成独立的模块或类。资源管理使用THREE.LoadingManager统一管理纹理、模型等异步资源的加载并提供进度反馈。错误边界在animate循环和事件监听器中添加try...catch避免因单点错误导致整个应用崩溃。内存管理动态创建的对象如中奖时的脉冲光在使用完毕后务必从场景中移除scene.remove(object)并调用object.dispose()释放几何体和材质。8.2 视觉与交互增强更精美的奖品模型使用THREE.GLTFLoader加载外部 3D 模型如 .glb/.gltf 文件代替简单的几何体。粒子特效使用THREE.Points或THREE.Sprite创建烟花、星光等粒子系统在中奖时爆发。镜头动画抽奖过程中使用Tween.js或GSAP库控制相机运动实现镜头推进、环绕等电影感运镜。物理引擎集成Cannon.js或Ammo.js让奖品在停止时具有碰撞、弹跳等物理效果。8.3 工程化与部署使用构建工具对于正式项目建议使用 Vite、Webpack 等构建工具通过npm install three管理依赖并利用 Tree Shaking 优化打包体积。响应式设计监听窗口resize事件动态更新相机比例和渲染器尺寸确保在不同设备上正常显示。后端集成将抽奖逻辑如奖品概率、中奖记录移至后端服务器前端通过 WebSocket 或 REST API 与之通信保证抽奖的公平性和安全性。数据驱动将奖品列表、颜色、动画参数等配置项提取为 JSON 文件便于非开发人员修改。8.4 性能监控在开发过程中可以打开浏览器的性能监测工具如 Chrome DevTools 的 Performance 面板录制一段抽奖动画查看帧率FPS和函数调用耗时针对瓶颈进行优化。通过以上步骤我们不仅完成了一个好玩的 3D 抽奖应用更掌握了使用 Three.js 创建交互式 3D 网页的基本流程。从场景搭建、物体创建、动画控制到交互集成这套方法论可以迁移到任何需要 Web 3D 展示的项目中例如产品展示、数据可视化、游戏原型等。