资讯详情

资讯详情

React + Three.js 全栈项目复盘:把一只柴犬送上月球

最近在做的一个周末项目叫 dogonthemoon名字很直白——把一只狗送上月球。听起来像个段子但做完了再看这其实是一个相当完整的创意 Web 实验用三维渲染讲一个 30 秒的睡前小故事主角是一只捞月亮没捞着、结果被气球带上天的柴犬。整个项目跑在浏览器里打开链接就能玩不需要安装任何东西。这个项目的价值不在于狗真的上了月球而在于我把它当成一个全栈练手项目来做React Three.js 搭三维场景自写的叙事引擎驱动镜头和动画配音和背景音乐靠 Web Audio API 现场合成最后用 Docker 部署到自己的服务器上。整个过程踩了不少坑也积累了一些在教科书上看不到的实操细节这篇文章就当作一份完整的项目复盘来写。适合正在学 Three.js 想做点有意思东西的人也适合想了解一个前端项目从零到上线要经历什么的人。1. 项目整体设计与思路拆解1.1 接到一个域名怎么决定做成什么项目一开始其实只有一个域名摆在那儿dogonthemoon没有后缀、没有业务方向。拿到手里先想的不是这能做什么引流产品而是什么东西放在这个域名下最不违和。狗和月亮的组合最自然的联想有三个方向宠物电商、表情包站、儿童互动内容。我最后选了儿童互动内容理由很简单——三维场景讲故事这个方向我自己最熟而且它能把 Web 技术栈里我很想练的部分一次全用上。这个选择背后有一个判断逻辑个人项目最怕的不是做不出来而是做出来之后没有持续性动力去维护。选一个和日常生活有关联、技术上又有挑战的方向下班后才有兴趣继续写。给小朋友讲小狗登月的故事既保留了域名的趣味性又能名正言顺地引入 3D 渲染、动画编排、音频合成这些硬核话题属于典型的以兴趣驱动技术演进的选题。1.2 为什么选 React React Three Fiber 而不是纯 Three.js技术选型方面我没怎么犹豫直接选了 React Three FiberR3F。这个选择主要有三个考量。第一我需要组件化的场景组织能力。这个项目中月球、小狗、气球、星星、小屋都是独立对象如果用原生 Three.js 写所有对象都堆在同一个场景图里对象一多代码就会变得非常难维护。R3F 把 Three.js 的场景对象包装成 React 组件每个对象一个组件、自己的 props、自己的生命周期代码结构干净得几乎不需要额外注释。第二状态管理省心。R3F 天然和 React 的状态流打通我用 Zustand 管理了一个全局的故事进度状态镜头切换、角色动画、音频播放全都监听这个状态变化。如果用原生 Three.js我得自己实现事件系统或者全局回调麻烦且容易出 bug。第三社区生态成熟。drei 这个官方辅助库提供了大量开箱即用的工具组件比如OrbitControls、Stars、Text、Environment我需要的星空背景和场景环境直接用现成的组件少写了大概几百行代码。1.3 叙事结构30 秒怎么讲一个完整故事故事本身很短但短故事反而更难编排。我把它拆成了五个场景节点开场小狗蹲在屋顶上看月亮背景是星空。转折屋顶边出现一串气球小狗好奇地扑过去。升空镜头跟随小狗上升月亮越来越大。登月小狗降落在月球表面留下一个小脚印。收尾小狗坐在环形山边缘抱着月亮形状的球画面定格。每个场景都有一个镜头位、一组角色动作和一段配音。叙事引擎用数据驱动的方式把这五个场景串起来每一条故事线都是一份 JSON 配置改故事不用改代码只需要改配置。这个设计后来被证明非常值得因为我调整过至少四版节奏每次都只改 JSON不需要动任何逻辑代码。2. 技术架构与核心模块拆解2.1 目录结构一个故事项目应该怎么组织代码项目技术栈定下来之后我先把目录结构画好了。这里多花点时间想清楚后面写代码会非常顺。我的最终结构是这样的dogonthemoon/ ├── public/ │ ├── models/ # 小狗、气球的 glTF/GLB 模型 │ ├── textures/ # 月面、屋顶等贴图 │ └── audio/ # 配音片段和背景音乐 ├── src/ │ ├── scenes/ # 场景组件Moon, Rooftop, Space │ ├── models/ # 角色模型组件Dog, Balloon │ ├── engine/ # 叙事引擎核心 │ │ ├── story.ts # 故事配置的 TS 类型定义 │ │ ├── story.json # 故事数据五个场景的全部配置 │ │ └── player.ts # 场景调度器 │ ├── store/ # Zustand 状态管理 │ ├── audio/ # Web Audio 合成器和配音播放 │ ├── components/ # UI 组件 │ └── App.tsx这个结构最大的好处是把内容和引擎彻底分开。story.json是纯数据player.ts是纯逻辑scenes/和models/是纯渲染。我后来调整故事节奏、换配色、改镜头角度都没碰过引擎代码全是改 JSON 和组件的 props。2.2 叙事引擎用数据驱动的场景播放器叙事引擎是整个项目的灵魂。它的核心是一个StoryStep接口定义了单个场景需要的一切interface StoryStep { id: string; duration: number; // 本场景持续秒数 label: string; // 播报文本用于字幕显示 camera: { position: [number, number, number]; // 相机位置 target: [number, number, number]; // 相机看向的目标点 fov: number; // 可选控制取景范围 }; dogAction?: { type: idle | run | jump | float; duration?: number; }; audio?: { script: string; // 配音对应的文案 startAt: number; // 场景开始后多少秒开始播 }; overlays?: string[]; // 场景内特殊效果如文字浮现 }播放器player.ts做的事其实很简单维护一个当前索引每帧根据时间插值计算相机位置场景时间到了就触发切换。但真正写的时候有几个细节值得注意。相机运动我用了 smoothstep 插值而不是线性插值。线性插值在镜头起止时会显得非常生硬smoothstep 让镜头的加速和减速都有了观感上专业很多。插值函数我直接写在工具文件里function smoothstep(t: number): number { return t * t * (3 - 2 * t); } function lerpVector3( out: THREE.Vector3, from: THREE.Vector3, to: THREE.Vector3, t: number ): THREE.Vector3 { const s smoothstep(Math.min(1, Math.max(0, t))); return out.lerpVectors(from, to, s); }每帧在useFrame里读取当前 story step计算当前时间在场景时间轴上的进度然后更新相机位置。这套机制在桌面端跑起来非常流畅所有场景切换都是丝滑渐变。2.3 状态管理与场景调度逻辑全局状态我用了 Zustand状态结构设计得比较精简interface StoryState { phase: idle | playing | paused | ended; stepIndex: number; progress: number; // 当前场景内的进度 0~1 muted: boolean; // actions start: () void; pause: () void; resume: () void; seekToStep: (index: number) void; }这里有个我一开始没考虑到的点故事播放不允许用户手动暂停或拖动进度条因为它是线性叙事用户一旦切入中间场景音画就会错位。所以我砍掉了进度条只保留重播按钮。产品上这是一个很关键的决定——交互越少沉浸感越强。很多做互动内容的项目喜欢堆交互但儿童向的睡前故事需要的恰恰是安静地自动往下讲。2.4 音频方案用 Web Audio API 现场合成配乐和音效没有用现成 MP3而是用 Web Audio API 自己合成。这个选择一开始是因为找不到合适的免费授权音乐后来发现自合成有额外的好处——体积几乎为零而且可以做到和场景节奏严格同步。背景音乐我写了一个简单的主旋律循环基于 C 大调五声音阶听起来像八音盒。每次播放用setTimeout精确控制音符时值function playNote(ctx: AudioContext, freq: number, startAt: number, duration: number) { const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type sine; osc.frequency.value freq; osc.connect(gain); gain.connect(ctx.destination); gain.gain.setValueAtTime(0.0001, ctx.currentTime startAt); gain.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime startAt 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime startAt duration); osc.start(ctx.currentTime startAt); osc.stop(ctx.currentTime startAt duration 0.05); }五声音阶的好处是随便怎么组合都不刺耳非常适合给儿童向内容配乐。音效方面气球的噗嗤声我用噪声发生器加低通滤波合成小狗的脚步声用极短的方波脉冲模拟。虽然合成音效和录音棚录制没法比但在这个画风偏低龄卡通的项目里反而匹配。3. 场景实现一步步把狗送上月球3.1 月球场景的物理建模与贴图处理先搭月球本身。月面我一开始直接丢了一个球体加贴图结果发现一个大问题球体半径如果太小相机离表面很近的时候贴图会糊成一片半径如果太大浮点精度又会出问题相机在表面附近会抖动。经过测试我最后把球体半径定在 10 个单位细分为 64 x 64 分段贴图用的是一张 2048x2048 的月面纹理。这里要重点说一个坑Three.js 里物体表面浮点精度问题。如果你把月球的半径设为 1000相机在距离表面 1 个单位的地方移动位置向量计算结果大概是 (999.4, 2.1, -7.8)这时候浮点运算的精度损失会导致画面整体出现肉眼可见的抖动。规避方法有两个一是像我这样用小半径控制浮点范围二是用相机跟随技巧场景坐标永远以相机为原点而不是让相机在世界坐标里移动。对新手来说小半径最省心。贴图处理上我用了texture.colorSpace THREE.SRGBColorSpace来修正颜色空间否则画面会偏灰。这一步很容易被忽略很多为什么我的 Three.js 场景颜色发白的求助帖根源就是忘了设置色彩空间。3.2 星空背景几百颗星星的正确摆法星空背景我用的是 drei 的Stars组件但它默认是随机分布在一个球壳内视觉效果太均匀没有真实星空的疏密层次。我改成了自己的生成逻辑用幂律分布控制星星的半径距离让大部分星星集中在中远距离少数在近处同时给星星增加 10% 的随机闪烁动画。星星的材质我用了THREE.PointsMaterial这比给每颗星创建一个球体网格性能好得多——几百颗星用 Points 一次 draw call 就能渲染完。注意设置sizeAttenuation: false这样不管距离多远星的大小都不会变更像真实星空。3.3 小狗角色从 GLB 模型烘焙到骨骼动画小狗模型我自己在 Blender 里捏的低多边形柴犬导出为 glTF 格式.glb。这个模型本身不复杂大概 3000 个面骨骼只有 12 根——四条腿各 2 根、尾巴 2 根、耳朵 2 根、身体 1 根、头 1 根。在 R3F 里加载并控制动画的核心代码如下import { useGLTF, useAnimations } from react-three/drei; function Dog({ actionType }) { const { scene, animations } useGLTF(/models/dog.glb); const { ref, actions, names } useAnimations(animations); useEffect(() { if (!actions[actionType]) return; // 先停止当前动画再播新动画避免动作叠在一起 Object.values(actions).forEach(a a.stop()); actions[actionType].reset().fadeIn(0.3).play(); return () actions[actionType]?.fadeOut(0.3); }, [actionType, actions]); return primitive object{scene} ref{ref} scale{0.6} /; }这里有个我踩过的问题useAnimations的actions在动画切换时如果不先stop()两段动画会叠加在一起比如小狗的 idle 和 float 同时播模型就会抖成帕金森。所以我在切换之前先Object.values(actions).forEach(a a.stop())再用fadeIn做平滑过渡。小狗在升空场景中的漂浮动画没有用自动动画而是程序化控制的它的y轴位置用正弦函数做上下浮动同时身体轻微左右摇摆。这比在 Blender 里手 K 帧更灵活因为幅度和频率可以直接调参。3.4 气球升空刚体物理和绳子摆动气球组是全场最麻烦的部分。我一开始想用 Cannon.js 做物理引擎让气球真实受浮力、绳子真实摆动但后来发现完全没必要——30 秒的故事物理引擎引入的复杂度远远超过收益而且物理模拟在小移动端上很吃性能。最终方案是用假物理每个气球是一个球体网格位置由一个主控点沿抛物线轨迹运动控制气球的偏移量和摆动角度用正弦函数模拟空气阻力。三根连接小狗的绳子是THREE.CatmullRomCurve3每帧更新曲线的控制点让绳子尾部跟随小狗位置。视觉效果足够真实但代码量只有物理引擎方案的十分之一性能开销可以忽略。这段经历给我一个很大启发做产品展示类的动画假物理通常比真物理更划算。除非你需要用户交互去推撞气球否则观众根本分辨不出你是不是真做了浮力计算。3.5 镜头语言用相机轨道讲渐入佳境镜头是这个故事里最微妙的部分。看完整段故事回放你会发现镜头永远在缓慢运动没有一个镜头是静止的。开场镜头从屋顶左下方缓缓右移升空镜头是仰角 拉远登月镜头从狗的背后越过它看向月球地平线。每一个镜头切换我都至少看了二十遍回放只为了确认观众视线有没有被自然引导到下一个重点上。我的镜头路径完全写在 story.json 的 camera 字段里播放器每帧插值计算。具体的镜头参数示例{ id: takeoff, duration: 8, camera: { position: [-12, 3, 14], target: [0, 12, 0], fov: 55 }, dogAction: { type: float, duration: 8 }, audio: { script: 小狗乘着气球越飞越高月亮离它越来越近。, startAt: 1.5 } }这里我建议所有想做类似项目的人多花时间在镜头和节奏上而不是急着堆模型细节。三维场景里观感80% 来自镜头运动模型糙一点反而更像手绘风格镜头一僵大家第一反应就是这是个半成品。4. 性能优化与跨端适配实录4.1 移动端卡顿DPR 限制和 draw call 优化项目开发全程在 Mac 上跑画面流畅得让我差点忽视了移动端。等到真拿手机打开链接结果被现实狠狠教育了一顿——iPhone 上掉帧明显尤其是月球表面纹理加载完成后那几秒几乎卡到没法看。排查路径分了三步先开 Chrome DevTools 的 Performance 面板看帧时间发现渲染线程每次都要 60ms明显是 GPU 瓶颈然后逐步禁用场景对象定位到月球球体的 64 x 64 分段在移动端渲染开销巨大最后做了两个关键优化。第一个优化是限制 DPR设备像素比。移动端很多设备默认 DPR 是 2 甚至 3意味着像素数量是桌面端的 4~9 倍。我在 Canvas 上设置dpr{[1, 1.75]}让高端设备最高不超过 1.75 倍肉眼几乎看不出差别但帧率立刻回到了 60fps。第二个优化是把月球细分从 64 x 64 降到 32 x 32并开启flatShading。反正月面是岩石感不需要特别光滑的曲面32 分段已经足够细腻draw call 和顶点数都减少了一大截。4.2 WebGL 兼容性不是所有浏览器都能跑起来三维项目最容易翻车的环节是兼容性。我遇到过两种典型情况一是老版本 Safari 不支持某些 WebGL2 特性导致场景黑屏二是部分安卓自带浏览器的 WebGL 实现有 buguseTexture加载的贴图翻转方向不对月亮看起来像一张皱巴巴的纸。针对第一种情况我在入口处加了 WebGL 支持检测不支持的浏览器直接显示一张静态的封面图加提示语而不是让用户面对空白页面。检测逻辑很简单function isWebGLAvailable(): boolean { try { const canvas document.createElement(canvas); return !!(window.WebGLRenderingContext (canvas.getContext(webgl) || canvas.getContext(experimental-webgl))); } catch { return false; } }针对贴图翻转的问题我统一在useTexture之后设置texture.flipY true并且在加载阶段手工指定。这个 bug 在 iOS 和 Android 上表现不一致所以不能只靠 Three.js 默认行为必须显式设置。4.3 加载性能首屏 3 秒内必须有画面睡前故事的受众是家长和小朋友没人愿意等超过 3 秒的加载。我的资源清单包括一个 2MB 的 GLB 模型、两张 2048 贴图、若干音效总大小接近 5MB本地预览没啥感觉上线之后首屏体验非常差。优化方案是分层加载首屏只需要星空、月亮和屋顶小狗模型放到第二梯队。我用 React 的lazySuspense把模型组件拆出来配合一个简单的加载进度条界面。资源加载优先级用THREE.LoadingManager控制先加载贴图和小场景模型资源走后台 prefetch。具体代码大致是这样const Dog lazy(() import(./models/Dog)); function Scene() { const dogLoaded useStore(s s.dogLoaded); return ( Moon / Stars / Suspense fallback{null} Dog onLoaded{() useStore.setState({ dogLoaded: true })} / /Suspense {dogLoaded FloatDog /} / ); }优化后首屏资源从 5MB 降到约 1.2MB3G 网络下首屏 2 秒内能出画面月球和小狗会在之后两秒内逐步出现。因为没有进度条视觉上的空白窗口体验好了不少。4.4 音频自动播放限制的处理策略浏览器对自动播放音频的管理很严格不经过用户交互AudioContext默认是 suspended 状态。儿童睡前故事场景里用户打开页面就指望听到声音指望用户先点一下再开始播体验差。我的处理办法是做一个开始按钮点击后立刻恢复AudioContext并开始播放。但为了让这个按钮看起来不突兀我把按钮设计成了故事封面的一部分写着点击开始 小狗登月之旅点击之后不仅启动音频也让故事从头正式播放。这其实是把浏览器的限制转化成了一个产品交互体验反而更顺了。5. 部署上线与后续扩展5.1 资源路径问题部署后才发现的坑项目开发时用的 Vite 默认配置所有资源都是绝对路径/models/dog.glb。部署到自己的服务器时为了不占用根路径我把站点放在了子路径/dog下结果所有资源 404。解决方法是给 Vite 配置base: /dog/同时代码里所有useGLTF、useTexture的资源路径也要改成相对路径或基于import.meta.env.BASE_URL拼路径const assetPath (p: string) ${import.meta.env.BASE_URL}${p};这个坑太经典了几乎所有从本地预览走向线上部署的 Three.js 项目都会遇到。我的建议是从第一天写代码起就用import.meta.env.BASE_URL拼资源路径不要因为本地跑得通就用硬编码绝对路径否则部署时哭都来不及。5.2 部署方案一行 Dockerfile 搞定部署我用了 Docker NginxDockerfile 非常精简构建阶段和运行阶段分两段FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD [nginx, -g, daemon off;]Nginx 配置里唯一需要注意的就是 static 文件的缓存头。三维模型和贴图体积大且不常变给它们设置一年的强缓存首屏加载会快很多location /assets/ { expires 1y; add_header Cache-Control public, immutable; }5.3 后续扩展方向这个项目做完之后我发现它的扩展空间其实非常大。一个方向是做成系列睡前故事每种故事都沿用同一套叙事引擎只是换模型、换贴图、换音频配置。我已经验证过改 JSON 就能换故事所以内容生产方式上完全可以走批量制造路线。另一个方向是加 WebXR 支持让小狗登月变成 VR 场景。Three.js 生态里有现成的 WebXR 支持R3F 也提供了XRCanvas之类的封装把现有的相机运动换成 VR 头部跟随理论上两天能出一个 demo。这个方向比较适合需要展示效果给客户看或者做儿童教育内容的人试试。还有一个我很感兴趣的玩法用 LLM 动态生成故事文案再通过 TTS 转成语音。这样用户输入一个主题系统就自动生成一段三维动画故事——叙事引擎的数据驱动设计让这种任意故事变成了可能。不过这就牵扯到后端和内容审核短期不会动工。6. 常见问题与避坑清单6.1 高频问题速查表我把项目开发过程中遇到的最典型问题整理成了表格按症状-原因-解决方案的方式列出来方便以后做类似项目的人直接对照。症状根本原因解决方案页面黑屏且无报错WebGL 上下文创建失败或 GPU 不支持入口检测 WebGL 支持降级为静态封面月球表面相机抖动物体半径太大导致浮点精度问题缩小场景单位或相机跟随原点方案模型动画重叠抖动切换动画前未停止前一个动画切换前统一stop()所有 actions模型颜色偏灰发白未设置颜色空间贴图设置texture.colorSpace SRGBColorSpace移动端掉帧严重DPR 过高 顶点数过多Canvas 限制 DPR降低球体分段数部署后资源全部 404绝对路径与子路径部署冲突用import.meta.env.BASE_URL拼资源路径打开页面没有声音浏览器阻止未交互自动播放点击开始按钮时 resume AudioContext6.2 独家避坑经验三个最值得分享的教训第一件事是关于贴图的 Y 轴翻转。Three.js 的纹理坐标系统在不同平台上表现不一致iOS 上有些贴图会上下颠倒Android 上又正常。排查这个问题花了我整整一个晚上最后发现是flipY在不同浏览器默认值有差异。我的建议是所有 2D 贴图在加载后都显式设置一遍flipY true不要依赖默认值。虽然这看起来多写一行代码但能省掉大量跨平台排查时间。第二件事是动画淡入淡出的节奏。一开始我所有动画切换都用同一套fadeIn(0.3)后来发现升空这个动作需要 0.8 秒的淡入才有那种慢慢飘起来的感觉跑步动作只要 0.15 秒就很顺。动画过渡时长必须跟动作本身的语义匹配否则观众会觉得画面黏糊糊的。这个细节教科书不会讲纯靠一遍遍回放感受。第三件事是资源体积控制。GLB 模型导出时如果不做压缩一个低多边形模型也能轻松到 10MB 以上因为 Blender 默认会带 UV 展开图、顶点色等数据。我最后用gltf-transform这个命令行工具做了 Draco 压缩和纹理压缩模型体积从 2MB 压到 400KB观感几乎无差别。强烈建议所有用 glTF 模型的 Three.js 项目都把 Draco 压缩加进构建流程。6.3 调试工具与 Workflow 分享最后分享一下开发时的工作流。本地开发用 Vite 的 dev server配合Stats组件实时看 FPS 和 draw call 数量这个组件对性能问题定位帮助极大。模型调试场景单独开一个路由/debug放着轨道控制器可以自由旋转观察模型正式场景隐藏。调试 useState 看不太方便我用 Zustand 的subscribe在控制台打印状态变化简单粗暴但有效。代码提交方面这个项目我保持了 gitmoji 规范每个 commit 对应一个清晰的行为描述比如feat: add takeoff scene animation、fix: texture flipY on iOS Safari。个人项目虽然不需要严格规范但清晰的 commit 历史让我隔两周再回来看代码时能快速定位改动也方便未来把项目写成详细案例给合作者看。做这个项目最大的感受是一个看似搞笑的小点子只要认真拆解需求、划分模块、讲究节奏和细节就能变成一个完整且有技术深度的作品。三年前我可能觉得把狗送上月球只是个段子现在我觉得它是三维叙事、数据驱动动画和性能优化三个技能的一次综合演练。如果你手头也有一个听起来不太正经的创意我建议你别管别人怎么评价先把它做出来再说——过程中学到的那些怎么把坑填平的实操经验往往才是最值钱的部分。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →