在Unity里搭一套包含八大行星的太空场景,比想象中更像一门“取舍”的功课。之前接了个展示类的需求,要求在一个页面里看到完整的太阳系,还得让参观者一眼判断出当前跑得顺不顺,于是就有了这套东西:Unity星球资源,水星到海王星一共八大行星,围绕中心太阳公转自转,左上角常驻FPS显示。整个项目没有用付费插件,模型、材质、脚本全部手写,打包后PC和Android一体机都能跑。如果你正要交课设、做展示大屏,或者刚开始接触Unity想找一个能练到渲染、动画、UI、性能优化全流程的项目,这篇内容可以直接照着做。
先说清楚这东西的定位:它不是一个追求天文级精度的模拟器,而是一套带性能反馈的交互式太阳系演示场景。八大行星是视觉主体,FPS显示承担“体检报告”的作用。项目本身不难,但里面很多坑——比如行星比例怎么压、自转轴怎么摆、FPS为什么显示不准、为什么一上真机就掉帧——都不是第一次写就能避开的。这篇文章会把每个环节的选择理由和实际踩过的细节都写出来,方便你二次开发。
1. 太阳系的“比例压缩”问题:先解决尺度与浮点精度
1.1 为什么不能按真实比例搭建
很多人第一次做太阳系,下意识按照真实距离摆放行星,结果几十秒后就会发现问题。太阳系是个尺度跨度极大的系统,海王星轨道距离太阳约45亿公里,而地球直径大约1.2万公里。如果把海王星放在坐标100000000的位置,Unity里所有Transform位置都是单精度浮点数,精度在小数点后三四位就开始退化。相机移动时物体边缘会肉眼可见地抖动,这就是典型的浮点精度问题。
正确做法是压缩轨道半径,常见做法是把整个太阳系压缩到几十个单位以内。我用的方案是:轨道半径在3到20个Unity单位之间,行星半径按“能看清但别夸张”的原则单独设定。地球半径0.55,木星1.2,土星1.0,这样木星比地球大两倍多,视觉上一眼能分辨,又不会让内圈行星被外圈完全挡住。注意这不是真实比例,但演示场景里“可辨认”比“真实”更重要。
1.2 一套可以直接抄的行星参数表
下面这组参数是在我的项目里实际跑过的,它解决了两个矛盾:公转速度要足够快到观众有感知,但又不能快到每个行星都像赶火车。公转周期以地球为例,游戏内24秒转一圈,其他行星按真实公转周期的比例做一个“演示化压缩”。
| 行星 | 轨道半径 | 球体半径 | 公转周期(秒) | 初始轨道偏角 | 备注 |
|---|---|---|---|---|---|
| 水星 | 3.2 | 0.35 | 10 | 0° | 表面灰暗 |
| 金星 | 4.5 | 0.5 | 18 | 15° | 自转反向 |
| 地球 | 6.0 | 0.55 | 24 | 30° | 自转轴倾角23.5° |
| 火星 | 7.6 | 0.45 | 36 | 45° | 红色明显 |
| 木星 | 10.5 | 1.2 | 60 | 60° | 波段纹理 |
| 土星 | 14.0 | 1.0 | 90 | 75° | 带环 |
| 天王星 | 17.5 | 0.8 | 120 | 90° | 几乎是躺着转 |
| 海王星 | 20.0 | 0.75 | 180 | 105° | 深蓝色 |
轨道偏角是每一圈相对太阳赤道面做的倾斜。全部放在同一平面会显得太“扁平”,加一点偏角之后,镜头从侧面看时层次感很强。公转周期不要用真实的天文数值,否则水星转一圈,海王星还停在原地,演示效果会非常无聊。
1.3 目录结构和场景组织方式
搭建之前先把资源目录规划好。我的目录结构是这样的:
text复制Assets/
PlanetSystem/
Scenes/
Scripts/
Prefabs/
Materials/
Textures/
Hierarchy里用空物体做分组。中心是Sun,Sun下面挂PointLight和自发光球体。Planets空物体下挂八个子物体,每个子物体有MeshRenderer、PlanetController和OrbitLineRenderer。Canvas独立在最外层,专门放FPS显示。这样分类后,后续不管是做Prefab还是打AssetBundle,都非常方便。
还有一个小建议:每个行星独立做成Prefab,材质球单独命名,命名规则用“Planet_水星”“Planet_地球”这种可读性强的格式。如果哪天想把这套资源给另一个项目用,直接复制PlanetSystem文件夹就行,不用来回找引用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 行星球体与材质:从内置Sphere到程序化星球
2.1 内置球体其实不够用
Unity内置的Sphere是给通用场景设计的,顶点数不多,网格密度从球心到边缘不够均匀。如果镜头怼近看地球,能明显看到多边形棱角。行星这种高频出现在大屏上的物体,需要更细腻的网格。我选择用脚本生成UV球体,参数化控制段数和环数。
这段代码生成一个基础的UV球体网格:
csharp复制using UnityEngine;
public static class PlanetMeshGenerator
{
public static Mesh CreateUVSphere(float radius, int segments = 64, int rings = 32)
{
Vector3[] vertices = new Vector3[(segments + 1) * (rings + 1)];
Vector2[] uv = new Vector2[vertices.Length];
int[] triangles = new int[segments * rings * 6];
int vertIndex = 0;
for (int y = 0; y <= rings; y++)
{
float v = (float)y / rings;
float theta = v * Mathf.PI;
for (int x = 0; x <= segments; x++)
{
float u = (float)x / segments;
float phi = u * 2f * Mathf.PI;
float sinTheta = Mathf.Sin(theta);
vertices[vertIndex] = new Vector3(
radius * sinTheta * Mathf.Cos(phi),
radius * Mathf.Cos(theta),
radius * sinTheta * Mathf.Sin(phi));
uv[vertIndex] = new Vector2(u, 1f - v);
vertIndex++;
}
}
int triIndex = 0;
for (int y = 0; y < rings; y++)
{
for (int x = 0; x < segments; x++)
{
int i0 = y * (segments + 1) + x;
int i1 = i0 + 1;
int i2 = i0 + (segments + 1);
int i3 = i2 + 1;
triangles[triIndex++] = i0;
triangles[triIndex++] = i2;
triangles[triIndex++] = i1;
triangles[triIndex++] = i1;
triangles[triIndex++] = i2;
triangles[triIndex++] = i3;
}
}
Mesh mesh = new Mesh();
mesh.vertices = vertices;
mesh.uv = uv;
mesh.triangles = triangles;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
}
segments设64、rings设32,一个行星大约6000多个三角形,即使八个行星加起来也就五万面,对渲染毫无压力。顶点数没有高到需要担心GC或内存。你可以把这段代码放到Editor脚本里,预先烘焙好Mesh保存为.asset文件,运行时直接加载,避免每次进场景都计算一遍。
2.2 星球材质怎么做出差异感
没有现成贴图时,最容易出现的坑是八个球都像一个模子刻出来的。星球素材包里的材质差异,不是靠贴图越花越好,而是靠颜色、条纹、半透明层拉开层次。
我处理每个行星的思路如下:
- 水星:深灰主色,加轻微法线贴图模拟陨石坑,整体偏哑光。
- 金星:米黄主色,表面加半透明云雾层,用来遮住下面颜色。
- 地球:蓝色海洋基色,绿色陆地用一张Mask贴图控制,云层单独一层白色半透明球。
- 火星:橙红色,颜色饱和度高,不加云层。
- 木星:浅棕底色,叠加几条深色水平条纹,条纹用程序化纹理生成。
- 土星:暗黄底色,加一条环,环的透明度从内到外渐变。
- 天王星:淡青色,表面几乎是纯色,不需要复杂贴图。
- 海王星:深蓝,适当加一点噪点纹理,模拟大气扰动。
如果你用的URP,建议所有行星材质都基于URP/Lit,只是参数和贴图不同。这样URP的SRP Batcher可以把Draw Call压得很低。不要一会儿用Standard、一会儿用Unlit,那样材质切换反而会比较频繁。
2.3 土星环的两种实现方式
土星环是这套场景里最容易翻车的部分。最省事的办法是在球体外圈放一个Torus模型,但Torus的横截面是圆管状,看起来像轮胎,跟真实的薄环差异很大。
我的做法是程序化生成一个环形网格,把UV的x轴映射成角度,y轴映射成半径。材质上用一个从中心到边缘渐变的透明纹理,内圈不透明部分窄,外圈逐渐消失,接近土星照片的效果。这个环形Mesh只有两圈顶点、几百度段位,面数非常低。
另一种思路是直接在Shader里写Clip,在球体Shader内部做一个圆环区域。这个方法不需要额外Mesh,但Shader编写复杂,而且阴影处理容易出问题。对于演示项目来说,我推荐程序化环形Mesh,实现最直观,出了问题也好查。
3. 公转、自转与相机视角:让八大行星动起来
3.1 公转脚本:用角度算位置而不是RotateAround
网上很多教程会用Transform.RotateAround,但我的经验是不推荐。RotateAround的问题在于它会自动修改世界坐标,如果你想同时画轨道线或者取得行星当前角度,还得再额外算一遍。更干净的做法是自己管理一个公转角度变量,每帧累加,然后用正弦余弦解算出位置。
以下是核心脚本:
csharp复制using UnityEngine;
public class PlanetController : MonoBehaviour
{
public float orbitRadius = 6f;
public float orbitSpeed = 15f;
public float selfRotateSpeed = 30f;
public Vector3 axisTilt = Vector3.zero;
private float angle;
void Start()
{
transform.rotation = Quaternion.Euler(axisTilt);
}
void Update()
{
angle += orbitSpeed * Time.deltaTime;
Vector3 pos = new Vector3(
Mathf.Sin(angle * Mathf.Deg2Rad) * orbitRadius,
0f,
Mathf.Cos(angle * Mathf.Deg2Rad) * orbitRadius);
transform.position = pos;
transform.Rotate(Vector3.up, selfRotateSpeed * Time.deltaTime, Space.Self);
}
}
公转角度用累加而不是直接把当前时间乘速度,意味着你可以随时暂停、回放或者设置初始角度。把orbitSpeed暴露到Inspector面板后,给每颗行星Prefab配置不同的数值,调起来非常方便。
如果想在一个公转周期内看到明显的自转效果,selfRotateSpeed不要设太小,我一般是公转速度的5到10倍。否则地球转完一圈,观众根本注意不到球体自己在动。
3.2 自转轴倾角、天王星侧躺与金星逆向旋转
八大行星里有两个特殊案例:金星是逆向自转,天王星是侧躺自转。如果做成全部绕Y轴正转,天文爱好者一眼就会觉得“不对”。处理方式很简单:
- 金星:把selfRotateSpeed设为负数,绕Y轴反向旋转。
- 天王星:把axisTilt设为
new Vector3(0, 0, 98),让它几乎“躺着”滚动前进。 - 地球:axisTilt设为
new Vector3(23.5f, 0, 0),模拟黄赤交角。
只要在Start里做一次初始旋转,然后Update里用Space.Self绕本地轴自转,就能保持这个倾斜角度。不要直接在Update里重复设置绝对旋转,那样会覆盖自转积累的角度,球体会永远保持一个表情。
3.3 轨道线可视化与自由摄像机
轨道线是这类演示场景的刚需。用LineRenderer画个圆圈就行,注意线要放在独立的OrbitLine空物体下,材质用Unlit/Color,关闭阴影投射。以下代码可以在Awake里生成轨道:
csharp复制using UnityEngine;
[RequireComponent(typeof(LineRenderer))]
public class OrbitLine : MonoBehaviour
{
public float radius = 6f;
public int segments = 64;
void Awake()
{
LineRenderer lr = GetComponent<LineRenderer>();
lr.positionCount = segments + 1;
lr.useWorldSpace = true;
for (int i = 0; i <= segments; i++)
{
float a = i * Mathf.PI * 2f / segments;
lr.SetPosition(i, new Vector3(Mathf.Sin(a) * radius, 0, Mathf.Cos(a) * radius));
}
}
}
相机控制我推荐自己写一个几十行的相机脚本,比引入Cinemachine更轻量。核心逻辑:右键拖动旋转,滚轮缩放,中键平移。这样在演示时能快速从太阳俯视视角切到木星近景。相机脚本不复杂,但每个项目都需要,写一次一劳永逸。
4. FPS显示模块:最简单的性能仪表盘,也是最容易被忽略的
4.1 为什么这个场景必须放FPS
很多教程项目做完就结束了,没人知道它到底能跑多少帧。这套八大行星场景的帧率受贴图尺寸、阴影距离、透明层数量影响极大,同一个场景在台式机和VR一体机上完全两个体验。没有FPS显示,观众只能凭感觉说“好像有点卡”,开发者也没法快速定位是哪个设置拖了后腿。所以我一开始就把FPS显示和解包素材、优化挂钩,这其实是一个很“资源包”式的做法。做一个带FPS的资源场景,别人拿到手第一件事就是看帧数,然后决定要不要优化。
FPS显示要特别注意:不要每帧都把字符串写一遍,那样UI本身会成为性能负担。更稳的方式是开一个0.5秒的统计窗口,累计帧数,到点了再刷新一次文本。下面是我实际用的脚本:
csharp复制using UnityEngine;
using TMPro;
public class FPSDisplay : MonoBehaviour
{
public TextMeshProUGUI fpsText;
public float updateInterval = 0.5f;
private int frameCount;
private float timer;
private float fpsValue;
void Update()
{
timer += Time.unscaledDeltaTime;
frameCount++;
if (timer >= updateInterval)
{
fpsValue = frameCount / timer;
fpsText.text = string.Format("FPS:{0:F1} {1:F2}ms", fpsValue, 1000f / fpsValue);
timer = 0f;
frameCount = 0;
}
}
}
这里用Time.unscaledDeltaTime而不是Time.deltaTime,原因很关键:公转演示时我可能会把Time.timeScale调成0.2倍速,如果FPS计算也受timeScale影响,显示的帧率会瞬间变成真实帧率的五分之一,这明显是错的。FPS统计必须用不受缩放影响的时间。
4.2 为什么还要显示毫秒数
在FPS旁边同时显示一帧的耗时(ms),能让排查快很多。30FPS约等于33.3ms,60FPS约等于16.7ms。如果FPS数值在60左右徘徊,说明单帧耗时已经到了16ms以上,稍微加一点点特效就会跌破60。而如果你只盯着FPS看,它会忽高忽低,很难定位瓶颈。我一般把两行信息合在一起:不占用太多UI空间,信息量却很足。
4.3 UI布局和真机上常见的坑
Canvas的缩放模式建议用Scale With Screen Size,参考分辨率设为1920x1080。TextMeshProUGUI放在左上角,给它加一个半透明黑色背景,否则在星空场景里白色数字会被亮星球吞掉。字体用TextMeshPro自带的LiberationSans,开启SDF后缩放不会糊。数字区域要预留宽度,避免60变120时文字突然抖动移位。
实际部署到Android或Pico4这种设备时,需要注意vSyncCount。PC编辑器里如果开着垂直同步,FPS会被锁定到显示器刷新率,你可能误以为优化很完美。真机上如果不开垂直同步,GPU可能跑到上百帧,白白消耗电量。我的做法是写一个启动脚本,在GameManager的Awake里设置参数。
另一个典型坑是Unity编辑器里的Game视图统计面板和实际打包后的帧率差距很大。编辑器里每帧还有大量Editor开销,FPS显示经常偏低;但如果打包后在真机上不升反降,那基本就是纹理带宽或者阴影问题。所以FPS显示一定要在真机上验证,这个结论很朴素但经常被忽略。
5. 从30帧到60帧:这个场景的渲染压力与优化路线
5.1 明明只有八个球,为什么还会卡
八个球体加一个太阳,面数总量不高,理论上不该卡。但这个场景依然有几处隐藏的渲染压力。第一是纹理内存:如果每颗行星都塞一张4096贴图,光贴图显存就可能超过两三百兆,移动端直接爆内存。第二是阴影:方向光的Shadow Distance如果设成1000,所有行星和太阳都要参与阴影渲染,阴影贴图分辨率又不可能无限大,远距离阴影锯齿会很严重。第三是透明层:金星云层、土星环、地球云层,如果每个材质都是Transparent半透明,Overdraw会成倍增加。
5.2 我最先做的三个优化动作
我的优化顺序从来不是一上来就加LOD,而是先看这三件事:
-
贴图压缩和尺寸。PC上保留2048,Android真机全部压缩成1024并用ASTC格式。Unity导入面板里Texture Type选Default,Compression选ASTC 8x8,肉眼差别不大,但内存和带宽压力显著降低。
-
阴影距离。URP Asset里的Shadow Distance我压到35。这个距离以内只有水星和金星能看到明显实时阴影,木星、土星这些远轨道行星本来就在星空背景下,没有阴影反而更像照片。
-
关闭多余的实时灯光。太阳位置只放一盏方向光,不要为了好看再给每个行星补点光源。点光源数量一多,每个物体都要算多次光照,Draw Call立刻上涨。
做完这三步,场景基本可以从卡顿变成流畅。如果还不行,再往下查材质和网格。
5.3 为什么LOD在大场景里不太重要
LOD(多层次细节)适合物体密集、单个物体网格巨大的场景,比如树木、建筑群。太阳系场景里八个球体,面数本身很低,加LOD的收益十分有限。真正要控制的是纹理采样和阴影。这也算是我做这个项目过程中反省比较深的一点,盲目套用优化套路经常事倍功半,不如用Profiler先看一下瓶颈在哪。
如果确实要做LOD,关键不是远处的球降低精度,而是近处的球不要切换得太突兀。可以为每个行星准备两个Mesh:一个是64段的高清网格,另一个是24段的低清网格,切换距离设在15米左右。注意LODGroup的过渡不要用Cull None,用CrossFade在移动端可能会产生透明度采样开销。
5.4 用Profiler定位“到底谁在拖后腿”
FPS显示只能告诉你“卡了”,不能告诉你“为什么卡”。定位原因还得靠Profiler。在编辑器点开Window > Analysis > Profiler,切到Render模块,先看SetPass Call。如果这个数字在200以上,说明材质切换太频繁,优先压缩材质数量。再切到CPU Usage模块,看Rendering、Shadow、Script各自消耗的时间。
真机调试时,Android需要勾选Development Build和Autoconnect Profiler,然后用USB线连Unity Profiler。如果你用的是一体机,还可以在设备上打开高级开发者选项,配合Perfetto或者SimplePerf做更底层的native定位。不过对这个项目而言,绝大多数性能问题在Unity Profiler的CPU和GPU模块里就能看明白了,不必引入额外工具。
6. 打包、真机测试与常见问题
6.1 Personal版水印和启动画面怎么处理
很多刚接触Unity的开发者第一次打包,看到右下角有个Unity Logo或水印,会以为项目出问题了。其实这是Unity Personal版的默认行为。在Player Settings > Splash Image里,可以关闭Splash Screen,或者取消勾选Show Unity Logo。只要你的项目规模和收入符合Unity个人版许可范围,这样可以合规地去掉启动水印。不用去找任何第三方破解工具,正规设置就能解决。
6.2 PC和Android真机测试流程
我的测试顺序是:先在Windows编辑器里调到不卡,再打包Android在一体机上测。PC端分辨率设成1920x1080,关闭VSync,看角落FPS显示。如果PC能稳定60帧,再上移动端。移动端目标帧率通常设成设备刷新率,Pico这种一体机常见的刷新率是72或90Hz,直接在脚本里Application.targetFrameRate设成对应的值。
真机测试时我习惯把QualitySettings.vSyncCount设为0,然后把targetFrameRate设定为目标值。如果不设,部分Android设备会跑满屏幕刷新率,看起来FPS很高,但实际渲染压力没问题时其实可以再往上限帧,白白发热。如果设了还掉帧,说明设备承受不住当前画质,需要按第5章的思路继续压贴图、阴影或抗锯齿。
6.3 把整套资源整理成可复用包
项目做完后,如果你想把这套星球资源交给别人用,或者带到下一个项目里,只需要做几件事:把所有星球场景物体保存为Prefab,材质和贴图保持在对应文件夹内,然后选中Assets/PlanetSystem整个目录,右键Export Package。这样导入方双击unitypackage,整个资源目录会自动落到项目中,脚本引用关系保持完整。
如果项目大了,想在游戏运行时按需加载某个行星场景,可以把Prefab打成AssetBundle,用AssetBundle.LoadFromFile异步加载。或者用Additive Scene的方式把太阳系场景叠加到主场景之上,方便做“进入演示”的过渡。但这两种方式对本项目都不是必需,先跑通普通Prefab复用就足够了。
最后再分享一个经验:行星场景的调节是肉眼驱动的,不同显示器、不同Gamma值,同一颗火星可能会从橙红变成土黄。完成基础版本之后,强烈建议拿一台真机做最终校色,别只盯着编辑器看效果。这个习惯能省掉很多交付时的返工时间。
