基于Three.js的3D抽奖系统开发:从场景搭建到动画交互全流程
2026/8/4 7:46:16 网站建设 项目流程

最近在开发一个线上活动页面时,需要一个既能吸引用户眼球,又能保证公平性的抽奖环节。传统的静态抽奖列表或简单的转盘动画,在视觉冲击力和互动体验上总觉得差了点意思。于是,我决定动手实现一个更具沉浸感的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 抽奖系统将包含以下核心功能模块:

  1. 3D 场景搭建:创建场景、相机、渲染器,并添加光源。
  2. 奖品建模与布局:将每个奖品创建为一个 3D 物体(如立方体、圆盘),并均匀排列在一个圆形或球面上。
  3. 动画系统
    • 待机动画:奖品缓慢旋转或浮动,吸引注意力。
    • 抽奖动画:触发抽奖后,所有奖品开始高速旋转,速度逐渐减慢,最终随机停止在一个奖品上。
  4. 交互控制:允许用户通过鼠标拖拽旋转场景,从不同角度查看奖品。
  5. 结果展示:高亮显示中奖奖品,并弹出提示信息。

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(); } } // 可以通过监听用户交互(如鼠标移动)来调用 startRendering

6.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. 物体未设置castShadowreceiveShadow
1. 设置renderer.shadowMap.enabled = true
2. 设置light.castShadow = true
3. 对网格(Mesh)设置mesh.castShadow = truemesh.receiveShadow = true
抽奖动画卡顿1. 物体面数太多或材质太复杂。
2. 动画循环中有性能瓶颈。
3. 浏览器硬件加速未开启。
1. 使用更简单的几何体(如BoxGeometry代替SphereGeometry高细分)。
2. 在animate函数中避免复杂计算,使用console.time定位耗时操作。
3. 在浏览器设置中开启硬件加速。
文字精灵(Sprite)模糊Canvas 绘制文字的分辨率(canvas.width/height)太低。增加canvas.widthcanvas.height的值(如 512x256),并相应调整sprite.scale
在移动设备上无法触摸交互OrbitControls 默认支持触摸,但可能需要设置controls.enablePan = false来避免误触平移。确保渲染器的 canvas 元素覆盖了正确的触摸区域,并检查是否有其他CSS样式阻止了触摸事件。

8. 最佳实践与扩展方向

8.1 代码组织最佳实践

  1. 模块化:对于更复杂的项目,应将场景创建、奖品管理、动画逻辑、UI控制拆分成独立的模块或类。
  2. 资源管理:使用THREE.LoadingManager统一管理纹理、模型等异步资源的加载,并提供进度反馈。
  3. 错误边界:在animate循环和事件监听器中添加try...catch,避免因单点错误导致整个应用崩溃。
  4. 内存管理:动态创建的对象(如中奖时的脉冲光),在使用完毕后务必从场景中移除(scene.remove(object))并调用object.dispose()释放几何体和材质。

8.2 视觉与交互增强

  1. 更精美的奖品模型:使用THREE.GLTFLoader加载外部 3D 模型(如 .glb/.gltf 文件)代替简单的几何体。
  2. 粒子特效:使用THREE.PointsTHREE.Sprite创建烟花、星光等粒子系统,在中奖时爆发。
  3. 镜头动画:抽奖过程中,使用Tween.jsGSAP库控制相机运动,实现镜头推进、环绕等电影感运镜。
  4. 物理引擎:集成Cannon.jsAmmo.js,让奖品在停止时具有碰撞、弹跳等物理效果。

8.3 工程化与部署

  1. 使用构建工具:对于正式项目,建议使用 Vite、Webpack 等构建工具,通过npm install three管理依赖,并利用 Tree Shaking 优化打包体积。
  2. 响应式设计:监听窗口resize事件,动态更新相机比例和渲染器尺寸,确保在不同设备上正常显示。
  3. 后端集成:将抽奖逻辑(如奖品概率、中奖记录)移至后端服务器,前端通过 WebSocket 或 REST API 与之通信,保证抽奖的公平性和安全性。
  4. 数据驱动:将奖品列表、颜色、动画参数等配置项提取为 JSON 文件,便于非开发人员修改。

8.4 性能监控

在开发过程中,可以打开浏览器的性能监测工具(如 Chrome DevTools 的 Performance 面板),录制一段抽奖动画,查看帧率(FPS)和函数调用耗时,针对瓶颈进行优化。

通过以上步骤,我们不仅完成了一个好玩的 3D 抽奖应用,更掌握了使用 Three.js 创建交互式 3D 网页的基本流程。从场景搭建、物体创建、动画控制到交互集成,这套方法论可以迁移到任何需要 Web 3D 展示的项目中,例如产品展示、数据可视化、游戏原型等。

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

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

立即咨询