做「月灯工坊」时,我想让中秋祝福落到一盏能转动的灯上:写一句话,换张灯纸,把灯点亮,再保存成图片或动图。我用华为云码道 CodeArts 代码智能体完成了这个纯前端项目,技术栈是 Vue 3、TypeScript、Vite 和 Three.js,打开网页就能操作,不需要登录或上传照片。
最后做出来的是下面这盏六角宫灯。右侧最多添加六条祝福,每条最多五个可见字素;左侧能拖动旋转,也有转向和缩放按钮。PNG 保存当前视角,GIF 则让整盏灯转一圈,把背面的文字也带出来。
这篇文章沿着实际开发过程,讲清楚两件事:六条文字怎样分配到六个灯面,以及浏览器怎样把 Three.js 场景导出为循环 GIF。源码和启动命令放在后面,关键实现都能在项目里找到。
先把需求缩到一盏能操作的灯
第一轮需求里,我只让码道完成写祝福、换纸色、开关灯、调整视角和导出这几件事。登录、数据库、作品广场没有放进首版,灯体和文字都由代码绘制。
我在初始 Prompt 里明确了几条能检查的要求:
- 页面打开就有完整的六角纸灯,不让用户先面对空画布。
- 文字画到灯面上,转动时跟随同一个模型。
- PNG 输出 1600×1200;GIF 输出 480×480,一圈 60 帧,每帧延时 80ms。
- 交付可运行源码、测试和说明文档,导出失败时给出提示。
这样写完,我就能逐项对照页面:换纸色是否只影响灯纸,关灯后能否看清灯体,向左转和向右转是否作用于当前视角,下载的文件是否包含实际画面。
码道第一轮交付了源码、测试与浏览器检查记录。这里的报告对应首轮版本,后面扩展六面祝福后,测试数量和输入规则都有变化。
从一面祝福,改到六面各写一句
初版只有一个输入框,最多写 12 字。宫灯已经能转,导出入口也有了,但竖排文字与纸框的搭配还需要调整。
我先反馈了三个具体问题:字体再贴近宫灯的样式,文字不要跑出纸面,GIF 生成后直接显示动图。下面这张是反馈时的会话截图。
随后又补了一条需求:既然宫灯有六面,就让每一面都能放一句不同的祝福。这是看过首版后新增的功能。最终输入规则改成了「最多六条,每条五字」,更适合在窄长灯面上竖排。
这次改动涉及的不只是输入框数量。输入清洗、灯面分配、纹理更新要一起接上,页面才能显示六句不同的话。
六条文字怎样落到六个纸面上
先处理文字,再决定位置
这条数据流分在三个地方:App.vue管输入状态,src/lib/blessing.ts管文本规则和六面布局,src/three/createLantern.ts负责把文字画到灯纸上。
normalizeBlessing()先移除换行和控制字符,再按可见字素分段,最多保留五个。这里没有直接使用字符串的length或slice(0, 5):一个组合 emoji 可能占多个 UTF-16 代码单元,直接截取可能拆开它。代码优先使用Intl.Segmenter;环境不支持时才用Array.from按 Unicode 码点降级,此时组合 emoji 的计数可能不同。
接着,computeFaceLayout()把输入整理成有效祝福,再按条数分配位置。下面是src/lib/blessing.ts中的两个关键片段:
consttexts=blessings.map((b)=>normalizeBlessing(b,MAX_BLESSING_GRAPHEMES).text).filter((t)=>t.length>0).slice(0,LANTERN_FACE_COUNT);constFACE_LAYOUT_MAP:Record<number,readonlynumber[]>={1:[0],2:[0,3],3:[0,2,4],4:[0,1,3,4],5:[0,1,2,3,4],6:[0,1,2,3,4,5],};这张表决定了少量祝福的摆放方式:两条放正面和背面,三条间隔分布,六条则铺满六面。函数最终返回固定长度为 6 的字符串数组,没有文字的面用空字符串表示,交给绘制层保留装饰纹样。
例如输入['但愿人长久', '', '千里共婵娟'],中间空项会被跳过,按两条有效祝福分配到 0、3 面。它不会因为第三个输入框有字,就把这句话固定放在 2 号面。这个规则在删除或清空某条祝福时尤其容易被忽略,所以项目为它单独写了测试。
修改文字时,重画变化的贴图
六个纸面各自持有 Canvas 和CanvasTexture。纹理附着在各自的纸面网格上,模型转动时,文字便跟着转动。
修改祝福时,setFaceTexts()会比较各面的新旧内容。下面节选的是实际更新循环:
for(leti=0;i<FACE_COUNT;i+=1){consttext=next[i]??'';if(text!==currentFaceTexts[i]){currentFaceTexts[i]=text;drawFace(faceCanvases[i],currentTone,text);faceTextures[i].needsUpdate=true;changed=true;}}drawFace()重画灯纸纹样和文字,needsUpdate通知 Three.js 更新纹理。这里保留原来的模型、材质和纹理实例,只重画内容发生变化的面。新增或删除祝福可能引起重新分配,因此一次操作也可能更新多个灯面。
输入侧还处理了中文输入法:拼音处于组合输入阶段时,先不把中间结果更新到场景;普通输入以约 100ms 合并更新。这样,文本规则、输入体验和三维绘制各有清楚的职责,后面修改五字上限或布局表也容易找到位置。
把转动的一圈导出成 GIF
用帧索引决定角度
PNG 只需要渲染当前角度,GIF 需要连续的画面。项目没有录制屏幕,而是在浏览器里逐帧渲染,再把像素交给 Worker 编码。
src/lib/gifTimeline.ts把时间轴写成了纯函数:
exportconstGIF_FRAMES=60;exportconstGIF_FRAME_DELAY_MS=80;exportconstFULL_TURN=Math.PI*2;exportfunctionframeAngle(k:number,frames:number=GIF_FRAMES):number{if(!Number.isInteger(k)||!Number.isInteger(frames)||k<0||k>=frames){thrownewRangeError(`帧索引超出范围:${String(k)}/${String(frames)}`);}return(FULL_TURN*k)/frames;}第k帧的旋转角是2π × k / 60。这里只输出第 0 到 59 帧,末帧是 354°,循环回到 0° 时仍然跨过 6°。如果额外加入一张 360° 的画面,就会与开头的 0° 重复,接头处多停留一帧。
文件里的时间轴是60 × 80ms = 4.8 秒,由帧数和帧延时确定。设备生成这些画面花多久,不会改变写入文件的时长;查看器实际播放时仍可能有自己的延时处理。
整圈共用一份调色板
灯纸、暖光和夜色背景有不少渐变。GIF 的颜色数量有限,如果每帧各自选色,相邻画面的颜色映射可能变化,转动时容易看见闪色。
当前实现先从六个均匀角度采样,各缩成 128×128,再在gifEncoder.worker.ts中合并像素,量化出一份至多 256 色的调色板。之后的 60 帧都用这份调色板做颜色映射。128×128 只用于选色,最终帧仍按 480×480 输出。
Worker 写帧时,首帧携带全局调色板,后续帧复用它:
constindexed=applyPalette(rgba,palette,'rgb565');constfirst=req.index===0;encoder.writeFrame(indexed,req.width,req.height,{palette:first?palette:undefined,delay:req.delay,repeat:req.repeat,first,transparent:false,});共享调色板能减少逐帧重新选色带来的变化,但不能让 GIF 拥有 PNG 的完整颜色。暖光渐变仍可能出现色阶,这是选择这个输出格式时要接受的取舍。
编完一帧,再生成下一帧
主线程负责设置角度和渲染,Worker 负责颜色映射与 GIF 编码。exportLanternGif.ts中每帧的顺序是:设置绝对角度,立即渲染并复制到二维 Canvas,读取 RGBA 像素,等待 Worker 确认写入,再继续下一帧。
其中的交接是await session.sendFrame(...)。内部通过postMessage的 transferable 参数转移像素缓冲区,进度也在收到写入确认后增加。
一张 480×480 的原始 RGBA 帧需要480 × 480 × 4 = 921,600字节;若一次囤下 60 张,仅原始帧就约 52.7 MiB,还没算场景、编码器和其他缓冲区。逐帧交接避免在主线程积攒整圈原始图像。这个数字是按尺寸算出的数据量,并非项目的实测内存峰值。
全部帧写完后,Worker 调用finish(),复制有效字节视图并返回主线程;主线程再封装成image/gif的 Blob,触发下载。下面是项目实际导出的动图:本次重新读取本地素材,尺寸为 480×480,共 60 帧,时间轴 4.8 秒。灯转过一圈时,可以看到不同祝福依次来到正面。
PNG 和 GIF 还需要处理导出状态
两个导出入口共用互斥控制,避免 PNG 和 GIF 同时修改同一个 Renderer。场景导出事务暂存尺寸、DPR、模型姿态和相机状态,暂停普通预览调度,再按目标尺寸渲染。采样流程结束时恢复预览,GIF 生成期间用静态画面盖住舞台,用户看到的是进度和取消入口。
PNG 路径在导出前等待本地字体,随后以 DPR=1 渲染到 1600×1200,并复制到同尺寸的二维 Canvas,再生成 PNG。这样输出像素不会随着设备屏幕倍率翻倍。
这部分值得在生成代码后单独检查。画面能成功保存一次,还不足以说明取消、重复点击和失败后重试都能正常工作。项目为导出互斥、取消、Worker 错误和场景恢复安排了测试;实际浏览器里的文件内容和恢复状态仍要亲自查看。
拉起项目,按具体结果验收
源码仓库是 dragonbady/yuedeng-gongfang。下面的会话截图保留了创建仓库和提交代码的过程。
本次复核使用 Node.js 26.3.0 和 npm 11.13.0。准备好对应环境后,在终端执行:
gitclone https://atomgit.com/dragonbady/yuedeng-gongfang.gitcdyuedeng-gongfangnpmcinpmrun dev打开终端给出的本地地址。本文核对的是2dfc7ca提交;项目文档中仍有首版「12 字、56 项测试」的旧描述,当前六面版本以源码中的五字上限和下文测试结果为准。
第一次运行,可以按这个顺序检查:
| 操作 | 应当观察什么 |
|---|---|
| 写入「但愿人长久」 | 正面文字更新,计数为 5/5 |
| 再添「千里共婵娟」 | 转到背面能看到第二句,两句没有覆盖在同一面 |
| 清空其中一条 | 有效祝福按剩余条数重新布局,空面保留装饰 |
| 换成浅杏色,再关灯 | 纸色和灯光发生变化,祝福内容保留 |
| 保存 PNG | 打开下载文件,核对 1600×1200、文字和当前视角 |
| 生成 GIF | 打开下载文件,查看不同灯面是否完整转过一圈 |
| 生成中取消后重试 | 页面恢复可操作,下一次导出能独立进行 |
表里是读者复现时的检查目标,不代表已经覆盖了所有设备。PNG 的最终文件和手机保存方式尤其要在自己的环境里打开确认。
本次重新核对了哪些结果
本次整理时,在本地2dfc7ca版本重新运行了测试、类型检查和构建。项目提供的命令如下:
npmtestnpmrun typechecknpmrun build结果是 8 个测试文件、67 项测试通过,类型检查和生产构建通过。测试中有 11 项专门检查六面布局,包含有效条数、空输入跳过、五字截取和面位分配;GIF 编码测试还会调用真实的 gifenc,再用 omggif 解码,核对帧数、尺寸、延时和循环设置。
这里也有范围区别:导出编排单测中的场景和 Worker 使用测试替身,不能当成真实浏览器里的 WebGL 验收。项目保留的 15 项 Chromium 检查记录来自此前开发阶段,脚本只操作第一条祝福,本次没有重跑;六面展示的直接证据是文中的最终截图与实际 GIF。Safari、Firefox 和真实手机体验仍需另测。
回头看这次开发,最有价值的一轮反馈是把「字看着不合适」落到文字边界、面位和预览行为上。码道能据此继续修改,我也能沿着blessing.ts、createLantern.ts和exportLanternGif.ts检查结果。做类似的定制作品时,这三处分别回答了文字怎么处理、画面怎么更新、成品怎么导出,源码读起来也就有了顺序。