ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用Three.js打造太阳系:从零掌握3D场景与层级动画

2026/9/18 5:07:52 拓冰建站 浏览量
用Three.js打造太阳系:从零掌握3D场景与层级动画 做前端的朋友聊起三维可视化十个里有八个第一课都是 Three.js。我当初入坑 3D挑的第一个练手项目就是太阳系星球自转公转——场景直观上手快又能把 Three.js 里的场景、相机、坐标系、光照、动画循环整套串起来。这篇文章就把我搭这套太阳系时用到的思路、参数缩放逻辑和踩过的坑原原本本整理一遍给同样想从零开始学 Three.js 的读者做个参考。别被“天文级”的项目名吓到它其实是 Three.js 学习路径里性价比极高的一课不依赖外部素材只靠一组几何体、几盏灯和两行旋转代码就能把 3D 最核心的“变换与层级”练得明明白白。1. 为什么用太阳系练手从第一个 3D 场景到工作场景1.1 Three.js 学什么太阳系就能练什么不管你想做产品展示、数据可视化还是后面往数字孪生方向走Three.js 始终绕不开几件事场景管理、对象变换、相机投影、光照材质、动画循环。这几个概念单看教程很容易散真到写项目的时候又不知道谁和谁搭配。太阳系这个项目的好在于它把所有概念都串在了同一个画面里。公转是绕父对象的旋转自转是绕自身轴的旋转轨道半径是 position 的偏移星球大小是 scale 的体现画一条轨道线则要自己通过数学计算把点放进场景。可以说太阳系是一个天然的教学样例每个知识点都有明确的视觉反馈写错一行代码画面立刻表现出来不存在“代码跑通了但看不出效果”的情况。而且这个项目对美术资源几乎零依赖。不需要设计师出模型不需要找贴图纯代码就能完成。只要有 JavaScript 基础再对三维空间里的向量、矩阵有一点点直觉就能动手做。我见过不少刚接触 Three.js 的同事第一个完整跑起来的 demo 都是类似结构的天体模拟原因很简单它足够小小到半天能出成果又足够完整完整到能覆盖 Three.js 入门的主干。1.2 从太阳系到数字孪生的能力迁移很多人会觉得太阳系动画只是个玩具和工作里的“工业数字孪生”差得远。但从技术底层看两者几乎是同一个东西。Three.js 的核心是场景树也就是 Object3D 的层级嵌套。一个太阳系里“太阳组”下面挂着“地球公转组”“地球公转组”下面挂着“地球自转体”和“月球公转组”。这种嵌套关系放到工业数字孪生里就是一个工厂组下面挂设备组设备组下挂机械臂基座组基座组再挂大臂组、小臂组、末端组。你让地球绕太阳转时用的那套“旋转父层级、子层级跟着动”的逻辑和让机械臂末端的焊枪按轨迹运动完全一样。再往大了说太阳系动画里的“时间驱动”概念在数字孪生项目里也非常重要。很多数字孪生系统不是静态展示模型而是要通过实时数据驱动设备动作。你在太阳系里学会用 clock 对象做帧率无关的动画后面接传感器数据、接历史数据回放思路是通的。所以我一直建议别小看这个练手项目它其实是通往更大场景的一把钥匙。2. 核心实现思路三个对象模型决定整个项目骨架2.1 场景、相机、渲染器Three.js 的“铁三角”开始写代码前先把 Three.js 最基础的“铁三角”理清楚。Scene 是舞台所有物体都放在里面Camera 是观众的眼睛决定你从哪个角度看舞台Renderer 则是摄影师和放映机负责把舞台上的画面渲染到浏览器里。太阳系项目里我一般这么初始化const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.set(0, 30, 45); camera.lookAt(0, 0, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement);这里面有两个参数最容易踩坑。一个是相机的 near 和 far它们决定近裁剪面和远裁剪面之间的距离。太阳系场景轨道半径一旦放大far 太小就会看到星球莫名其妙消失一个是 setPixelRatio如果不限制像素比高分屏上会卡显存也吃得多。把这两点处理好后面能省掉好多排查时间。另一个容易被忽略的是 antialias 参数。WebGLRenderer 创建时设置 antialias: true可以让边缘更平滑尤其是圆形轨道线效果差距很明显。2.2 Group 嵌套自转和公转的本质是层级关系实现自转公转最容易想到的方法是在动画循环里用三角函数算位置earth.position.x orbitRadius * Math.cos(angle); earth.position.z orbitRadius * Math.sin(angle);这个写法没有错但一旦要加月球、加多颗行星、再调整轨道倾角代码会迅速变得混乱。更优雅、也是 Three.js 官方推荐的做法是用 Group 做层级嵌套。const sun createPlanet(5, sunMaterial); scene.add(sun); const earthOrbitGroup new THREE.Group(); scene.add(earthOrbitGroup); const earth createPlanet(1, earthMaterial); earth.position.x 15; earthOrbitGroup.add(earth);此时如果旋转 earthOrbitGroup 的 rotation.y那么 earth 就会绕场景中心公转而 earth 自身再旋转 rotation.y就是自转。自转公转被拆到两个不同层级的对象上互不干扰。想加月球只需要再建一个月球公转组挂到 earth 上月球放在这个组里的 offset 位置。这个“父层级旋转子层级跟着动”的机制是 3D 开发里最值得花时间理解的部分。做太阳系的时候觉得简单但到了机械臂动画、数字孪生设备联动你会感谢当初把这个结构理清楚了的自己。2.3 轨道平面与坐标约定太阳系建模的关键几何设定Three.js 默认使用右手坐标系Y 轴向上XZ 平面是水平面。太阳系建模时一般把轨道放在 XZ 平面上也就是 Y 轴表示“高度”。这样从屏幕外部看就是一个从上方俯视银河系的视角。公转方向我统一使用 Y 轴正向角度增加的方向也就是逆时针方向。从北极俯视太阳系行星的公转方向恰好也是逆时针所以 rotation.y 增量取正值即可。为了让视觉效果更直观我习惯把相机初始位置放在 Y 轴正上方偏一点既能俯视整个轨道又能看到行星的立体感。如果想要一个“完全俯视”的视角可以直接把相机放在 (0, 40, 0)lookAt 原点。不同视角适合不同演示需求没标准答案但至少要把坐标系约定好别一会儿 X 当轨道面一会儿 Y 当轨道面否则后期加代码很容易晕。3. 手写第一个太阳系从白屏到 8 颗行星转起来3.1 环境搭建拿到 Three.js 的三种方式拿到 Three.js 有三种常用方式。第一种是 npm 安装适合正经工程项目npm install three然后在代码里导入import * as THREE from three;第二种是 CDN 引入适合快速写 demo 或做在线示例。新版本 Three.js 推荐使用 ES Module 方式script typemodule import * as THREE from https://unpkg.com/three/build/three.module.js; /script第三种是直接去 GitHub 仓库下载整个项目主要是为了参考官方示例。这个方式比较适合离线环境但要注意新版 Three.js 的模块化程度很高直接双击 index.html 用 file:// 协议打开很多资源会因为跨域限制加载失败。我建议无论如何都起一个本地静态服务比如用 VS Code 的 Live Server或者 npx serve能省掉大量 CORS 问题。版本选择方面我推荐直接用 r150 以上的版本。旧版本里的 Geometry 和 BufferGeometry 区分在新版已经被统一学新不学旧少踩兼容性的坑。3.2 创建太阳、行星与轨道线材质、网格、半径取舍初始化完铁三角接下来就是往场景里放天体。太阳可以用 MeshBasicMaterial因为它本身是光源不需要依赖其他灯光也能被看见行星则建议用 MeshStandardMaterial这样光照效果更真实明暗面分明。创建球体的代码很简单const sunGeometry new THREE.SphereGeometry(5, 32, 32); const sunMaterial new THREE.MeshBasicMaterial({ color: 0xffaa00 }); const sun new THREE.Mesh(sunGeometry, sunMaterial); scene.add(sun);SphereGeometry 的第二个和第三个参数是分段数。分段数越高球体越圆但顶点越多。太阳系里球体本来就小32 乘 32 就够用了没必要开到 64否则纯属浪费性能。轨道线可以用 LineLoop 画一个圆环。方法是把圆周上的点取出来组成一个 BufferGeometryconst points []; for (let i 0; i 128; i) { const angle (i / 128) * Math.PI * 2; points.push(new THREE.Vector3(orbitRadius * Math.cos(angle), 0, orbitRadius * Math.sin(angle))); } const orbitGeometry new THREE.BufferGeometry().setFromPoints(points); const orbitMaterial new THREE.LineBasicMaterial({ color: 0x666688, transparent: true, opacity: 0.4 }); const orbitLine new THREE.LineLoop(orbitGeometry, orbitMaterial); scene.add(orbitLine);这里有一个实用经验轨道线透明度不要设太高颜色也不要太亮。它只是辅助视觉的参考线太显眼会干扰主视觉。真正做项目时这类辅助线通常做成可开关的图层。3.3 用 clock 控制自转公转角速度与真实周期的换算动画循环用 requestAnimationFrame但每一帧间隔的时间不固定。如果每帧直接加一个固定角度帧率高的电脑上星球转得快帧率低的电脑上转得慢。正确做法是用 Three.js 自带的 Clock 对象拿到两帧之间的时间差 delta。const clock new THREE.Clock(); function animate() { const delta clock.getDelta(); const elapsed clock.getElapsedTime(); updatePlanets(delta, elapsed); renderer.render(scene, camera); requestAnimationFrame(animate); }更新行星的角速度需要换算。角速度 omega 等于 2π 除以周期 Tomega 2 * Math.PI / T; // T 的单位是秒如果想让地球公转一圈用 60 秒那么地球公转的角速度就是 2π / 60 弧度每秒每帧根据 delta 累加即可。为了让体系更真实一点可以先定义每个行星的真实公转周期天再定义一个“时间加速倍数”。比如真实地球公转周期是 365.25 天木星是 4333 天。如果我希望地球在画面里 20 秒转一圈那么木星理论上应该用 20 乘以 4333 除以 365.25约 237 秒转一圈。这样做出的动画速度比例是符合真实行星关系的视觉上很舒服。自转同理。地球自转周期是 1 天木星约 0.41 天。在同样的时间比例下木星应该转得比地球快。把自转周期和公转周期分别配置到数据数组里再用循环统一更新代码会非常简洁。3.4 让画面更完整星空背景、轴辅助、观察控制器纯黑背景虽然不报错但看起来太寂寞。最省事的方式是给 scene.background 设置深蓝色再叠加一层星星粒子。星星粒子本质是一堆随机位置的点const starGeometry new THREE.BufferGeometry(); const starCount 2000; const positions new Float32Array(starCount * 3); for (let i 0; i starCount * 3; i 3) { positions[i] (Math.random() - 0.5) * 200; positions[i 1] (Math.random() - 0.5) * 200; positions[i 2] (Math.random() - 0.5) * 200; } starGeometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); const stars new THREE.Points(starGeometry, new THREE.PointsMaterial({ color: 0xffffff, size: 0.2 })); scene.add(stars);粒子数量控制在 2000 以内性能压力很小效果却很明显。如果想更省事也可以直接下载星空贴图当成 scene.background 的背景图片但粒子方式完全不需要外部资源推荐先这么做。调试阶段可以顺手加两个辅助工具。一个是 AxesHelper能显示出坐标轴方向方便确认轨道面在哪个平面另一个是 OrbitControls实现鼠标拖拽旋转视角。OrbitControls 不是 Three.js 核心库的一部分需要从 examples 里导入import { OrbitControls } from three/addons/controls/OrbitControls.js;装上之后你就能从任意角度欣赏这个太阳系排查问题也方便很多。4. 参数缩放与真实感优化物理正确和视觉好看怎么平衡4.1 行星大小、轨道半径、周期的合理压缩按真实比例建模的第一个问题就是行星完全看不见。太阳直径约 139 万公里地球直径约 1.27 万公里相差约 109 倍轨道半径更是动辄几千万公里级。如果严格等比缩放屏幕上只能看到一团巨大的太阳地球小到只有一个像素。所以太阳系项目里我们做的是“视觉等比”而不是“真实等比”。我的做法是先固定太阳半径为一个舒服的数值比如 5地球半径设为 1其他行星按视觉差异去调。轨道半径按递增数列拉开距离比如水星 6、金星 9、地球 13、火星 18、木星 25、土星 34、天王星 45、海王星 55。这样整个场景张弛有度。周期可以不完全按照真实精确值但最好符合大趋势。木星公转周期比地球长海王星更长。如果想要更接近真实规律可以借用开普勒第三定律的思想让公转周期约等于轨道半径的 1.5 次方再乘一个系数。这样轨道越远转得越慢视觉上非常自然。4.2 光照策略点光源、环境光和阴影的配合太阳系里的光来自太阳所以在太阳位置放一盏 PointLightconst pointLight new THREE.PointLight(0xffffff, 2, 100); pointLight.position.set(0, 0, 0); scene.add(pointLight);只放点光源的话行星背向太阳的一面会完全漆黑。为了避免太“恐怖”再加一盏 AmbientLight或者用 HemisphereLightconst ambientLight new THREE.AmbientLight(0x404060); scene.add(ambientLight);环境光强度不要太高否则会冲淡点光源的明暗对比。太阳的材质用 MeshBasicMaterial它不受灯光影响永远保持明亮的颜色行星用 MeshStandardMaterial会正常响应灯光。阴影方面太阳系没有地面接收阴影所以做不做意义不大。但如果想练手可以开启 renderer.shadowMap.enabled给行星设置 castShadow true再随便加一个平面作为接收体。这个练习对后面做产品展示、数字孪生场景很有帮助。4.3 贴图与材质让星球不再是纯色球体纯色球体虽然能演示运动但观感一般。想让画面好看最简单的办法是给行星贴图。Three.js 自带 TextureLoader只需要准备一张地球纹理图const textureLoader new THREE.TextureLoader(); const earthTexture textureLoader.load(textures/earth.jpg); const earthMaterial new THREE.MeshStandardMaterial({ map: earthTexture, roughness: 0.8, metalness: 0.2, });注意贴图文件的加载路径。如果使用本地图片同样会受 CORS 限制所以建议放到 assets 目录下并用本地服务打开页面。没有贴图资源时也可以通过 CanvasTexture 生成程序化纹理比如用 Canvas 画几条色带模拟木星条纹。这个方法不依赖外部资源而且自由度更高适合当练习。太阳的材质如果不想太单调可以加上 emissive 发光属性模拟表面热辐射。用 ShaderMaterial 做动态流动的太阳表面效果就更高级了但这个属于进阶内容初学者可以先跳过。5. 实操中高频问题与排查我把踩过的坑都记在这了5.1 常见问题速查表我整理了一份太阳系项目里最常见的故障表按出现频率排的现象最常见原因解决办法场景空白什么都没有相机位置不对或 far 太小检查 camera.position增大 far 到 500 以上行星绕自身转没有绕太阳Group 层级没建好直接改子对象位置新建公转组把行星挂到组下旋转公转组动画时快时慢每帧加固定角度没有用 delta使用 Clock.getDelta() 计算增量行星太暗或太黑环境光太弱或材质用了不受光的类型增加 AmbientLight检查材质类型阴影出不来shadowMap 没开启castShadow 没设renderer.shadowMap.enabled true灯光和物体都设 castShadow高分屏上卡顿没有限制 pixelRatiorenderer.setPixelRatio(Math.min(devicePixelRatio, 2))本地文件打开报跨域错file:// 协议下的模块加载限制起本地静态服务5.2 排查思路详解层级问题是最容易跳进去的坑。如果你发现行星在绕自身中心旋转而不是绕太阳旋转先别急着改代码打开浏览器控制台把 scene 的 children 打印出来看看行星是不是真的挂在公转组下面。我遇到过一个情况地球确实放在公转组里但因为公转组没有加入 scene而是加到了别的地方导致地球满屏乱飞。这种问题靠看代码不容易发现直接用控制台看对象树最快。delta time 的问题不太容易看但现象很典型在 60Hz 显示器上一切正常换到 144Hz 就“飞”了。解决方式是强制使用 clock.getDelta()并且不要在回调函数里直接写rotation.y 0.01这样的硬编码。阴影问题也很有意思。很多人在场景里加了 PointLight 和 MeshStandardMaterial发现没有阴影原因往往是 renderer.shadowMap.enabled 没有开启。这个开关默认是 false忘了开就会掉进“多重条件不满足”的排查泥潭。建议初始化渲染器时就把 shadowMap 开好后面再做实验。另一个容易被忽略的点是轨道线段数。LineLoop 的采样点如果太少圆形轨道会变成多边形但如果每根轨道都采样 1024 个点8 根轨道就是 8000 多个点对性能也有影响。128 个点足够平滑性价比最高。6. 后续延伸从太阳系到柳树再到工业数字孪生6.1 粒子系统与自然场景柳树、树枝、风场学会了刚性的太阳系动画下一步可以去碰一碰柔性自然物比如树、草、布料之类。社区里常有人用 Three.js 做柳树动画核心已经不是“绕着节点旋转”而是顶点位移和曲线插值。柳树的实现思路大致是把枝条建模成若干段用骨骼或者曲线控制每一段的弯曲然后用一个风场函数把风的方向和强度转换成每段节点的位移最后把柳絮做成粒子系统让它们随重力飘落。这套技能和太阳系的 Group 旋转完全不同是另一种思维方式但它同样非常好玩。为什么把柳树和太阳系放在一起提因为这是从“机械式运动”走向“有机运动”的转折点。太阳系里每个天体的运动轨迹都是确定的、数学可描述的柳树则需要考虑外力、弹性、随机性。这两个极端都见过之后你对 Three.js 的能力边界会有更清晰的认识。6.2 Three.js 与 Cesium 在工业数字孪生中的分工近几年“three.js、cesium 工业数字孪生”这个话题特别热。很多团队做园区、城市、工厂的数字孪生系统时会同时用两个渲染引擎Cesium 负责全球尺度、地理信息、地形、影像和 3D TilesThree.js 负责局部高精度的设备模型、特效和动画交互。太阳系项目对这条路的启发主要在两点。第一点Three.js 的 Group 嵌套结构可以直接迁移到数字孪生中的复杂装备体。比如一个数控机床底座、主轴、刀具、防护门各自是独立的子节点通过父子关系组合成一台可动设备。第二点时间驱动和状态驱动的思想是相通的。太阳系里用 Clock 控制公转数字孪生里用服务器下发的实时数据控制设备位姿。代码结构上都是“更新子节点 rotation/position”。所以别把太阳系当成一个“玩过就扔”的 demo。把它理解成一个最小可运行的场景树示例你后面接入 Cesium、接入业务数据会发现很多思路都能复用。6.3 继续学习的三条路径资源下载、官方示例与社区如果你学完太阳系想继续深入我给三条建议路径。第一把官方示例仓库完整跑一遍。Three.js 的 GitHub 仓库里有个 examples 目录涵盖了从小动画到后期处理的几乎所有常见效果。直接克隆下来起一个本地服务按分类一个个看收获比刷教程大得多。第二找一个真实的小需求做落地。比如给公司某个产品页面加一个 3D 展示或者给内部管理系统做一个设备状态可视化。太阳系的代码结构可以原样复用初始化场景、加载模型、创建动画循环、用数据驱动对象。第三关注 community 里的案例。现在网上有大量基于 Three.js 的创意项目从柳树、流体、粒子星系到数字孪生大屏案例非常多。看到好的项目第一反应不是感叹而是去它的源码里找到对应的 Group 结构、材质参数和动画逻辑拆解一遍再想想怎么迁移到自己项目里。我个人的体会是太阳系这个练手项目真正的价值不是让你背熟几个 API而是让你建立一种“空间感”一个物体在场景里为什么是这个位置、为什么会这样运动、灯光和材质为什么让它呈现这个颜色。把这个空间感培养出来后面的路会越走越宽。最后再分享一个小技巧给你的行星都加上名字属性比如 earth.name earth。调试的时候在场景树里搜名字能省去一大半定位问题的时间。这个习惯我从太阳系项目开始一直保留到现在做数字孪生项目时尤其有用。