☰
跨平台AR/VR天文科普应用开发:从天文算法到Unity渲染
2026/9/26 6:16:43 网站建设 项目流程

去年冬天我在小区里给女儿科普猎户座,讲到参宿四是一颗随时可能走向超新星的红色超巨星,孩子抬头问“那颗星现在到底在哪个方向?”我掏出手机翻了一圈,居然没有一个App能对着真实的天空把星座指给她看。恰逢这个系列写到了第12章,前面几章已经从天球坐标讲到行星轨道计算,数学底子都搭好了,那不如干脆自己做一版。这篇文章就是完整记录我如何把一个“科普想法”,从天文算法一路做到能在手机和VR眼镜上跑的跨平台AR/VR应用。

整个项目折腾下来,最大的体会是:天文科普应用真正难的不是算法本身,而是把“赤经赤纬”这种专业天球坐标,变成玩家眼前一个准确、流畅、看得懂的星空。算法层和渲染层只要有一个坐标对不上,满屏星星就会变成“抽象艺术”。我会把整条链路分成方案选型、算法解算、渲染实操、性能排坑四个大块,中间穿插我在真机调试时踩过的坑和补救过程。如果你正打算做星空导览、天文科普工具,或者单纯想学Unity里接AR/VR渲染的完整套路,这篇应该能从第一行给你带到打包。

1. 为什么这么选型:跨平台方案与整体架构

1.1 为什么最终选了Unity这条路线

这个项目从一开始就定了一个硬指标:同一套代码,要能在Android、iOS、以及Quest这类VR一体机上跑。如果按传统思路拆成两个原生项目来做,光AR/VR两套SDK就能让我维护到崩溃。备选方案其实还有几个,但实际比下来差距很明显。

方案跨平台UI生态AR/VR能力3D渲染性能踩坑成本
iOS原生 + Android原生各自为战各自接SDK最高双倍工作量
Flutter很强插件不成熟一般做2D没问题,3D星空吃力
React Native很强需要桥接原生一般不适合沉浸式场景
Unity + AR Foundation够用原生级高学习曲线中等

选Unity是我试过一圈之后做出的决定。Unity 2022 LTS配合AR Foundation,底层同时封装了ARCore和ARKit,写一套C#就能同时出Android和iOS的AR包。VR那边用XR Interaction Toolkit接OpenXR,Quest 2可以直接跑。天文科普这种场景本身需要大量粒子系统、点精灵、Bloom后处理,Unity的URP渲染管线和Shader开发效率比Flutter高太多。至于UI部分,Unity的UGUI加上TextMeshPro做信息卡片、搜索列表完全够用,没必要为了一个设置页单独套一层Flutter壳。

注意:如果你的核心页面有大量的列表、VStack、复杂动效交互,Flutter的优势才会体现出来。天文科普的主界面是3D星空,一打开满屏星星,这类场景应该把3D渲染能力作为选型第一优先级。

1.2 一套数据核心,多端渲染前端

整个项目我拆成了三层:算法层、数据层、渲染层。算法层是完全不依赖Unity的纯C#类库,天文坐标计算全部放在这里;数据层负责把星表文件从CSV转成紧凑的二进制格式,运行时加载进内存;渲染层才去碰Unity的GameObject、粒子系统、XR接口。这三层严格分层的好处是:我可以在PC上用命令行直接验证“某颗星此刻的方位角到底是不是正确的”,不打开Unity就能跑算法测试。

工程目录大致长这样:

Assets/ Scripts/ Astronomy/ JulianDay.cs SolarPosition.cs SkyCoordinates.cs Precession.cs StarCatalog.cs Rendering/ StarFieldRenderer.cs ConstellationRenderer.cs PlanetMarker.cs StarShader.shader UI/ InfoCard.cs SearchPanel.cs TrackingStateHUD.cs Data/ hyg_full.bin constellations.json planets.json Scenes/ ARScene.unity VRScene.unity Sky2D.unity

数据流是单向的:StarCatalog从hyg_full.bin读出Hipparcos星表,算法层根据当前时间和观察者经纬度算坐标,渲染层拿到坐标后决定画在哪、画多大。单向的好处是排查问题非常快,某一层出错了只需要检查该层的输入输出,不用满工程翻依赖关系。

1.3 平台差异处理:目标设备清单

