辛辛苦苦忙了一整年,年会抽奖环节如果没把气氛顶上去,前面多少节目都白排了。市面上的通用抽奖工具不少,但要么界面土得掉渣,要么规则卡得死,放公司大屏上总差点意思。今年我们团队年会要做一个“公司团队年会抽奖工具_3D圆球效果”,把员工名字铺成一个旋转的3D圆球,滚动、加速、定格、爆出中奖名单,现场效果比普通转盘强太多。搞这套东西并不需要多深的图形学功底,但真落地时有好多细节坑,这篇文章把从需求拆解、3D球面布局、抽奖公平性到现场彩排的完整实现思路都捋一遍,给同样要忙活年会抽奖的兄弟们做个参考。
1. 项目启动前,先把需求盘明白
1.1 要做的不是一个“按钮抽奖”,而是一场视觉节目
很多人觉得抽奖工具就是个随机函数加上名单展示,按下按钮出结果,完事。但年会场景完全不是这样。台下几百双眼睛盯着大屏,抽奖环节至少持续三到五分钟,真正紧张刺激的是那个“结果出来之前”的过程。用户要的不是一瞬间出名字,而是先看到一个3D圆球在转,名字在球面上翻飞,转速越来越快,光影闪烁,最后某个人名被锁定放大,伴随全场欢呼。
所以需求拆解的第一步,不是“写个随机抽奖”,而是“设计一场三分钟的互动表演”。这块如果当面和年会筹备组沟通,基本都是这几个要求:
- 大屏端要足够炫,不能是网页里一个平面转盘。
- 操作端和展示端最好分开,别让操作界面出现在大屏上。
- 支持多轮抽奖,三等奖、二等奖、一等奖、特等奖,每轮剩余名单要自动剔除已中奖的人。
- 中奖名单要留痕,防止现场扯皮,也方便会后公示。
- 有候补机制,万一中奖人不在现场,马上能补抽一个。
这些需求看起来多,但拆开之后会发现,真正核心的只有两件事:一是3D球面渲染效果,二是多轮抽奖的状态管理。前者是面子,后者是里子,缺一不可。
1.2 技术选型:绕开那些改动成本高的方案
一开始我脑子里蹦出过几个候选方案,逐个对比之后才确定下来。先把方案盘点一下,大家以后也能少走弯路。
- 纯CSS 3D transform。用
transform: rotateY() rotateX()去转一个由多个平面卡片拼成的球体,好处是不用引任何库,原生就能跑。但坏处也明显:名字标签一多,几十上百个DOM节点同时做3D变换,大屏解析起来非常吃力;而且想给球体加发光的粒子背景、名字闪烁高亮、抽中后的飞出动画,纯CSS得写大量Hack,改到后期代码又臭又长。 - Canvas 2D 手写透视投影。适合硬核玩家,数学能力强也确实能做出来,但项目周期会拉长,调试成本高。年会系统通常提前一周才动手,时间伤不起。
- Three.js + WebGL。用了这个方案之后,球体、旋转、光照、粒子、动画全都变成常规操作。Three.js 的生态成熟,文档多,社区案例多,遇到问题搜一下基本能解决。而且最后现场只需要一台能跑WebGL的电脑,硬性要求很低。
最后选了 Three.js,并不代表它没学习成本,而是这个学习成本是一次性的,后续扩展非常舒服。年会抽奖最怕的就是彩排时发现某个效果做不出来,临时改方案,所以技术选型宁可引入一个库,也要保证效果的可控性。
2. 3D圆球效果怎么设计才不像PPT转盘
2.1 把员工名字均匀“种”在球面上
球面上均匀撒点这个事,听着基础,但做起来有讲究。最简单直观的想法是按经纬度网格排,也就是经度每隔几度放一排名字,纬度每隔几度放一列,最后拼成一个球。这方案看起来直观,实际效果却有很大问题:南北极附近空间被压缩,名字挤成一团;赤道附近又太空,稀稀拉拉不饱满。
我当时用的是斐波那契球体分布(Fibonacci Sphere),思路来自黄金角度均匀分布。对总人数 N,遍历 i = 0 到 N-1,按下面公式计算每个名字所在球面坐标:
const goldenAngle = Math.PI * (3 - Math.sqrt(5)); for (let i = 0; i < total; i++) { const y = 1 - (i / (total - 1)) * 2; // 从1到-1的线性分布 const radius = Math.sqrt(1 - y * y); const theta = goldenAngle * i; const x = Math.cos(theta) * radius; const z = Math.sin(theta) * radius; positions.push({ x, y, z }); }这个算法最核心的点是黄金角。它保证了每个点和相邻点在球面上的间距基本一致,不会出现某个方向特别密集、某个方向特别空的问题。上面代码里y不是简单从高到低排,而是让点在竖直方向上尽量均匀;theta用黄金角递增,保证在水平方向上的均匀旋转错位。连起来的效果就是,一百个人的名字会像蒲公英一样均匀分布在球面上,怎么看都顺眼。
名字生成之后,还要把每个名字变成一个可以随球体旋转的标签。这里我没有用 Three.js 里的TextGeometry,而是用 Canvas 先把名字画成一张透明底纹的纹理,再把这个纹理贴到 Thre ee 的Sprite(精灵)上。这么做的好处是名字渲染效率极高,一百个名字只是100个精灵,图形开销非常小。而且 Canvas 纹理天然支持中文,不用像 TextGeometry 那样还要异步加载字体文件。
function createNameSprite(name, color = '#ffffff') { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 256; canvas.height = 64; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = 'bold 28px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; if (name.length > 5) { name = name.slice(0, 5) + '…'; } ctx.shadowColor = color; ctx.shadowBlur = 12; ctx.fillStyle = color; ctx.fillText(name, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); const material = new THREE.SpriteMaterial({ map: texture, transparent: true }); const sprite = new THREE.Sprite(material); return sprite; }这里有个细节:名字超过5个字就截断加省略号。现场大屏的分辨率虽然高,但球体旋转起来,过长名字占的屏幕面积太多,反而看不清,截断处理之后球面显得更干净。
2.2 让球“活”起来:旋转、加速、爆出动画
球面上只挂着静态名字还不行,要让人一眼看出是个“活的”球体。Three.js 里实现旋转非常简单,每一帧把整个名字组绕 Y 轴转一个角度就行:
function animate() { requestAnimationFrame(animate); if (state === 'idle') { nameGroup.rotation.y += 0.003; } else if (state === 'spinning') { nameGroup.rotation.y += currentSpeed; currentSpeed *= 0.985; // 逐渐减速 } renderer.render(scene, camera); }初始待机状态旋转要慢,让观众看清球面上的人名;抽奖过程中要有一个加速的过程,模拟转盘越来越快的感觉。这里最忌讳的是转速突变,观众会觉得突兀。所以我在开始抽奖时把currentSpeed提到初始速度的5到8倍,然后每帧乘以一个小于1的系数慢慢衰减,让球体看起来像因为惯性自己停下来。这个细节决定了整个环节是“高级”还是“廉价”。
抽中名字之后的爆出动画我是这么设计的:球体停转后,选中的名字标签瞬间放大,颜色变黄或变金,其他整个球体整体透明度压暗,只留下高亮的那个名字,同时旁边再打上一束简单的点光。打光在 Three.js 里也很省事,给场景加一个PointLight,位置放在选中名字旁边即可。
背景部分再贴一层随机粒子的星空,加强纵深感。粒子数量建议控制在500以内,再多在大屏集成显卡上容易掉帧。
3. 前端代码怎么落:一个最小可运行Demo
3.1 工程结构和依赖
年会系统不用搞得多复杂,一个纯前端项目就够。我建议按下面结构组织文件,所有静态资源和字体一律本地化,坚决不依赖公共CDN。年会现场网络环境不一定靠谱,万一大屏电脑没外网,结果球体渲染不出来,那就当场翻车了。
year-party-lottery/ ├── index.html // 主屏展示页 ├── admin.html // 控制员页面 ├── assets/ │ ├── names.csv // 参与抽奖名单 │ ├── js/ │ │ ├── three.min.js │ │ ├── main.js │ │ └── lottery-core.js │ └── style.css依赖方面我用的是 Three.js 官方发布版本中的一个模块包,直接本地引入。没有复杂打包步骤,纯 HTML + JavaScript 就能跑,这样年会现场就算临时要改名单、改奖项,也能立刻改完刷新就生效。
3.2 球体布局与渲染代码骨架
main.js里做三件事:初始化场景和相机、读取名单生成球体、监听抽奖事件。Three.js 的固定套路不多说,直接看核心部分:
const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 0, 240); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement);renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))这行非常关键。不限制的话,在 4K 大屏上直接把像素比顶到 3 或 4,渲染一帧的时间成倍增长,球体转动就会卡。限制到2,视觉上依然非常清晰,但性能压力小很多。
名字组生成和加到场景里的代码也一并打包:
const nameGroup = new THREE.Group(); employeeNames.forEach((name, index) => { const sprite = createNameSprite(name); const pos = fibSpherePosition(index, employeeNames.length); sprite.position.set(pos.x * 80, pos.y * 80, pos.z * 80); sprite.scale.set(12, 3, 1); nameGroup.add(sprite); }); scene.add(nameGroup);球体半径设为80,是因为场景里相机放在240远处,这个半径在屏幕上看起来大小适中。名字缩放为12,3,1也对应了Canvas纹理是横向长方形的比例,保证显示出来接近正常文字的横宽比。
3.3 控制端与展示端如何做到双屏互动
年会现场最省心的玩法是:观众看到的大屏是一台电脑,操作抽奖的人在旁边拿另一台电脑或同一个浏览器窗口用小面板操作。但如果只有一台电脑且用扩展屏,就需要支持在同一台机器上开两个页面。
这里我用了浏览器自带的BroadcastChannel,在两个标签页之间传消息,不用 WebSocket、不用服务端,局域网断网也能跑。思路是这样的:
index.html是主屏展示页,只负责渲染3D球体和动画。admin.html是控制页,操作员点“开始抽奖”“结束抽奖”,选择奖项级别。- 控制页向广播频道发送
START、STOP、RESET等指令,展示页监听并执行。
// admin.html 里发送指令 const channel = new BroadcastChannel('lottery-control'); channel.postMessage({ type: 'START_SPIN' }); // index.html 里监听指令 const channel = new BroadcastChannel('lottery-control'); channel.onmessage = (event) => { if (event.data.type === 'START_SPIN') startSpin(); if (event.data.type === 'STOP_SPIN') stopSpin(); };这个方案踏实、简单、无依赖,比搞什么前后端分离、HTTP接口调用稳定得多。实际彩排时也验证了,两个标签页一开,消息秒达,几乎没有延迟。
4. 抽奖规则怎么设计才够“公平”(不让人骂)
4.1 用“密码学随机”替代普通随机
抽奖最怕被质疑暗箱操作,所以随机数的来源必须解释得清。JavaScript 里常见的是Math.random(),但严格讲它是伪随机数,分布在一定概率上不均匀,而且种子的不可预测性不如密码学安全随机数。虽然年会抽奖不至于上纲上线,但为了现场解释起来硬气,我用的是crypto.getRandomValues()生成的随机索引。
具体做法是:维护一个剩余参抽人数组,每轮从中随机取一个元素:
function pickWinnerFromRemaining(remainingList) { const randomBuffer = new Uint32Array(1); crypto.getRandomValues(randomBuffer); const index = randomBuffer[0] % remainingList.length; return remainingList[index]; }Uint32Array是32位无符号整数,最大到40多亿,用它取模剔除偏差的影响小到可以忽略,根本不用担心常见的取模均匀性问题。
4.2 多轮奖项的名单剔除与候补机制
年会抽奖不是只抽一个人,通常分好几轮。第三轮抽完的人如果又被第二轮抽到,现场就会很尴尬。所以核心逻辑是把每次中奖的人从剩余名单里删掉,下轮名单自动变短。
奖项配置我定义成一个数组:
const awardConfig = [ { id: 1, name: '三等奖', count: 5 }, { id: 2, name: '二等奖', count: 3 }, { id: 3, name: '一等奖', count: 2 }, { id: 4, name: '特等奖', count: 1 }, ]; let remainingPool = [...employeeNames]; const winnersRecord = []; function runAward(award) { const roundWinners = []; for (let i = 0; i < award.count; i++) { const winner = pickWinnerFromRemaining(remainingPool); roundWinners.push(winner); remainingPool = remainingPool.filter(name => name !== winner); } winnersRecord.push({ award: award.name, winners: roundWinners }); return roundWinners; }抽的时候有个小技巧:同一轮次的多个获奖者不要一次性全部爆出来,而是先抽出一个做动态效果,再以“名单逐个浮现”的方式公布另外几个,现场更有戏剧性。比如三等奖要抽5个人,可以先随机定为“第一个人”,等球体停,名字高亮,再快速把剩下4个名字一起展示出来。
4.3 候选人缺席和补抽的预案
现场经常出现抽中的同事请假没来,这时候必须能补抽。常规做法是把“未到场同事”从初始名单里直接过滤掉,但过滤操作最好放在入场签到结束后再做。我是让行政同事签到时在表格里勾选“到场”,然后把名单导出成names.csv上传到页面。
补抽的逻辑也简单:如果连续三次都抽到同一个人,说明名字大概率没剔除干净;所以在角色状态里加了“跳过本轮”按钮,允许主持人手动移除当前高亮的人并重新抽选。当然这个按钮只有控制页有,大屏上不会露出任何操作按钮,避免误触。
5. 现场运营那些坑(彩排与正式环节)
5.1 双屏模式和大屏适配
年会场地一般都有LED背景屏或投影幕,操作员用的是一台笔记本或一个控制台。最简单可行的方法是把两个页面分别打开:大屏电脑开index.html并进入全屏,操作员手里的笔记本或另一屏开admin.html。两个页面用BroadcastChannel通信,不需要服务器介入。
这里要提醒所有第一次做这事的同学:全屏API在某些浏览器里必须由用户点击事件触发,不能一进页面就自动全屏。所以彩排时必须演练一次“手动按F11”或“点击全屏按钮”。
另外大屏比例最好是16比9。如果现场是超宽的LED屏,可能要考虑球体居中、背景撑满的问题。我的做法是把Three.js的渲染区域固定为全屏,然后根据实际宽高比调整相机视野,宽屏时稍微加大FOV,避免球体被裁切得太多。
5.2 彩排清单:音乐、音效、字幕和备用方案
年会这种场景,音响和灯光不是我们能控制的,但抽奖环节的音乐必须提前交到音控老师手里。不然球体转得再花哨,现场安静如开会,气氛一样起不来。我的方案是在网页里直接播放一段鼓点逐渐加强的音乐文件,到公布结果时切换为激昂片段。注意浏览器可能会拦截自动播放,所以要在控制端第一次点击“开始抽奖”时顺带初始化音频上下文。
还有一个“备用方案”:单独做一个纯2D滚动名单抽奖模式。万一现场某个显卡不支持WebGL,3D球渲染不出来,至少还能用平面模式正常抽。我把它做成index.html?mode=2d的隐藏参数,真到关键时刻启动,避免全场尴尬。
5.3 正式环节的节奏把控建议
抽奖仪式不是功能代码,是现场节目,所以主持人的节奏比代码更重要。实操中我发现最顺的环节顺序是:
- 大屏先进入待机页面,球体缓慢旋转,观众能看到很多同事名字在转。
- 主持人喊口令“开始”,控制员点击按钮,球体加速旋转。
- 球体速度逐渐减慢,最终定格到一个名字。
- 名字高亮、放大,音效爆发。
- 控制员点击“公布本轮全部中奖名单”,屏幕下方滚入完整名单。
- 行政同事用手机拍照留痕。
整个环节大概三分钟,既不会太长拖沓,也足够让现场气氛热起来。
6. 常见问题与排查技巧实录
6.1 名字在球面上重叠严重怎么办
用斐波那契球体均匀分布之后,重叠仍然可能发生,原因往往不是分布算法,而是名字标签的尺寸太大。每个人名字都被缩放成12,3,1,一百个人的标签叠在一起,难免互相遮挡。解决方法是开启标签的透明和深度测试,让被挡住的标签半透明显示,产生一种嵌套的层次感,观感反而更高级。
如果特定角度下几个名字还是完全重合,可以在生成标签位置时加一个极小的随机扰动,半径方向偏移0.2到0.5个单位。这个偏移肉眼几乎看不出来,但能彻底避免完全重合。
6.2 球体旋转到某些角度时标签变得模糊
Three.js 的 Sprite 默认始终面向相机,所以正常情况下不会糊。糊的最大原因是 Canvas 纹理分辨率不够。名字纹理我用的是256乘64,如果大屏是4K分辨率,纹理放大会有轻微模糊。解决办法是单独把文字纹理提到512乘128,同时把纹理的minFilter设为THREE.LinearFilter,不要用会产生马赛克的NearestFilter。
6.3 WebGL 黑屏与显卡兼容性问题
现场如果遇到黑屏,先别慌,按这个顺序排查:
- 打开浏览器控制台,看有没有 WebGL 相关的报错。
- 如果报错说明不支持 WebGL,立刻启动2D备用模式。
- 如果 WebGL 正常但页面全黑,检查场景中相机位置和球体半径,常见问题是相机被放在了球体内部,球面把所有画面都挡死了。
- 调低背景粒子数量,老显卡上粒子过多会导致帧率极低,看起来像卡死。
这个坑我们彩排时踩过一次,当时是笔记本没有插电源,系统自动切到省电模式,集显性能骤降,球体转起来像幻灯片。后来所有彩排和正式场次都强制插电并把电源模式调到高性能,问题再没出现过。
6.4 名单导入前一定要清洗数据
最后再说一个看着小、实际杀伤力极大的坑:名单里有隐藏空行,或者末尾混进了不可见字符。之前彩排时有同事导出一份 Excel 表格,表面看没问题,结果页面上球体多了一个空名字标签,抽奖时出现一个未填姓名的占位,瞬间露怯。
解决办法是导入 CSV 后先做一次清洗,把空字符串、\r、\n、不可见空格全部过滤掉,顺手按去重逻辑把重复名字合并。这一步写在代码里,比任何口头提醒都可靠:
function cleanNames(rawList) { return [...new Set( rawList .map(n => n.trim()) .filter(n => n.length > 0) )]; }这套方案从头到尾没有引入后端服务,没有在线依赖,所有逻辑都在浏览器里跑,年会现场只要有一台能上网的电脑就行。如果你们团队也在筹备年会抽奖,强烈建议提前一天做一次完整彩排,尤其是名单导入、双屏通信、声音播放这三个最容易现场翻车的点。只要把这几件事踩顺,年会抽奖环节基本就是全场最炸的节目了。