This commit is contained in:
2026-10-30 14:48:06 +01:00
commit c4a5a4b4ed
131 changed files with 10399 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.Physics;
using Universe.Core.World;
namespace Universe.App.Runtime;
/// <summary>Repeatable camera path and frame-time capture for a selected planetary terrain scene.</summary>
internal sealed class BenchmarkRunner
{
private const double SpaceSeconds = 8;
private const double DescentSeconds = 40;
private const double SurfaceSeconds = 24;
private const double TotalSeconds = SpaceSeconds + DescentSeconds + SurfaceSeconds;
private const double GraphSampleSeconds = 0.2;
private const double BrakingAltitudeMeters = 10_000;
private readonly StarSystemData _system;
private readonly PlanetData _planet;
private readonly UniversePosition _systemCenter;
private readonly UniversePosition _body;
private readonly UniversePosition _returnPosition;
private readonly DVector3 _returnForward;
private readonly DVector3 _returnUp;
private readonly double _cellSize;
private readonly DVector3 _targetRadial;
private readonly DVector3 _spaceRadial;
private readonly DVector3 _spaceUp;
private readonly DVector3 _orbitTangent;
private readonly DVector3 _orbitBitangent;
private readonly List<double> _frameRates = [];
private readonly List<double> _graph = [];
private double _elapsed;
private double _renderElapsed;
private double _sampleElapsed;
private double _sampleFpsSum;
private int _sampleFrameCount;
private bool _completionFrameRecorded;
private bool _stopped;
public BenchmarkRunner(StarSystemData system, PlanetData planet, double cellSizeMeters, FlyCamera camera,
UniversePosition returnPosition, DVector3 returnForward, DVector3 returnUp)
{
_system = system;
_planet = planet;
_cellSize = cellSizeMeters;
_systemCenter = UniversePosition.AtCellCenter(system.Coordinate, cellSizeMeters);
_body = _systemCenter.Add(_planet.PositionMeters, cellSizeMeters);
_returnPosition = returnPosition;
_returnForward = returnForward.Normalized();
_returnUp = returnUp.Normalized();
DVector3 bodyDirection;
if (_planet.Id.Equals("earth", StringComparison.OrdinalIgnoreCase))
{
// The installed Copernicus tile covers the Alps. Its longitude is mirrored
// into the planet's local direction frame by the shared heightmap mapping.
const double latitude = 46.5;
const double longitude = -7.5;
var lat = latitude * System.Math.PI / 180;
var lon = longitude * System.Math.PI / 180;
bodyDirection = new(System.Math.Cos(lat) * System.Math.Cos(lon), System.Math.Sin(lat),
System.Math.Cos(lat) * System.Math.Sin(lon));
}
else
{
var random = new DeterministicRandom(DeterministicRandom.Mix(_planet.Seed ^ 0x42454E43484D4152UL));
var y = random.NextDouble() * 2 - 1;
var longitude = random.NextDouble() * System.Math.Tau;
var latitudeRadius = System.Math.Sqrt(1 - y * y);
bodyDirection = new(latitudeRadius * System.Math.Cos(longitude), y, latitudeRadius * System.Math.Sin(longitude));
}
_targetRadial = _planet.Orientation.ToWorld(bodyDirection).Normalized();
_spaceRadial = _targetRadial;
_spaceUp = Perpendicular(-_spaceRadial);
_orbitTangent = Perpendicular(_targetRadial);
_orbitBitangent = DVector3.Cross(_targetRadial, _orbitTangent).Normalized();
var initialRadius = _planet.RadiusMeters * 4.8;
camera.SetPlayerPose(_body.Add(_spaceRadial * initialRadius, cellSizeMeters), -_spaceRadial, _spaceUp);
}
public bool IsRunning => !_stopped && _elapsed < TotalSeconds;
public bool IsComplete => !_stopped && _elapsed >= TotalSeconds;
public bool HasRun => true;
public double ElapsedSeconds => System.Math.Min(_elapsed, TotalSeconds);
public double DurationSeconds => TotalSeconds;
public long GraphRevision { get; private set; }
public IReadOnlyList<double> GraphSamples => _graph;
public string TargetLabel => _planet.Id.Equals("earth", StringComparison.OrdinalIgnoreCase) ? "EARTH" : _planet.Name;
public double TargetEarthRadii => _planet.RadiusMeters / UniverseScale.EarthRadiusMeters;
public double CurrentFps { get; private set; }
public double AverageFps => _renderElapsed <= 0 ? 0 : _frameRates.Count / _renderElapsed;
public double MinimumFps => _frameRates.Count == 0 ? 0 : _frameRates.Min();
public double OnePercentLowFps
{
get
{
if (_frameRates.Count == 0) return 0;
var count = System.Math.Max(1, (int)System.Math.Ceiling(_frameRates.Count * .01));
return _frameRates.Order().Take(count).Average();
}
}
public string Phase => _stopped ? "STOPPED"
: IsComplete ? "COMPLETE"
: _elapsed < SpaceSeconds ? "SPACE VIEW"
: _elapsed < SpaceSeconds + DescentSeconds ? "DESCENT"
: "GROUND WALK";
public void Stop() => _stopped = true;
public void Update(double deltaSeconds, FlyCamera camera)
{
if (!IsRunning || !double.IsFinite(deltaSeconds) || deltaSeconds <= 0) return;
_elapsed = System.Math.Min(TotalSeconds, _elapsed + System.Math.Min(deltaSeconds, .1));
UniversePosition proposed;
DVector3 radial;
DVector3 forward;
DVector3 up;
var finalRadius = TerrainHeight.Sample(_planet, _targetRadial) + 1.8;
if (_elapsed <= SpaceSeconds)
{
radial = _spaceRadial;
proposed = _body.Add(radial * (_planet.RadiusMeters * 4.8), _cellSize);
forward = -radial;
up = _spaceUp;
}
else if (_elapsed < SpaceSeconds + DescentSeconds)
{
var t = System.Math.Clamp((_elapsed - SpaceSeconds) / DescentSeconds, 0, 1);
// Keep the long, high-speed part of the approach far above the surface.
// The final 10 km get their own slow curve so collision resolution never
// has to snap a fast-moving camera down onto the ground.
var startRadius = _planet.RadiusMeters * 4.8;
var brakingRadius = finalRadius + BrakingAltitudeMeters;
var radius = t < .5
? startRadius + (brakingRadius - startRadius) * Smooth(t * 2)
: finalRadius + BrakingAltitudeMeters * (1 - SmootherStep((t - .5) * 2));
radial = _targetRadial;
proposed = _body.Add(radial * radius, _cellSize);
var landingLook = (_orbitBitangent * System.Math.Cos(.25) - radial * System.Math.Sin(.25)).Normalized();
forward = Slerp(-radial, landingLook, SmootherStep(System.Math.Clamp((t - .28) / .72, 0, 1)));
up = (radial - forward * DVector3.Dot(radial, forward)).Normalized();
}
else
{
var t = System.Math.Clamp((_elapsed - SpaceSeconds - DescentSeconds) / SurfaceSeconds, 0, 1);
var angle = t * System.Math.Tau;
var walkRadius = 16 * Smooth(t); // Starts at the exact landing point without a lateral jump.
var orbitAngle = walkRadius / _planet.RadiusMeters;
radial = (_targetRadial * System.Math.Cos(orbitAngle)
+ (_orbitTangent * System.Math.Cos(angle) + _orbitBitangent * System.Math.Sin(angle)) * System.Math.Sin(orbitAngle)).Normalized();
var terrainRadius = TerrainHeight.Sample(_planet, radial);
var radius = System.Math.Max(terrainRadius + 1.8, _planet.RadiusMeters + 1.8);
proposed = _body.Add(radial * radius, _cellSize);
var walkingDirection = (_orbitTangent * -System.Math.Sin(angle) + _orbitBitangent * System.Math.Cos(angle)).Normalized();
var headTurn = .48 * System.Math.Sin(t * System.Math.Tau * 1.5);
var pitch = -.25 + .18 * System.Math.Sin(t * System.Math.Tau * 2);
var lookingDirection = RotateAround(walkingDirection, radial, headTurn);
forward = (lookingDirection * System.Math.Cos(pitch) + radial * System.Math.Sin(pitch)).Normalized();
up = (radial - forward * DVector3.Dot(radial, forward)).Normalized();
}
if (up.LengthSquared < .1) up = Perpendicular(forward);
var collision = PlanetCollisionSolver.Sweep(camera.Position, proposed, _systemCenter, _system.Planets,
_cellSize, cameraRadiusMeters: 1.5, samples: 32);
if (_elapsed >= TotalSeconds)
{
camera.SetPlayerPose(_returnPosition, _returnForward, _returnUp);
return;
}
var finalPosition = proposed;
if (collision.Collided)
{
var contactRadial = collision.Position.RelativeTo(_body, _cellSize).Normalized();
finalPosition = _body.Add(contactRadial * (TerrainHeight.Sample(_planet, contactRadial) + 1.8), _cellSize);
}
camera.SetPlayerPose(finalPosition, forward, up);
}
public void RecordFrame(double deltaSeconds)
{
if (_stopped || _elapsed >= TotalSeconds && _completionFrameRecorded || !double.IsFinite(deltaSeconds) || deltaSeconds <= 0)
return;
var frameSeconds = System.Math.Clamp(deltaSeconds, .0001, 1);
CurrentFps = 1 / frameSeconds;
_frameRates.Add(CurrentFps);
_renderElapsed += frameSeconds;
_sampleElapsed += frameSeconds;
_sampleFpsSum += CurrentFps;
_sampleFrameCount++;
if (_sampleElapsed >= GraphSampleSeconds || _elapsed >= TotalSeconds)
{
_graph.Add(_sampleFrameCount == 0 ? CurrentFps : _sampleFpsSum / _sampleFrameCount);
_sampleElapsed = 0;
_sampleFpsSum = 0;
_sampleFrameCount = 0;
GraphRevision++;
}
if (_elapsed >= TotalSeconds) _completionFrameRecorded = true;
}
private static double Smooth(double t) => t * t * (3 - 2 * t);
private static double SmootherStep(double t) => t * t * t * (t * (t * 6 - 15) + 10);
private static DVector3 Perpendicular(DVector3 direction) =>
DVector3.Cross(direction, System.Math.Abs(direction.Y) < .9 ? new(0, 1, 0) : new DVector3(1, 0, 0)).Normalized();
private static DVector3 RotateAround(DVector3 vector, DVector3 axis, double angle) =>
vector * System.Math.Cos(angle) + DVector3.Cross(axis, vector) * System.Math.Sin(angle)
+ axis * DVector3.Dot(axis, vector) * (1 - System.Math.Cos(angle));
private static DVector3 Slerp(DVector3 from, DVector3 to, double t)
{
var dot = System.Math.Clamp(DVector3.Dot(from, to), -1, 1);
if (dot > .9995) return DVector3.Lerp(from, to, t).Normalized();
var angle = System.Math.Acos(dot);
var tangent = (to - from * dot).Normalized();
if (tangent.LengthSquared < .1) tangent = Perpendicular(from);
return (from * System.Math.Cos(angle * t) + tangent * System.Math.Sin(angle * t)).Normalized();
}
}