跨平台不是一句口号,是实打实要跟各家SDK打交道。AR模式下Android依赖ARCore,iOS依赖ARKit,VR模式走OpenXR。这三个API在Unity里虽然被AR Foundation和XR Toolkit统一了接口,但底层的权限、生命周期、相机行为还是有差别。比如ARCore对设备列表有严格限制,部分国产机型必须先装ARCore APK才能跑;ARKit对室内低光环境的追踪能力比ARCore强很多;Quest 2则是OpenXR runtime,驱动方式跟前两者完全不是一回事。

我这边最终的目标设备定为了:Android中端机(骁龙778G以上)、iPhone 12系列以上、Quest 2。这个清单直接影响后面的性能预算和材质规格,每台设备的粒子上限、纹理压缩格式、渲染分辨率都是单独配置的。强烈建议拿到设备清单之后再开始写性能相关代码,否则后期适配会非常被动。

2. 天文算法:从时间到坐标的解算链路

2.1 儒略日与恒星时:先把时间对齐

任何天体的位置计算,起点都是时间。公历上的“2025年1月1日 20:00”在日历上很好认,但天文学计算需要的是连续流逝的日数,于是要先转成儒略日(Julian Day)。我用的Meeus风格公式,C#实现很简洁:

public static double ToJulianDay(DateTime dt) { int y = dt.Year; int m = dt.Month; double d = dt.Day + dt.Hour / 24.0 + dt.Minute / 1440.0 + dt.Second / 86400.0; if (m <= 2) { y -= 1; m += 12; } double a = Math.Floor(y / 100.0); double b = 2 - a + Math.Floor(a / 4.0); return Math.Floor(365.25 * (y + 4716)) + Math.Floor(30.6001 * (m + 1)) + d + b - 1524.5; }

接着要算格林尼治平恒星时GMST。恒星时的意义是告诉你“此刻天上的坐标原点在哪”。民用时间是跟着太阳走的,恒星时间跟着遥远恒星走,两者每天差约4分钟,累积起来就是地球自转轴的相对位置在星空背景里移动了。算得公式:

public static double GMST(double jd) { double t = (jd - 2451545.0) / 36525.0; double gmst = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933 * t * t - t * t * t / 38710000.0; gmst = gmst % 360.0; if (gmst < 0) gmst += 360.0; return gmst; }

单位是度,换算成小时要除以15。地方恒星时就是GMST加上观察者的东经度数(东经为正),再模360。这里有个小坑:GPS给的是地理经度,东经为正,但有些平台输出的西经是负数,统一处理好再进公式,否则满屏星星都会偏。

2.2 太阳与行星位置的实用算法

太阳位置是很多计算的地基,比如白天判断哪些星星不可见、或者粗略估算行星大致的黄道位置,都需要它。严格的做法是套VSOP87完整周期项,但科普应用根本不需要角秒级精度,我把太阳位置简化为“平黄经 + 中心差”:

