晚上十一点我窝在沙发里脚边那只狗睡得四仰八叉爪子偶尔蹬两下估计在梦里追兔子。我抬头看了一眼窗外那轮月亮脑子里突然蹦出一个没头没尾的词dogonthemoon。狗在月亮上。如果我家这位爷真被放到月球表面它会是什么姿势大概还是会四仰八叉吧。这个念头越想越好玩于是我用几个晚上的时间把它做成了一个能在浏览器里直接打开的网页动画一只狗安静地坐在月亮上背后星空缓慢眨动它自己会呼吸、会摇尾巴鼠标扫过时整个画面会像隔着舷窗看出去一样产生轻微的视差。这篇东西就是 dogonthemoon 这个项目的完整记录从灵感、画面设计到 Canvas 实现、排错优化再到后续能怎么玩。想用代码做点小创意却不知道从哪下手的朋友可以参考我的思路哪怕你不是专业前端也不是什么大工程一个晚上就能跑出能给别人看的东西。1. 从一句没用的胡思乱想到能打开的页面1.1 灵感是遛狗的时候冒出来的很多人问我 dogonthemoon 是不是有什么深意其实真没有。那天晚上遛狗狗在路灯下拖出一道被拉长的影子我抬头正好看见月亮又圆又亮脑子里忽然把这两个毫不相关的东西叠在了一起狗在月球上。这个画面一旦出现就挥之不去。我回家之后反复想如果月球上真有一只狗它会不会低头看地球会不会像大多数狗一样对脚下灰扑扑的月尘闻了又闻月球那么大一只狗那么小那种巨大的空间差异本身就带着一种又孤独又治愈的味道。当天晚上我没开电脑先把脑子里冒出来的画面记在了手机备忘录里包括三个关键词月亮、一只狗、大面积的空。第二天再看觉得这个意象值得做成点什么。1.2 为什么我坚持做成网页而不是插画或短视频其实我手里有三条路可以走画一张静态插画剪一个小视频或者用代码做一页网页动画。插画太重一张精细的图足以耗尽我一个周末短视频有制作成本而且没有交互网页动画却有几个天然优势——打开即看无需安装跨设备都能跑而且鼠标一动画面就有反应这种被作品轻轻接住的感觉是静态图和视频给不了的。从能力圈考虑我对前端和 JavaScript 熟用代码表达这个场景比用画笔更顺手。我的经验是做任何个人项目选自己最顺手的那条生产链路成功率和完成度都会高很多千万别在立项阶段就给自己制造学习成本。1.3 先定最小可行版本别一上来就想要全宇宙这种小项目最容易死在想要的太多。一开始我也列了一个夸张的愿望清单星星要有银河漩涡、月亮要有环形山纹理、狗要能逐帧行走、要加入音乐、要可以切换不同狗品种……后来我把这个清单拉回来只留了三个必须达成的要素一只会呼吸的狗、一轮有质感的月亮、一片会眨眼的星空。其他所有东西都算如果有余力再追加。最终我给自己定了一个晚上的 MVP 目标明天早上醒来这个页面必须是能打开的、能动的、不会卡成一帧一帧的。事实证明这种克制是对的。我做完 MVP 之后才陆续加入视差、点击回应、移动端适配每一层都有上一版作为地基不会出现做了一半发现基础架构支撑不住的推倒重来。2. 画面设计月亮上的狗不该只是搞笑表情包2.1 先定情绪基调孤独和陪伴的平衡动手写代码之前我花了比预期更长的时间想一个问题这幅画面要让看的人产生什么感觉如果把狗放在月亮上搞成搞笑表情包画一只张嘴傻乐、眼睛瞪得像铜铃的狗也能逗人一笑但那没有后劲。我更想要的感觉是孤独里有一点温暖月亮是冷的太空是无限空旷的但那只狗坐在那里像是在等你尾巴尖轻轻晃一下传递出嘿我在这儿呢的信号。这个基调定下来之后所有的视觉决策都有了判断标准。星空不能像节日的彩灯一样闪得眼花缭乱要有耐心月面不能是刺眼的白色要带一点灰暖狗的轮廓不能太卡通要有点毛茸茸的厚度感。2.2 图层拆解一个假场景也需要真实深度页面看起来是一张平面图但我从一开始就按伪 3D的思路拆图层。最终我把场景分成六层由远到近依次是层级内容视差系数L1远处小星群稀疏、闪烁慢8L2近处亮星稍大、闪烁快16L3月亮主体大圆盘 环形山纹理30L4狗与它脚下的月面局部50L5前景月尘微粒浮动、最受鼠标影响70每一层的视差系数不一样鼠标移动时远处的星星移动得慢近处的狗移动得快画面自然产生立体感。这个思路和三维场景里把摄像机左右晃动的效果是同一套原理只是我把几何透视简化成了简单的位移偏移。2.3 配色与光影深空不能全黑月亮也不能惨白配色是我花时间最多的部分。星空基础色如果用纯黑#000000画面上所有的颜色都会被衬得很脏而且大面积的死黑会让页面显得廉价。我最后用的是从顶部#05060f到中下部#141b3d的深蓝紫渐变既有太空的幽深又保留了一点空气感。月亮颜色是#e8e0d0到#b8ad9a的暖灰渐变表面再叠加几个半透明的环形山圆斑。很多人习惯把月亮画成惨白真实感反而弱带一点暖灰的月亮才像一个有质量的球体而不是一张贴纸。光影逻辑上我设定主光源来自左上方所以月面高光在左上狗的影子偏右下狗的毛色是焦糖色#c96f3a左肩加一条淡淡的亮边当轮廓光。这一条亮边特别重要能让狗的轮廓从深色背景里跳出来不然深色环境下整只狗会糊成一团。2.4 构图与留白把空当成一种设计元素构图我按传统三分法处理狗放在画面右下三分之一交叉点的位置月亮作为背景元素压在左上方右上大片区域留给稀疏的星星。这个构图和最终的情绪基调直接相关——狗在角落大部分画面是空旷的观者的视线会先落在那片空上再慢慢滑到小小的狗身上孤独感就出来了。留白不是什么都不放而是要控制信息密度。我后期反复删减星星的数量从最初的三百多颗降到一百六十颗左右。星星太多、太均匀天空就会变成一块噪点墙反而没有星空的呼吸感。3. 用 Canvas 把这套想法画出来核心实现思路3.1 为什么用 Canvas 而不是 CSS 动画这个决定我做得很快。虽然 CSS 动画写起来直观但在 dogonthemoon 的场景里它有几处明显短板对比维度CSS 动画Canvas星星数量几百颗随机分布每个星星要挂一个 DOM/SVG 节点几百个节点让页面变重一次绘制几百个圆点毫无压力闪烁相位随机控制需要为每个节点单独定义关键帧和延迟每帧用正弦函数算透明度统一管理视差多层位移需要 transform 联动层多时逻辑分散帧循环里统一算偏移量边缘光、粒子、光晕能力有限需要叠加多个元素像素级自由发挥我最终选择了 Canvas把所有图层交给一个主循环统一调度。这不是说 CSS 动画一无是处——做按钮悬停、菜单展开这类 UI 动效CSS 反而更省事。但 dogonthemoon 本质上是一个实时渲染的微型场景Canvas 才是为它量身定做的画布。3.2 主循环与分层渲染整个页面的核心是一个requestAnimationFrame驱动的帧循环。每一帧里依次执行绘制天空、绘制月亮、绘制狗、绘制前景月尘。我把每层封装成独立的绘制函数这样修改任何一层都不会牵动其他层。let lastTime 0; function frame(time) { // 防止切后台回来之后 dt 过大导致画面乱跳 const dt time - lastTime; if (dt 100) lastTime time; // 清空画布不用 clearRect 之外的方式避免残影 ctx.clearRect(0, 0, canvas.width, canvas.height); // 四层依次绘制 drawStars(time); drawMoon(offsetMoon); drawDog(time, offsetDog); drawDust(time); lastTime time; requestAnimationFrame(frame); } requestAnimationFrame(frame);有一个细节值得注意dt防抖。当浏览器标签页切到后台再切回来时requestAnimationFrame会停住恢复后的第一帧time与lastTime的差值可能很大。如果不拦一下动画会突然往前跳一大截。用dt 100直接放弃这一帧是成本最低的处理方案。3.3 星空与闪烁预渲染发光点别硬画光晕星星闪烁是最出效果也最容易翻车的部分。我第一版实现里每个星星都靠ctx.shadowBlur来画光晕结果动画肉眼可见地卡顿。原因后面专门讲这里先给最终做法。最终方案是预渲染一个 16×16 的发光圆点图放进离屏画布然后每帧只是把这个小图drawImage到对应位置再叠一个由正弦函数控制的全局透明度// 预渲染一次之后每帧复用 const glowCanvas document.createElement(canvas); glowCanvas.width 16; glowCanvas.height 16; const glowCtx glowCanvas.getContext(2d); const glow glowCtx.createRadialGradient(8, 8, 0, 8, 8, 8); glow.addColorStop(0, rgba(255,255,255,1)); glow.addColorStop(0.4, rgba(255,255,255,0.6)); glow.addColorStop(1, rgba(255,255,255,0)); glowCtx.fillStyle glow; glowCtx.fillRect(0, 0, 16, 16); // 帧循环里每颗星星只做一件事 function drawStars(time) { for (const star of stars) { const alpha 0.4 0.6 * (0.5 0.5 * Math.sin(time * star.speed star.phase)); ctx.globalAlpha alpha; const size star.r * 4; ctx.drawImage(glowCanvas, star.x - size / 2, star.y - size / 2, size, size); } ctx.globalAlpha 1; }星星的坐标在初始化时随机生成闪烁速度随机、相位随机这样每颗星都各眨各的眼不会出现整齐划一的眨眼队列。用正弦函数的0.5 0.5 * sin()把透明度限制在 0 到 1 之间亮度变化自然平滑。3.4 月亮与狗狗离屏缓存避免每帧重绘月亮和狗是场景里结构最复杂的两个对象如果每帧都从头计算几十条贝塞尔曲线性能肯定吃不消。我的做法是把月亮和狗分别绘制到两个离屏画布上只画一次帧循环里直接用drawImage贴上去再叠加呼吸和摇尾巴的变形。狗狗的呼吸效果不是真的一点点重画而是在贴图时用ctx.transform做微小的缩放偏移锚点设在狗腹部中心。呼吸的本质是一个低频正弦波function drawDog(time) { const breathe Math.sin(time * 0.002) * 3; // 慢速起伏 const wag Math.sin(time * 0.006) * 8; // 尾巴摆动 ctx.save(); // 平移到锚点缩放再平移回去 ctx.translate(dogX, dogY); ctx.scale(1, 1 breathe / 400); ctx.translate(-dogX, -dogY); ctx.drawImage(dogOffscreen, dogX - 80, dogY - 90, 160, 180); ctx.restore(); }尾巴摆动我单独处理狗的离屏画布里尾巴是完整形态但我在主画布上用一个扇形裁切区域只将尾巴那一片单独旋转。这种做法省掉了重绘整个身体的开销看起来又足够自然。3.5 视差交互鼠标位置转化为层偏移视差的实现极其简单但效果立竿见影。我监听鼠标位置把坐标归一化到 -1 到 1 之间再乘以每层的视差系数window.addEventListener(mousemove, (e) { mouseX (e.clientX / window.innerWidth - 0.5) * 2; mouseY (e.clientY / window.innerHeight - 0.5) * 2; }); // 帧循环里 const offsetMoonX mouseX * 30; const offsetMoonY mouseY * 20; const offsetDogX mouseX * 50; const offsetDogY mouseY * 35;数值上远处的星星偏移量小近处的狗偏移量大这个层次差异正是上一章提到的图层表格落地的地方。要注意一点视差偏移不要超出画布边界太多否则会露出画布边缘的空白我最后给每个图层都加了Math.max()Math.min()限制并让背景渐变宽出画布一圈杜绝穿帮。4. 排错与优化这几个坑我翻了一晚上4.1 星星一多就卡罪魁祸首是 shadowBlur这是我踩过最大的坑。最初版本的星星绘制代码长这样ctx.save(); ctx.shadowColor #fff; ctx.shadowBlur 8; // 看似温柔的光晕实则是性能黑洞 ctx.fillStyle rgba(255,255,255,${alpha}); ctx.beginPath(); ctx.arc(x, y, star.r, 0, Math.PI * 2); ctx.fill(); ctx.restore();在只有二三十颗星星时完全没问题但一旦星星数量超过一百每一帧都要执行一百多次「设置阴影 → 计算光晕 → 绘制」的流程shadowBlur的开销是指数级别的。页面从流畅滑入掉帧动画变成幻灯片。排查过程很标准先把层一层一层注释掉发现星星层是瓶颈再二分化代码把shadowBlur去掉之后瞬间流畅。原理上shadowBlur需要对每个像素做高斯模糊卷积运算逐帧乘以上百个圆形路径任何浏览器都会吃不消。所以最终方案才改成「预渲染发光点 drawImage 复用」绘制成本从几百次模糊计算降为几百次贴图操作流畅度判若两页。4.2 高分屏上变成马赛克必须处理像素比项目第一次发给朋友看他回我一句画面有点糊。原因很经典Canvas 的逻辑分辨率小于物理分辨率。在主流高分屏上屏幕一个 CSS 像素对应 2 到 3 个物理像素Canvas 默认按 CSS 像素绘制导致画面被拉伸后模糊。解决办法是让 Canvas 的位图尺寸乘以devicePixelRatioconst dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系放大到物理像素尺度之后所有绘制代码仍然按 CSS 像素的坐标来写不用改动别处。处理完这个之后月亮边缘和狗的毛发光感立刻锐利起来。注意在窗口尺寸变化时重新执行这段逻辑否则重新调整窗口大小后画面会保持旧尺寸。4.3 月面边缘锯齿与色彩断层月亮是径向渐变画出来的大圆最初边缘总有一圈淡淡的锯齿拉近距离看得相当难受。我做了两层补救第一把月亮本身绘制到离屏画布时把半径比主画布需要的稍微多画 1 像素再使用ctx.imageSmoothingEnabled true第二在月面渐变中间多增加几个色标让颜色过渡更缓避免大面积渐变产生的色彩断层——那种一圈一圈肉眼可见的色带。色彩断层的本质是渐变值在 8 位色深下被量化跨度太大、梯度太陡就会出现分带。我的修法是让渐变从#e8e0d0到#c9bda8到#b8ad9a多一层中间色过渡立刻柔和。另外我给月面叠了一层很淡的低频噪点用肉眼几乎看不出来但能有效打散分带感。4.4 移动端与低性能设备的降级桌面端跑顺了之后我用手机打开页面发现两个问题一是触碰屏幕没有视差交互画面死板二是显存和 CPU 比不上电脑星星数量太多时发热明显。我做的降级策略很简单粗暴三件事触发条件降级动作屏幕宽度 768px星星数量从 160 降到 80关闭前景月尘是触屏设备视差改为跟随手指拖动而不是鼠标系统开启减少动态效果直接绘制静态场景星星不闪烁、狗不呼吸最后一条我用window.matchMedia((prefers-reduced-motion: reduce))判断这个 API 专门用来响应系统的无障碍动画偏好。尊重用户对减少动态效果的需求不仅是一种优化也是一种基本的产品礼貌。移动端的视差交互则改成了touchmove事件手指滑动时图层照常偏移放下手指就缓慢回弹手感还算自然。5. 让 dogonthemoon 继续长大交互、变体与三条经验5.1 点击狗狗会回应在动画里埋一颗彩蛋做到能跑、能看、不卡之后我开始琢磨能不能玩。给狗加了一个点击事件点击它的时候尾巴摆动速度加快狗脚下泛起一圈渐变的声波圆环像它在月亮上轻轻汪了一声但因为没空气声音传不出去只能用视觉代替。这圈声波圆环实现起来不难在帧循环里维护一个数组记录每次点击的坐标和时间然后绘制半径随时间扩张、透明度随时间减弱的圆ripples.push({ x: dogX, y: dogY, born: time }); // 帧循环中 ripples ripples.filter(r time - r.born 1200); for (const r of ripples) { const p (time - r.born) / 1200; // 0→1 ctx.beginPath(); ctx.arc(r.x, r.y, p * 120, 0, Math.PI * 2); ctx.strokeStyle rgba(255, 255, 255, ${1 - p}); ctx.lineWidth 2; ctx.stroke(); }这个彩蛋不复杂但它让整个页面从展示型变成了回应型给观看者一个留下来的理由。我后来让它做了好几稿圈太多会眼花一圈刚好。5.2 变体玩法把月犬做成一套小宇宙核心场景稳定后我开始想它的变体。dogonthemoon 这个名字本身就自带扩展空间不同品种的狗坐在不同星球上会是什么气质柯基在火星上柴犬在水星上边牧悬在土星光环里……我把狗的毛色、星球配色、背景星空的颜色尽量参数化抽成一个配置对象改几行就能生成一个全新的星球犬。这种参数化的想法我强烈建议做类似玩法的朋友尽早落地。不要把颜色、形状、尺寸写死在绘制函数里而是抽成变量。后期想批量生成变体、做一个小画廊页面时你会发现这个抽象带来的收益远超当初那半小时的改造时间。我还考虑过加 Web Audio 合成一段空灵的背景音用几个正弦波叠加出缓慢的氛围音不需要任何音频素材纯代码就能生成。这个功能最终没有加入主版本因为觉得无声音游戏的静默反而更贴合月球的真空感但作为实验挺好玩的。5.3 做完这个小项目我最大的三条体会第一边界先行。没有边界的创意项目一定会烂尾。dogonthemoon 能在一个晚上跑出原型是因为我把做完定义得很小小到不会有任何借口拖延。第二性能问题不要在早期过度操心但要在出现征兆时立刻定位。星星卡顿那晚我一度想放弃 Canvas 改用 WebGL后来发现只是shadowBlur滥用。大部分性能问题不是方案错了而是工具用重的了。先跑起来再根据实际表现做针对性的优化比一开始就大炮打蚊子高效得多。第三把灵感记录下来是最划算的习惯。那个遛狗晚上的念头如果我不在备忘录里写下来第二天就彻底消失了。做这类小项目灵感本身没有那么重要重要的是愿不愿意把它从脑子里搬到纸上、再到代码里。dogonthemoon 最后并不算一个多复杂的作品但它完整地跑完了从灵感到交付的全过程这比任何单点技术突破都更有价值。做完这个项目之后我现在每天打开电脑都会顺手看一眼那个页面。一只狗坐在月亮上安静地呼吸、摇尾巴有时候我在想它那个尾巴尖轻轻晃动的弧度和楼下那只傻狗睡觉时蹬腿的频率是不是其实来自同一种安心。如果你也想做点什么没用的但很喜欢的东西别犹豫就从今晚开始把它做出来。
阅读完成 · 觉得有帮助?