去年公司年会前一周,行政小妹突然找到我:"能不能做个比Excel随机点名更带劲的抽奖程序?"于是就有了这个会旋转的3D圆球抽奖器。不同于传统的平面抽奖界面,这个工具通过WebGL渲染出带有员工姓名的3D球体,点击屏幕时球体会高速旋转后随机停在一个名字上,配合粒子特效和音效,现场效果堪比综艺节目。
核心实现基于Three.js这个强大的Web 3D库,整个项目不到200行代码,但涉及几个关键技巧:3D物体创建、材质贴图处理、物理运动模拟以及性能优化。下面我会从设计思路到代码实现完整解析这个"年会气氛组神器"的开发过程。
对比传统抽奖方式,3D圆球有三大优势:
| 方案 | 优点 | 缺点 | 最终选择 |
|---|---|---|---|
| CSS 3D | 兼容性好 | 性能差/效果简陋 | ❌ |
| Three.js | 功能强大 | 需要学习新API | ✅ |
| Unity WebGL | 效果极致 | 打包体积大 | ❌ |
选择Three.js的核心考量:
首先创建标准HTML5页面,引入必要的资源:
html复制<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>年会抽奖神器</title>
<style>body { margin: 0; overflow: hidden; }</style>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<script src="main.js"></script>
</body>
</html>
注意:Three.js版本建议使用0.132.x以上稳定版,新版本可能有API变更
在main.js中初始化基础3D环境:
javascript复制// 初始化场景、相机、渲染器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 添加光源
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(1, 1, 1).normalize();
scene.add(light);
scene.add(new THREE.AmbientLight(0x404040));
// 相机位置调整
camera.position.z = 5;
核心技巧是使用球体贴图(Texture Mapping):
javascript复制function createNameBall(names) {
// 1. 创建canvas绘制名字纹理
const canvas = document.createElement('canvas');
canvas.width = 2048;
canvas.height = 1024;
const ctx = canvas.getContext('2d');
// 2. 在canvas上绘制所有名字(伪代码)
drawNamesOnCanvas(ctx, names);
// 3. 创建球体并应用纹理
const texture = new THREE.CanvasTexture(canvas);
const geometry = new THREE.SphereGeometry(3, 64, 64);
const material = new THREE.MeshPhongMaterial({
map: texture,
transparent: true,
opacity: 0.9
});
return new THREE.Mesh(geometry, material);
}
实操技巧:名字分布算法需要保证:
- 名字不重叠
- 均匀覆盖球面
- 大小随位置变化(模拟透视)
物理模拟是效果逼真的关键:
javascript复制function startLottery() {
// 初始速度
let speed = 0.5;
let friction = 0.98;
function animate() {
requestAnimationFrame(animate);
// 物理减速
speed *= friction;
if (speed < 0.005) {
speed = 0;
// 触发中奖逻辑
selectWinner();
}
// 应用旋转
ball.rotation.y += speed;
renderer.render(scene, camera);
}
animate();
}
在球体周围创建随机粒子:
javascript复制function createParticles() {
const particles = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < 1000; i++) {
positions.push(
Math.random() * 10 - 5,
Math.random() * 10 - 5,
Math.random() * 10 - 5
);
}
particles.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const particleSystem = new THREE.Points(
particles,
new THREE.PointsMaterial({ color: 0x8888ff, size: 0.1 })
);
scene.add(particleSystem);
return particleSystem;
}
增强现场互动感:
javascript复制// 抽奖时播放音效
const clickSound = new Audio('click.mp3');
document.addEventListener('click', () => {
clickSound.currentTime = 0;
clickSound.play();
// 设备震动API
if (navigator.vibrate) {
navigator.vibrate([100, 50, 100]);
}
});
javascript复制let isRotating = false;
function animate() {
requestAnimationFrame(animate);
if (isRotating || frameCount++ % 2 === 0) {
renderer.render(scene, camera);
}
}
javascript复制new THREE.SphereGeometry(3, 64, 64); // 适当减少分段数
动态加载资源时注意:
javascript复制// 切换不同抽奖名单时
function reloadNames(newNames) {
scene.remove(ball);
ball.geometry.dispose();
ball.material.dispose();
ball = createNameBall(newNames);
scene.add(ball);
}
常见于长名字或特殊字符:
javascript复制function wrapText(ctx, text, maxWidth) {
const words = text.split('');
let line = '';
let y = 0;
for (let n = 0; n < words.length; n++) {
const testLine = line + words[n];
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && n > 0) {
ctx.fillText(line, 0, y);
line = words[n];
y += 30;
} else {
line = testLine;
}
}
ctx.fillText(line, 0, y);
}
javascript复制window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
javascript复制renderer.domElement.addEventListener('touchstart', (e) => {
e.preventDefault();
startLottery();
});
这个3D圆球引擎稍加改造就能用于:
我后来给行政部做了个后台管理系统,支持:
字体选择很重要:推荐使用思源黑体等无衬线字体,避免艺术字体影响识别
物理参数调优:
防作弊设计:
javascript复制let isRolling = false;
function startLottery() {
if (isRolling) return;
isRolling = true;
// ...抽奖逻辑
}
备用方案:准备纯文本版抽奖,应对浏览器兼容问题
这个项目最终在公司年会抽奖环节大获成功,后来其他分公司也纷纷索要源码。最大的收获是认识到:技术不需要多复杂,关键是创造令人惊喜的体验。现在每次看到这个旋转的球体,都会想起当晚全场跟着倒计时的欢呼声——这大概就是程序员最幸福的时刻吧。