public static (double lon, double lat) SunEcliptic(double jd) { double t = (jd - 2451545.0) / 36525.0; double l0 = 280.46646 + 36000.76983 * t; // 太阳平黄经 double m = 357.52911 + 35999.05029 * t; // 太阳平近点角 double c = (1.914602 - 0.004817 * t) * Math.Sin(m * Math.PI / 180.0) + (0.019993 - 0.000101 * t) * Math.Sin(2 * m * Math.PI / 180.0) + 0.000289 * Math.Sin(3 * m * Math.PI / 180.0); double trueLon = l0 + c; return (trueLon, 0.0); }

太阳的黄纬基本为0,所以重点是黄经。行星位置我用的是简化周期项表,每颗行星存一组“平均要素”,精度大约在0.1度以内。对于AR/VR星空科普,这个精度够了——毕竟你举起手机时,手抖的角度都不止0.1度。

2.3 赤道坐标到地平坐标的转换

星表和行星数据算出来的都是赤道坐标(赤经RA、赤纬DEC),但用户看到的是地平坐标(高度角alt、方位角az)。这时候就要先根据前面算的地方恒星时,求时角H:

double H = localSiderealTimeDeg - raDeg;

然后转地平坐标,最稳的写法是:

public static (double alt, double az) EquatorialToHorizontal( double raDeg, double decDeg, double latDeg, double lstDeg) { double ra = raDeg * Mathf.Deg2Rad; double dec = decDeg * Mathf.Deg2Rad; double lat = latDeg * Mathf.Deg2Rad; double H = (lstDeg - raDeg) * Mathf.Deg2Rad; double sinAlt = Math.Sin(dec) * Math.Sin(lat) + Math.Cos(dec) * Math.Cos(lat) * Math.Cos(H); double alt = Math.Asin(sinAlt); double cosAz = (Math.Sin(dec) - Math.Sin(lat) * sinAlt) / (Math.Cos(lat) * Math.Cos(alt)); double az = Math.Acos(Math.Clamp(cosAz, -1.0, 1.0)); if (Math.Sin(H) > 0) az = 360.0 - az; return (alt * Mathf.Rad2Deg, az * Mathf.Rad2Deg); }

方位角的定义是从正北起算,顺时针为正。上面这个if是为了处理H跨过子午线时的象限翻转,实测下来比裸用Atan2少很多冤枉路。用Math.Clamp夹一下cosAz是为了防止浮点数误差导致acos域越界,这个小动作能避免屏幕上出现星点突然从地平线下方“弹”上来的现象。

2.4 岁差、自行与光行差的取舍

星表里的坐标通常标着J2000历元,也就是2000年1月1日的状态。但地球自转轴在空间里是做缓慢进动的,二十多年过去,赤道坐标已经偏移了不少。最简单的处理是给每颗星加上“岁差修正项”,把J2000坐标拉到当前年份,我用的是经典近似公式:

double t = (currentYear - 2000.0) / 100.0; double deltaRA = (3.074 + 1.336 * Math.Sin(raDeg * Deg2Rad) * Math.Tan(decDeg * Deg2Rad)) * t * 100.0 / 3600.0; double deltaDec = (20.043 * Math.Cos(raDeg * Deg2Rad)) * t * 100.0 / 3600.0;

这里deltaRA和deltaDec是从J2000岁差到当前历元的角秒变化,算完加到原始坐标上即可。如果要求更高精度,用IAU 1976岁差矩阵,但科普应用这个简化版足够。自行则是对少数距离近、自行大的恒星才做,比如巴纳德星一年能跑10角秒,不修正的话它在星空里的位置会跟星表对不上。

实操提示:星座连线画完后,一定要做一次真机对比测试。找一个视野开阔的夜晚,打开你的AR模式,把手机对准猎户座,如果腰带三星连线和真实天空一致,就说明整条坐标链路没问题;如果整条线偏移,八成是岁差或者恒星时代入错了。

3. 从星表到AR/VR场景的渲染实现

3.1 把地平坐标变成Unity世界坐标

算法层算出的alt/az是个“东—北—上”方向,得映射到Unity的左手法则坐标系。我定义的地平坐标世界映射是:Y轴为正北方向,Z轴向上,X轴为正东方向。于是:

public static Vector3 AltAzToWorld(float alt, float az, float radius) { float altR = alt * Mathf.Deg2Rad; float azR = az * Mathf.Deg2Rad; return new Vector3( radius * Mathf.Cos(altR) * Mathf.Sin(azR), radius * Mathf.Cos(altR) * Mathf.Cos(azR), radius * Mathf.Sin(altR) ); }

当初在这里踩过一个坑:如果按传统的“Z轴北、Y轴上”习惯去写,Unity里没法直接用,必须先做轴交换。后来我统一约定好“Y北、Z上、X东”,并把坐标转换函数放在算法层的最外层,渲染层只认这一个函数,从此再没出过方向错乱的问题。

3.2 AR模式:让星星跟着手机的位置和朝向走

AR模式的思路是:把整个“星空天球”看作一个以相机为圆心、半径1000米的球壳,所有星点都固定在这个球壳的内壁。天球本身不参与AR空间定位,它只是跟随相机的位置同步,同时根据当前时间和经纬度旋转到正确的姿态。这样手机就是一块“活动星盘”,举起来对准天空,星星就会出现在你眼前的位置。

AR Foundation的项目设置里有几个关键点:

  • ARCameraManager负责把手机摄像头画面渲染为背景,天上3D星点会叠加在真实画面上
  • 相机远裁剪面要拉到5000米以上,否则天球表面星星会被透视裁剪掉
  • 星点不参与光照,直接走URP的无光照Shader
  • 每帧根据GPS更新经纬度,用户移动几十米的量级对恒星视位置影响很小,但纬度变了会明显改变地平坐标

定位失败的情况下我会自动切换成“模拟模式”,默认把观察者放在北纬40度、东经116度,至少保证App能看、能演示,不至于在室内直接黑屏。

3.3 VR模式:在天球内部实现星空漫游

VR模式就简单暴力了:把摄像机放在天球的球心,天球半径20米,所有星星布置在球面内壁上。用户转动头部就能看遍全天,手柄射线指到任意一颗星,弹出信息卡片。信息卡片不能放在星星所在位置,太远看不清,我的做法是射线命中后,把卡片吸附在射线方向5米处,并让卡片永远面朝相机。

星座的连线在VR里特别重要。每个星座是一组预定义好的星点索引,运行时根据这两颗星当前的赤道坐标重新计算世界坐标,生成LineRenderer的Positions数组。线的宽度可以压得很细,配一个半透明材质,看起来像是悬浮在星空里的微弱引导线。VR环境下玩家会反复抬头低头,LineRenderer需要选择useWorldSpace = true,否则会跟错父级坐标系。

3.4 星点Shader与粒子系统的取舍

一开始我发现每颗星都放一个Sprite或Quad,一万颗星就是一万个GameObject,主线程直接顶不住。后来改成了两层渲染:亮星(视星等小于2.5)数量少,单独用Quad渲染,走Instancing合批;暗星全部塞进一个大粒子系统,粒子位置由脚本每帧从坐标计算结果写入粒子数组。

粒子的渲染用了一个极简的圆形光斑Shader,核心就是“距离越远,视觉尺寸越小,越接近中心越亮”:

v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); float dist = distance(_WorldSpaceCameraPos, mul(unity_ObjectToWorld, v.vertex).xyz); o.size = _StarSize / dist; return o; } fixed4 frag (v2f i) : SV_Target { float2 uv = i.uv - 0.5; float r = dot(uv, uv) * 4.0; // 圆形羽化 float alpha = smoothstep(1.0, 0.2, r) * _Brightness; return fixed4(1.0, 1.0, 1.0, alpha); }

这种圆形光斑比硬邦邦的方形贴图更接近真实星点,而且配合URP Volume里的Bloom后处理,亮星会有淡淡的辉光,暗星则保持自然。

4. 性能调优与多设备适配

4.1 先定预算再写代码

移动端性能优化第一原则是:先定预算,再写代码。我手里的设备性能差异很大,Quest 2和几年前的安卓中端机完全不是一个量级。按设备定了一个明确的性能预算表:

设备目标帧率粒子预算DrawCall预算内存预算
Android中端60fps2万150256MB
iPhone 12 Pro60fps3万150512MB
Quest 272fps3万120512MB

这里的粒子预算不是总粒子数,而是“屏幕上同时可见”的粒子数。因为粒子系统被大天球包围,玩家永远只能看到一半左右的星星,所以即使星表有12万颗,我也只会在可见半球范围内生成粒子,剩余部分不渲染。

4.2 12万颗星的渲染优化方案

星表全量灌进去显然不现实,按视星等做了三级裁减:

第一梯队是亮星级(视星等小于2.5),全天大概24颗,包含天狼星、参宿四、织女星这些耳熟能详的目标。这24颗用独立ShellRenderer对象渲染,材质带Bloom辉光,支持点击高亮。第二梯队是中等亮星(2.5到4.5等),约400颗,放进中等粒子系统。第三梯队是暗星(4.5到8等),数量庞大,放进一个超大粒子系统,粒子尺寸更小、透明度更低,负责提供银河背景的“颗粒感”。

星座连线、行星标记、信息卡片各自独立成DrawCall。最终一帧的DrawCall总数稳定在40-70之间,Quest 2跑起来很轻松。对比最初版本“全员GameObject”的做法,一帧12000个DrawCall,移动端直接卡到不可用,差距非常直观。

4.3 从卡顿到流畅的实测记录

开发过程中最有代表性的一次调优,是银河背景那部分。第一次把银河粒子数量拉到10万,Quest 2的帧率直接掉到35fps,发热严重。排查发现罪魁祸首不只是粒子数量,还有每个粒子的贴图采样——粒子的UV动画写得太复杂,导致GPU在Fragment阶段出现瓶颈。

后来我把银河粒子的贴图改成单张噪声纹理,去掉逐粒子的色差动画,只保留透明度随距离衰减,效果天差地别。同一台设备,帧率从35fps拉回到72fps,发热也降了一个级别。所以说,移动端Shader越简单越好,科普应用里星星不需要像游戏技能特效那样华丽,准确、干净才是核心。

4.4 一个容易被忽略的适配点:屏幕亮度和发热

AR模式下手机摄像头常亮,相机渲染+GPS+AR追踪叠加在一起,发热降频是常态。头戴显示器的VR模式对发热更敏感。我做了两件事:帧率动态跟随设备温度,温度超过警戒线自动把粒子数量砍半;画面亮度用HDR调光而不是纯白色叠加,避免屏幕全亮导致功耗飙升。这些小细节,最终多设备跑下来非常关键。

5. 常见问题与排查技巧实录

5.1 星座连线为何在真机上整体偏移

这个问题是AR模式第一轮内测时用户反馈最多的:手机明明对准了猎户座,星座连线的方向却整体偏了几度。一开始怀疑算法写错,后来发现是星表坐标没有加岁差修正。我的星表数据是J2000历元的Hyg数据库,但运行App时已经过了二十多年,不把岁差转过来,所有星星在物理上就都“丢”了一段距离。加上岁差修正后,连线立即对齐。

这个坑对一个天文App来说近乎致命——用户会很直观地发现“对不上”。真机验证必须放在开发流程前面,最好是每天晚上晴朗都出去校准一次。

5.2 暗星闪烁与点精灵最小尺寸

暗星在粒子系统渲染时会出现一个很烦的毛病:星星忽明忽暗,甚至整排整排地闪。究其原因,是暗星在屏幕上投影尺寸小于1像素,深度缓冲区里发生抖动。解决办法是给Shader加一个最小像素尺寸保护:

float projScale = o.size * _ScreenParams.y; float safeSize = max(projScale, _MinPixelSize); o.vertex.xy += normalize(o.vertex.xy) * safeSize * 0.001;

加上之后,暗星至少占据一个像素,闪烁立即消失,视觉上虽然亮度略高于真实,但科普场景下“稳定可见”比“精确暗淡”更重要。

5.3 AR模式下星星缓慢漂移

模拟器里一切正常,真机上AR星星会随着相机转动慢半拍似地“漂”。这个原因是ARFoundation的相机姿态更新频率和天文计算线程不一致,我在协程里用Update更新了恒星时,但没跟XRCameraSubsystem的帧回调同步。改成在ARSession的OnFrameReceived事件里触发坐标更新后,漂移问题解决。关键教训:AR应用里所有和相机姿态同步的更新,都必须放在AR Frame事件回调里,而不是普通的Unity Update循环。

5.4 问题速查表

现象可能原因解决方案
星座连线整体偏移星表坐标未加岁差在算法层补J2000→当前历元变换
暗星闪烁点精灵小于1像素Shader加最小投影尺寸保护
AR星星慢半拍漂移坐标更新没跟在相机帧回调里移入OnFrameReceived中同步
安卓端初始化失败ARCore未安装或设备不支持打包前加ARCore安装检测和提示页
VR开不了星空场景OpenXR Runtime未激活检查Player Settings中的XR Plug-in Management
帧率掉到40以下粒子贴图采样过重换单张噪声纹理,去掉粒子UV动画

5.5 排查经验升华:DEBUG要能“跑数据”

开发这个项目最大的习惯改变,是给所有坐标算法加了一个“Debug Plot”模式——在Unity里只显示星座的参考点,并用Debug.DrawLine把每颗星的赤道坐标、地平坐标、世界坐标三个阶段画出来。坐标链路一旦出问题,看哪一环节的线条断了就知道谁背锅。这个方法救了我很多次,尤其适合这种“算法+渲染”混合项目。

结尾

最后再分享一个心得体会。整个项目做下来,我最大的感悟是:算法层和渲染层一定要分开来验证,千万别等所有模块写完才做联调。所有恒星和行星的坐标,我是先在纯C#的测试控制台里跑了一遍,确认某颗星在某时刻的方位高度符合Stellarium的预期,才把它们接进Unity。如果一上来就直接在URP里调Shader,出了问题根本分不清是坐标算错还是材质写错,排查成本会翻几倍。

这个项目的下一阶段,我打算把流星群预报和近地卫星过境加进去,计算框架还是这一套跨平台架构,只是在算法层多加两个预测器,渲染层再加一个轨迹粒子系统。如果你也在做类似的天文科普或XR互动项目,希望这篇能帮你少走几个月的弯路。我自己在第一次用真机把猎户座腰带三星准确对齐在手机屏幕上的那一刻,忽然觉得这一年多的坑都值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询