最近在开发一个线上活动页面时,需要一个既能吸引用户眼球,又能保证公平性的抽奖环节。传统的静态抽奖列表或简单的转盘动画,在视觉冲击力和互动体验上总觉得差了点意思。于是,我决定动手实现一个更具沉浸感的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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>3D 动态抽奖系统</title> <link rel="stylesheet" href="style.css"> <!-- 引入 Three.js 核心库 --> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <!-- 引入轨道控制器,用于鼠标拖拽旋转场景 --> <script src="https://cdn.jsdelivr.net/npm/three@0.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 id="info">加载中...</div> <!-- 抽奖控制按钮 --> <div id="controlPanel"> <button id="startBtn">开始抽奖</button> <button id="resetBtn">重置</button> <div id="result"></div> </div> <!-- Three.js 渲染器将把画布注入到这里 --> <script src="main.js"></script> </body> </html>3.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); // 目标位置角度是PI(180度) // 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 id="spinSound" src="https://assets.mixkit.co/sfx/preview/mixkit-game-show-spin-904.mp3" preload="auto"></audio> <audio id="winSound" src="https://assets.mixkit.co/sfx/preview/mixkit-winning-chimes-2015.mp3" preload="auto"></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 展示的项目中,例如产品展示、数据可视化、游戏原型等。