View File

@@ -0,0 +1,379 @@
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.Physics;
using Universe.Core.World;
using Universe.Core.Configuration;
namespace Universe.App.Runtime;
public sealed class FlyCamera
{
private DVector3 _forward = new(0, 0, -1);
private DVector3 _up = new(0, 1, 0);
private FocusFlight? _flight;
private double _flightElapsed;
private double _defaultFieldOfView = 2 * System.Math.Atan(.67);
public double FieldOfViewRadians { get; private set; } = 2 * System.Math.Atan(.67);
public FlyCamera(UniversePosition initialPosition, double cellSizeMeters)
{
Position = initialPosition;
CellSizeMeters = cellSizeMeters;
SpeedMetersPerSecond = 250_000;
}
public UniversePosition Position { get; private set; }
public double CellSizeMeters { get; }
public double SpeedMetersPerSecond { get; private set; }
public bool IsFocusing => _flight is not null;
public DVector3 Forward => _forward;
public DVector3 Right => DVector3.Cross(_forward, _up).Normalized();
public DVector3 Up => DVector3.Cross(Right, _forward).Normalized();
public void Rotate(double mouseDeltaX, double mouseDeltaY)
{
if (IsFocusing) return;
_forward = RotateAround(_forward, Up, -mouseDeltaX * 0.0022).Normalized();
var axis = Right;
var beforePitchUp = Up;
_forward = RotateAround(_forward, axis, -mouseDeltaY * 0.0022).Normalized();
_up = RotateAround(beforePitchUp, axis, -mouseDeltaY * 0.0022).Normalized();
}
public void AdjustSpeed(double delta) => SpeedMetersPerSecond = System.Math.Clamp(SpeedMetersPerSecond * System.Math.Pow(1.7, delta), 0.1, UniverseScale.LightSpeedMetersPerSecond * 1_000_000);
public void SetPlayerPose(UniversePosition position, DVector3 forward, DVector3 up)
{
_flight = null;
Position = position; _forward = forward.Normalized(); _up = up.Normalized();
}
public void SetInitialView(StarSystemData system)
{
var planet = system.Planets[0];
var center = UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters);
var sun = (system.Stars[0].PositionMeters - planet.PositionMeters).Normalized();
var side = Perpendicular(sun);
var radial = (sun * 0.42 + side * 0.907).Normalized();
Position = center.Add(planet.PositionMeters + radial * (planet.RadiusMeters * 2.85), CellSizeMeters);
_forward = -radial;
_up = new(0, 1, 0);
}
public void SetInitialView(StarSystemData system, StartupConfiguration startup)
{
var planet = system.Planets.FirstOrDefault(p => p.Id == startup.PlanetId)
?? throw new InvalidDataException($"Startup planet '{startup.PlanetId}' does not exist in system '{system.Id}'.");
var longitude = startup.LongitudeDegrees * System.Math.PI / 180;
var latitude = startup.LatitudeDegrees * System.Math.PI / 180;
var bodyDirection = new DVector3(System.Math.Cos(latitude) * System.Math.Cos(longitude),
System.Math.Sin(latitude), System.Math.Cos(latitude) * System.Math.Sin(longitude));
var radial = planet.Orientation.ToWorld(bodyDirection).Normalized();
var distance = System.Math.Max(planet.RadiusMeters * startup.DistanceInRadii, planet.MaximumTerrainRadiusMeters + 10);
var center = UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters);
Position = center.Add(planet.PositionMeters + radial * distance, CellSizeMeters);
_forward = -radial;
_up = new(0, 1, 0);
if (System.Math.Abs(DVector3.Dot(_forward, _up)) > 0.999) _up = Perpendicular(_forward);
SpeedMetersPerSecond = startup.SpeedMetersPerSecond;
_defaultFieldOfView = FieldOfViewRadians = startup.FieldOfViewDegrees * System.Math.PI / 180;
_flight = null;
}
public void RequestFocus(IEnumerable<StarSystemData> systems) => RequestPlanetView(systems, false);
public void RequestFocus(IEnumerable<StarSystemData> systems, string planetId) => RequestPlanetView(systems, false, planetId);
public void RequestSurfaceView(IEnumerable<StarSystemData> systems) => RequestPlanetView(systems, true);
public void RequestCloudView(IEnumerable<StarSystemData> systems)
{
var available = systems.ToArray();
var target = available.SelectMany(s => s.Planets.Where(p => p.Clouds is { Enabled: true, Coverage: > 0 }).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length);
if (target.Planet is null) return;
var planet = target.Planet; var cloud = planet.Clouds!;
var body = World(target.System, planet.PositionMeters);
var sun = (target.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
var side = Perpendicular(sun); var across = DVector3.Cross(sun, side);
var current = Position.RelativeTo(body, CellSizeMeters).Normalized();
var radial = (sun * .4 + side * .9165).Normalized(); var best = double.NegativeInfinity;
for (var i = 0; i < 120; i++)
{
var angle = i * System.Math.Tau / 120;
var candidate = (sun * .4 + (side * System.Math.Cos(angle) + across * System.Math.Sin(angle)) * .9165).Normalized();
var score = DVector3.Dot(candidate, current) + (planet.OceanMask?.Sample(planet.Orientation.ToBody(candidate)) ?? 0) * 2;
if (score <= best) continue;
radial = candidate; best = score;
}
var height = System.Math.Max(planet.RadiusMeters + cloud.BaseAltitudeMeters + cloud.ThicknessMeters + 500, TerrainHeight.Sample(planet, radial) + 500);
var forward = ((sun - radial * DVector3.Dot(sun, radial)).Normalized() - radial * .22).Normalized();
Departure? departure = null;
var escape = available.SelectMany(s => s.Planets.Where(p => !p.IsGasPlanet).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length / p.Planet.RadiusMeters);
if (escape.Planet is { } solid)
{
var origin = World(escape.System, solid.PositionMeters);
var start = Position.RelativeTo(origin, CellSizeMeters);
if (start.Length < solid.MaximumTerrainRadiusMeters * 1.03)
departure = new(origin, start.Normalized(), body.Add(radial * height, CellSizeMeters).RelativeTo(origin, CellSizeMeters).Normalized(), start.Length, solid.MaximumTerrainRadiusMeters * 1.04);
}
BeginFlight(body, radial, height, System.Math.Max(height * 1.04, planet.MaximumTerrainRadiusMeters * 1.04), forward, radial, 100, departure: departure);
}
public void RequestOceanView(IEnumerable<StarSystemData> systems)
{
var available = systems.ToArray();
var target = available.SelectMany(s => s.Planets.Where(p => p.Ocean is { Enabled: true }).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length);
if (target.Planet is null) return;
var planet = target.Planet; var ocean = planet.Ocean!;
var body = World(target.System, planet.PositionMeters);
var sun = (target.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
var side = Perpendicular(sun); var bitangent = DVector3.Cross(sun, side);
var currentRadial = Position.RelativeTo(body, CellSizeMeters).Normalized();
var radial = DVector3.Zero; var score = double.NegativeInfinity;
for (var i = 0; i < 120; i++)
{
var angle = i * System.Math.Tau / 120;
var candidate = ((side * System.Math.Cos(angle) + bitangent * System.Math.Sin(angle)) * .925 + sun * .38).Normalized();
if (planet.OceanMask is { } mask && mask.Sample(planet.Orientation.ToBody(candidate)) < .99) continue;
if (TerrainHeight.Sample(planet, candidate) > planet.RadiusMeters + ocean.SeaLevelMeters - 3) continue;
var value = DVector3.Dot(candidate, currentRadial);
if (value <= score) continue;
radial = candidate; score = value;
}
if (radial == DVector3.Zero) return;
var forward = ((sun - radial * DVector3.Dot(sun, radial)).Normalized() - radial * .085).Normalized();
Departure? departure = null;
var escape = available.SelectMany(s => s.Planets.Where(p => !p.IsGasPlanet).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length / p.Planet.RadiusMeters);
if (escape.Planet is { } solid)
{
var origin = World(escape.System, solid.PositionMeters);
var start = Position.RelativeTo(origin, CellSizeMeters);
if (start.Length < solid.MaximumTerrainRadiusMeters * 1.03)
departure = new(origin, start.Normalized(), body.Add(radial * planet.RadiusMeters, CellSizeMeters).RelativeTo(origin, CellSizeMeters).Normalized(), start.Length, solid.MaximumTerrainRadiusMeters * 1.04);
}
BeginFlight(body, radial, planet.RadiusMeters + ocean.SeaLevelMeters + System.Math.Max(6, ocean.WaveAmplitudeMeters * 4),
planet.MaximumTerrainRadiusMeters * 1.04, forward, radial, 10, 6, departure: departure);
}
public void RequestRingView(IEnumerable<StarSystemData> systems)
{
var available = systems.ToArray();
var nearest = available.SelectMany(s => s.Planets.Where(p => p.Rings is not null).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length);
if (nearest.Planet is null) return;
var planet = nearest.Planet; var rings = planet.Rings!; var preview = rings.Preview;
var azimuth = preview.AzimuthDegrees * System.Math.PI / 180;
var radius = rings.InnerRadiusMeters + (rings.OuterRadiusMeters - rings.InnerRadiusMeters) * preview.RadiusFraction;
var offset = new DVector3(radius * System.Math.Cos(azimuth), preview.HeightMeters, radius * System.Math.Sin(azimuth));
var inward = new DVector3(-System.Math.Cos(azimuth), 0, -System.Math.Sin(azimuth));
var yaw = RotateAround(inward, new(0, 1, 0), preview.YawOffsetDegrees * System.Math.PI / 180);
var pitch = preview.PitchDegrees * System.Math.PI / 180;
var forward = planet.Orientation.ToWorld(yaw * System.Math.Cos(pitch) + new DVector3(0, System.Math.Sin(pitch), 0));
var escapeBody = available.SelectMany(s => s.Planets.Where(p => !p.IsGasPlanet).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length / p.Planet.RadiusMeters);
Departure? departure = null;
var destination = World(nearest.System, planet.PositionMeters);
if (escapeBody.Planet is { } solid)
{
var origin = World(escapeBody.System, solid.PositionMeters);
var start = Position.RelativeTo(origin, CellSizeMeters);
if (start.Length < solid.MaximumTerrainRadiusMeters * 6)
departure = new(origin, start.Normalized(), destination.RelativeTo(origin, CellSizeMeters).Normalized(), start.Length, solid.MaximumTerrainRadiusMeters * 6);
}
var up = RotateAround(planet.Orientation.ToWorld(new(0, 1, 0)), forward, preview.RollDegrees * System.Math.PI / 180);
BeginFlight(destination, planet.Orientation.ToWorld(offset.Normalized()), offset.Length, rings.OuterRadiusMeters * 2.6,
forward, up, preview.SpeedMetersPerSecond,
preview.FlightSeconds, preview.FieldOfViewDegrees * System.Math.PI / 180, departure);
}
private void RequestPlanetView(IEnumerable<StarSystemData> systems, bool surface, string? targetPlanetId = null)
{
var candidates = systems.SelectMany(system => system.Planets.Select(planet => (System: system, Planet: planet)))
.Where(body => targetPlanetId is null || string.Equals(body.Planet.Id, targetPlanetId, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (candidates.Length == 0) return;
var nearest = candidates
.MinBy(body => World(body.System, body.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length - body.Planet.RadiusMeters);
var planet = nearest.Planet;
var bodyPosition = World(nearest.System, planet.PositionMeters);
var sun = (nearest.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
var side = Perpendicular(sun);
DVector3 radial, forward, up;
double finalRadius, speed;
if (surface)
{
var moon = nearest.System.Planets.FirstOrDefault(p => p.ParentPlanetIndex == planet.Index);
// A low sun reveals ridge relief. Choose a mountain shoulder instead of a featureless basin.
radial = side;
var score = double.NegativeInfinity;
for (var i = 0; i < 40; i++)
{
var candidate = (RotateAround(side, sun, i * System.Math.Tau / 40) * 0.9755 + sun * 0.22).Normalized();
if (planet.Terrain.HeightMap is { } map && map.Sample(planet.Orientation.ToBody(candidate)) < planet.Terrain.LandThreshold + planet.Terrain.LandBlend)
continue;
var normal = TerrainHeight.Normal(planet, candidate);
var relief = (1 - DVector3.Dot(normal, candidate)) * 0.20
+ (TerrainHeight.Sample(planet, candidate) - planet.RadiusMeters) / System.Math.Max(1, planet.TerrainAmplitudeMeters) * 0.80;
if (moon is not null)
{
var moonDirection = (moon.PositionMeters - planet.PositionMeters - candidate * planet.RadiusMeters).Normalized();
var elevation = DVector3.Dot(moonDirection, candidate);
relief += 1.4 * System.Math.Max(0, 1 - System.Math.Abs(elevation - 0.14) / 0.13);
}
if (relief <= score) continue;
score = relief;
radial = candidate;
}
var terrainTop = TerrainHeight.Sample(planet, radial);
var localTangent = Perpendicular(radial);
var localBitangent = DVector3.Cross(radial, localTangent);
// Clear neighboring ridges as well as the terrain directly under the camera.
foreach (var ring in new[] { 0.003, 0.007, 0.012, 0.020 })
for (var sample = 0; sample < 16; sample++)
{
var angle = sample * System.Math.Tau / 16;
var d = (radial + (localTangent * System.Math.Cos(angle) + localBitangent * System.Math.Sin(angle)) * ring).Normalized();
terrainTop = System.Math.Max(terrainTop, TerrainHeight.Sample(planet, d));
}
finalRadius = terrainTop + planet.RadiusMeters * 0.0015;
var horizontal = (sun - radial * DVector3.Dot(sun, radial)).Normalized();
var east = DVector3.Cross(radial, horizontal).Normalized();
var look = (horizontal * 0.22 + east * 0.975).Normalized();
if (moon is not null)
{
var toMoon = (moon.PositionMeters - planet.PositionMeters - radial * finalRadius).Normalized();
if (DVector3.Dot(toMoon, radial) > 0.02) look = (toMoon - radial * DVector3.Dot(toMoon, radial)).Normalized();
}
forward = (look - radial * 0.32).Normalized();
up = radial;
speed = 1_200;
}
else
{
radial = (sun * 0.42 + side * 0.907).Normalized();
finalRadius = System.Math.Max(planet.MaximumTerrainRadiusMeters * 2.85, (planet.Rings?.OuterRadiusMeters ?? 0) * 2.6);
forward = -radial;
up = new(0, 1, 0);
if (planet.Rings is { } rings)
{
var v = rings.Preview;
var pole = planet.Orientation.ToWorld(new(0, 1, 0));
var equatorSun = (sun - pole * DVector3.Dot(sun, pole)).Normalized();
var heading = RotateAround(equatorSun, pole, v.OverviewSunAngleDegrees * System.Math.PI / 180);
var elevation = v.OverviewElevationDegrees * System.Math.PI / 180;
radial = (heading * System.Math.Cos(elevation) + pole * System.Math.Sin(elevation)).Normalized();
finalRadius = rings.OuterRadiusMeters * v.OverviewDistanceInOuterRadii;
forward = -radial;
up = RotateAround(pole, forward, v.RollDegrees * System.Math.PI / 180);
}
speed = 250_000;
}
BeginFlight(bodyPosition, radial, finalRadius, System.Math.Max(planet.MaximumTerrainRadiusMeters * 5, finalRadius * 1.4), forward, up, speed);
}
public void RequestStarView(IEnumerable<StarSystemData> systems)
{
var nearest = systems.SelectMany(system => system.Stars.Select(star => (System: system, Star: star)))
.MinBy(body => World(body.System, body.Star.PositionMeters).RelativeTo(Position, CellSizeMeters).LengthSquared);
if (nearest.Star is null) return;
var bodyPosition = World(nearest.System, nearest.Star.PositionMeters);
var radial = Position.RelativeTo(bodyPosition, CellSizeMeters).Normalized();
BeginFlight(bodyPosition, radial, nearest.Star.RadiusMeters * 2.55, nearest.Star.RadiusMeters * 5, -radial, new(0, 1, 0), 10_000_000);
}
private UniversePosition World(StarSystemData system, DVector3 offset) => UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters).Add(offset, CellSizeMeters);
private void BeginFlight(UniversePosition body, DVector3 radial, double finalRadius, double approachRadius, DVector3 forward, DVector3 up, double speed,
double duration = 4.5, double? fieldOfView = null, Departure? departure = null)
{
var travelStart = departure is null ? Position : departure.Body.Add(departure.TargetRadial * departure.ClearanceRadius, CellSizeMeters);
var startRadial = travelStart.RelativeTo(body, CellSizeMeters).Normalized();
if (startRadial == DVector3.Zero) startRadial = -Forward;
_flight = new(travelStart, body, startRadial, radial, approachRadius, finalRadius, Forward, Up, forward, up, speed,
duration, FieldOfViewRadians, fieldOfView ?? _defaultFieldOfView, departure);
_flightElapsed = 0;
}
public void Update(double deltaSeconds, DVector3 movement, UniverseNeighborhood neighborhood)
{
if (deltaSeconds <= 0 || !double.IsFinite(deltaSeconds)) return;
var systems = neighborhood.NearbySystems.Append(neighborhood.ActiveSystem).ToArray();
if (_flight is { } f)
{
_flightElapsed += System.Math.Min(deltaSeconds, 0.1);
var t = System.Math.Clamp(_flightElapsed / f.Duration, 0, 1);
var travelT = f.Departure is null ? t : System.Math.Clamp((t - .28) / .72, 0, 1);
UniversePosition proposed;
if (f.Departure is { } departure && t < .28)
{
var direction = t < .12 ? departure.StartRadial : Slerp(departure.StartRadial, departure.TargetRadial, Smooth((t - .12) / .16));
var radius = t < .12 ? departure.StartRadius + (departure.ClearanceRadius - departure.StartRadius) * Smooth(t / .12) : departure.ClearanceRadius;
proposed = departure.Body.Add(direction * radius, CellSizeMeters);
}
else if (travelT < 0.38)
{
var waypoint = f.Body.Add(f.StartRadial * f.ApproachRadius, CellSizeMeters);
proposed = f.Start.Add(waypoint.RelativeTo(f.Start, CellSizeMeters) * Smooth(travelT / 0.38), CellSizeMeters);
}
else if (travelT < 0.7)
{
var direction = Slerp(f.StartRadial, f.TargetRadial, Smooth((travelT - 0.38) / 0.32));
proposed = f.Body.Add(direction * f.ApproachRadius, CellSizeMeters);
}
else
{
var radius = f.ApproachRadius + (f.FinalRadius - f.ApproachRadius) * Smooth((travelT - 0.7) / 0.3);
proposed = f.Body.Add(f.TargetRadial * radius, CellSizeMeters);
}
var result = SweepNeighborhood(Position, proposed, systems);
Position = result.Position;
_forward = Slerp(f.StartForward, f.TargetForward, Smooth(t));
FieldOfViewRadians = f.StartFov + (f.TargetFov - f.StartFov) * Smooth(t);
var up = Slerp(f.StartUp, f.TargetUp, Smooth(t));
_up = (up - _forward * DVector3.Dot(up, _forward)).Normalized();
if (_up.LengthSquared < 0.1) _up = Perpendicular(_forward);
if (result.Collided || t >= 1)
{
_flight = null;
SpeedMetersPerSecond = f.FinalSpeed;
}
return;
}
if (movement.LengthSquared < 1e-12) return;
var distance = System.Math.Min(SpeedMetersPerSecond * System.Math.Min(deltaSeconds, 0.1), CellSizeMeters * 0.45);
var end = Position.Add(movement.Normalized() * distance, CellSizeMeters);
Position = SweepNeighborhood(Position, end, systems).Position;
}
private CollisionResult SweepNeighborhood(UniversePosition start, UniversePosition end, IReadOnlyList<StarSystemData> systems)
{
var best = new CollisionResult(end, false, -1);
var bestDistance = double.PositiveInfinity;
foreach (var system in systems)
{
var result = PlanetCollisionSolver.Sweep(start, end, UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters), system.Planets, CellSizeMeters);
if (!result.Collided) continue;
var distance = result.Position.RelativeTo(start, CellSizeMeters).LengthSquared;
if (distance >= bestDistance) continue;
best = result; bestDistance = distance;
}
return best;
}
private static double Smooth(double t) => t * t * (3 - 2 * t);
private static DVector3 Perpendicular(DVector3 d) => DVector3.Cross(d, System.Math.Abs(d.Y) < 0.9 ? new(0, 1, 0) : new DVector3(1, 0, 0)).Normalized();
private static DVector3 RotateAround(DVector3 v, DVector3 axis, double angle) => v * System.Math.Cos(angle) + DVector3.Cross(axis, v) * System.Math.Sin(angle) + axis * DVector3.Dot(axis, v) * (1 - System.Math.Cos(angle));
private static DVector3 Slerp(DVector3 a, DVector3 b, double t)
{
var dot = System.Math.Clamp(DVector3.Dot(a, b), -1, 1);
if (dot > 0.9995) return DVector3.Lerp(a, b, t).Normalized();
var angle = System.Math.Acos(dot);
var tangent = (b - a * dot).Normalized();
if (tangent.LengthSquared < 0.1) tangent = Perpendicular(a);
return a * System.Math.Cos(angle * t) + tangent * System.Math.Sin(angle * t);
}
private sealed record FocusFlight(UniversePosition Start, UniversePosition Body, DVector3 StartRadial, DVector3 TargetRadial,
double ApproachRadius, double FinalRadius, DVector3 StartForward, DVector3 StartUp, DVector3 TargetForward, DVector3 TargetUp, double FinalSpeed,
double Duration, double StartFov, double TargetFov, Departure? Departure);
private sealed record Departure(UniversePosition Body, DVector3 StartRadial, DVector3 TargetRadial, double StartRadius, double ClearanceRadius);
}

View File

@@ -0,0 +1,136 @@
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.Physics;
using Universe.Core.World;
namespace Universe.App.Runtime;
/// <summary>First-person locomotion in a local frame aligned to the nearest solid planet.</summary>
public sealed class PlayerController
{
private const double StandHeight = 1.7, CrouchHeight = 1.0;
private readonly double _cellSize;
private DVector3 _heading, _velocity;
private double _pitch;
private bool _jumpHeld;
public UniversePosition Position { get; private set; }
public bool Grounded { get; private set; }
public bool Crouching { get; private set; }
public DVector3 Up { get; private set; }
public DVector3 Forward => (_heading * System.Math.Cos(_pitch) + Up * System.Math.Sin(_pitch)).Normalized();
public double EyeHeight => Crouching ? CrouchHeight : StandHeight;
public double Speed => _velocity.Length;
public PlayerController(UniversePosition position, DVector3 forward, DVector3 fallbackUp, double cellSize, UniverseNeighborhood neighborhood)
{
Position = position; _cellSize = cellSize;
var target = Nearest(neighborhood);
Up = target is { } t ? position.RelativeTo(t.Body, cellSize).Normalized() : fallbackUp.Normalized();
if (Up == DVector3.Zero) Up = new(0, 1, 0);
var projected = forward - Up * DVector3.Dot(forward, Up);
_heading = projected.LengthSquared > .0001 ? projected.Normalized() : Tangent(Up);
_pitch = System.Math.Clamp(System.Math.Asin(System.Math.Clamp(DVector3.Dot(forward.Normalized(), Up), -1, 1)), -1.5, 1.5);
}
public void Look(double dx, double dy)
{
var angle = -dx * .0022;
_heading = (_heading * System.Math.Cos(angle) + DVector3.Cross(Up, _heading) * System.Math.Sin(angle)).Normalized();
_pitch = System.Math.Clamp(_pitch - dy * .0022, -1.5, 1.5);
}
public void Update(double dt, double forward, double side, bool jump, bool crouch, UniverseNeighborhood neighborhood)
{
if (!double.IsFinite(dt) || dt <= 0) return;
dt = System.Math.Min(dt, .1);
var target = Nearest(neighborhood);
if (target is not { } t) { _jumpHeld = jump; return; }
var relative = Position.RelativeTo(t.Body, _cellSize);
var radial = relative.Normalized();
if (radial == DVector3.Zero) radial = Up;
Align(radial);
var surface = TerrainHeight.Sample(t.Planet, radial);
var clearance = relative.Length - surface;
var previousHeight = EyeHeight;
Crouching = crouch;
var eye = EyeHeight;
// Tangential motion acquires a tiny outward component as the local radial frame turns.
Grounded = clearance <= previousHeight + .08 && DVector3.Dot(_velocity, radial) <= .05;
if (Grounded && System.Math.Abs(eye - previousHeight) > .001)
{ Position = t.Body.Add(radial * (surface + eye), _cellSize); relative = Position.RelativeTo(t.Body, _cellSize); }
var right = DVector3.Cross(_heading, radial).Normalized();
var input = _heading * System.Math.Clamp(forward, -1, 1) + right * System.Math.Clamp(side, -1, 1);
if (input.LengthSquared > 1) input = input.Normalized();
var walkSpeed = Crouching ? 1.5 : 4.5;
var horizontal = input * walkSpeed;
var vertical = DVector3.Dot(_velocity, radial);
var gravity = 9.81 * System.Math.Clamp(t.Planet.RadiusMeters / UniverseScale.EarthRadiusMeters, .15, 2.5);
if (Grounded && jump && !_jumpHeld) { vertical = 5.2; Grounded = false; }
_jumpHeld = jump;
if (Grounded)
{
var proposed = relative + horizontal * dt;
var nextRadial = proposed.Normalized();
var nextSurface = TerrainHeight.Sample(t.Planet, nextRadial);
var rise = nextSurface - surface;
var move = horizontal.Length * dt;
// Allow small steps but prevent walking up steep cliffs.
if (rise > .45 || (move > .001 && rise / move > .85)) nextRadial = radial;
else if (rise < -.45)
{
Position = t.Body.Add(proposed.Normalized() * (surface + eye), _cellSize);
Grounded = false; vertical = 0;
}
if (Grounded)
{
Position = t.Body.Add(nextRadial * (TerrainHeight.Sample(t.Planet, nextRadial) + eye), _cellSize);
_velocity = horizontal; Align(nextRadial); return;
}
}
// Far above a planet, a bounded descent assist avoids hours of empty free fall.
var altitude = System.Math.Max(0, relative.Length - surface - eye);
if (altitude > 2000 && vertical <= 0)
vertical = -System.Math.Max(-vertical, System.Math.Min(altitude / 5, _cellSize * .02));
else vertical = System.Math.Max(vertical - gravity * dt, -180);
_velocity = horizontal + radial * vertical;
var destination = Position.Add(_velocity * dt, _cellSize);
var collision = PlanetCollisionSolver.Sweep(Position, destination,
UniversePosition.AtCellCenter(t.System.Coordinate, _cellSize), [t.Planet], _cellSize, eye,
_velocity.Length * dt < 100 ? 16 : 128);
Position = collision.Position;
if (collision.Collided)
{
var up = Position.RelativeTo(t.Body, _cellSize).Normalized();
if (DVector3.Dot(_velocity, up) < 0) _velocity -= up * DVector3.Dot(_velocity, up);
Grounded = true;
}
Align(Position.RelativeTo(t.Body, _cellSize).Normalized());
}
private void Align(DVector3 up)
{
if (up == DVector3.Zero) return;
var heading = _heading - up * DVector3.Dot(_heading, up);
_heading = heading.LengthSquared > .0001 ? heading.Normalized() : Tangent(up);
Up = up;
}
private static DVector3 Tangent(DVector3 up) => DVector3.Cross(System.Math.Abs(up.Y) < .9 ? new(0, 1, 0) : new DVector3(1, 0, 0), up).Normalized();
private (StarSystemData System, PlanetData Planet, UniversePosition Body)? Nearest(UniverseNeighborhood neighborhood)
{
(StarSystemData System, PlanetData Planet, UniversePosition Body)? best = null;
var closest = double.PositiveInfinity;
foreach (var system in neighborhood.NearbySystems.Append(neighborhood.ActiveSystem))
foreach (var planet in system.Planets)
{
if (planet.IsGasPlanet) continue;
var body = UniversePosition.AtCellCenter(system.Coordinate, _cellSize).Add(planet.PositionMeters, _cellSize);
var distance = Position.RelativeTo(body, _cellSize).Length - planet.RadiusMeters;
if (distance >= closest) continue;
closest = distance; best = (system, planet, body);
}
return best;
}
}