2026/10/27 18:42:59

华为云码道实战:用 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×1200GIF 输出 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中的两个关键片段consttextsblessings.map((b)normalizeBlessing(b,MAX_BLESSING_GRAPHEMES).text).filter((t)t.length0).slice(0,LANTERN_FACE_COUNT);constFACE_LAYOUT_MAP:Recordnumber,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(leti0;iFACE_COUNT;i1){consttextnext[i]??;if(text!currentFaceTexts[i]){currentFaceTexts[i]text;drawFace(faceCanvases[i],currentTone,text);faceTextures[i].needsUpdatetrue;changedtrue;}}drawFace()重画灯纸纹样和文字needsUpdate通知 Three.js 更新纹理。这里保留原来的模型、材质和纹理实例只重画内容发生变化的面。新增或删除祝福可能引起重新分配因此一次操作也可能更新多个灯面。输入侧还处理了中文输入法拼音处于组合输入阶段时先不把中间结果更新到场景普通输入以约 100ms 合并更新。这样文本规则、输入体验和三维绘制各有清楚的职责后面修改五字上限或布局表也容易找到位置。把转动的一圈导出成 GIF用帧索引决定角度PNG 只需要渲染当前角度GIF 需要连续的画面。项目没有录制屏幕而是在浏览器里逐帧渲染再把像素交给 Worker 编码。src/lib/gifTimeline.ts把时间轴写成了纯函数exportconstGIF_FRAMES60;exportconstGIF_FRAME_DELAY_MS80;exportconstFULL_TURNMath.PI*2;exportfunctionframeAngle(k:number,frames:numberGIF_FRAMES):number{if(!Number.isInteger(k)||!Number.isInteger(frames)||k0||kframes){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 写帧时首帧携带全局调色板后续帧复用它constindexedapplyPalette(rgba,palette,rgb565);constfirstreq.index0;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 路径在导出前等待本地字体随后以 DPR1 渲染到 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检查结果。做类似的定制作品时这三处分别回答了文字怎么处理、画面怎么更新、成品怎么导出源码读起来也就有了顺序。