ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

华为云码道实战:用 Vue 3 + Three.js 实现六面祝福宫灯与 GIF 导出

华为云码道实战:用 Vue 3 + Three.js 实现六面祝福宫灯与 GIF 导出

做「月灯工坊」时,我想让中秋祝福落到一盏能转动的灯上:写一句话,换张灯纸,把灯点亮,再保存成图片或动图。我用华为云码道 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检查结果。做类似的定制作品时,这三处分别回答了文字怎么处理、画面怎么更新、成品怎么导出,源码读起来也就有了顺序。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进