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,191 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
using Universe.Core.Math;
namespace Universe.App.Graphics;
/// <summary>Builds tiles on worker threads. A parent remains visible until all four children are ready.</summary>
internal sealed class AdaptivePlanetMesh : IDisposable
{
private static readonly SemaphoreSlim Workers = new(System.Math.Clamp(Environment.ProcessorCount - 2, 1, 4));
private readonly GL _gl;
private readonly PlanetData _planet;
private readonly Dictionary<TerrainPatchKey, Tile> _tiles = [];
private readonly CancellationTokenSource _cancel = new();
private int _frame;
private int _uploads;
private int _pending;
private DVector3 _forward;
private DVector3 _right;
private DVector3 _up;
private double _aspect;
private double _tanHalfFov;
private DVector3 _cameraDirection;
private double _horizonAngle;
private double _localSurfaceRadius;
private double _innerRadius;
private double _nearSurfaceDistance;
public int DrawnTiles { get; private set; }
public int DeepestLevel { get; private set; }
public int PendingTiles => _pending;
public int NearestLevel { get; private set; }
public double NearestDistance { get; private set; }
public AdaptivePlanetMesh(GL gl, PlanetData planet)
{
_gl = gl;
_planet = planet;
for (var face = 0; face < 6; face++)
{
var key = new TerrainPatchKey(face, 0, 0, 0);
var data = TerrainPatchBuilder.Build(planet, key);
_tiles[key] = new() { Data = data, Mesh = new(gl, 9, data.Vertices, data.Indices) };
}
}
public void Draw(DVector3 cameraLocal, DVector3 forward, DVector3 right, DVector3 up, int screenHeight, double aspect, double tanHalfFov, Action<DVector3, GpuMesh> draw)
{
_forward = forward; _right = right; _up = up; _aspect = aspect; _tanHalfFov = tanHalfFov;
_cameraDirection = cameraLocal.Normalized();
_localSurfaceRadius = TerrainHeight.Sample(_planet, _cameraDirection);
var altitude = System.Math.Abs(cameraLocal.Length - _localSurfaceRadius);
_nearSurfaceDistance = altitude < 1000 ? altitude + 20 : 0;
_innerRadius = TerrainVisibility.InnerRadius(_planet);
_horizonAngle = TerrainVisibility.HorizonAngle(cameraLocal, _innerRadius, _planet.MaximumTerrainRadiusMeters);
_frame++;
_uploads = 0;
foreach (var tile in _tiles.Values)
{
if (tile.Task is not { IsCompleted: true }) continue;
tile.Data = tile.Task.GetAwaiter().GetResult();
tile.Task = null;
_pending--;
}
DrawnTiles = 0;
DeepestLevel = 0;
NearestLevel = 0; NearestDistance = double.PositiveInfinity;
Span<TerrainPatchKey> roots = stackalloc TerrainPatchKey[6];
for (var face = 0; face < roots.Length; face++) roots[face] = new(face, 0, 0, 0);
SortNearest(roots, cameraLocal);
foreach (var root in roots) Visit(root, cameraLocal, screenHeight, draw);
if (_frame % 120 != 0) return;
foreach (var pair in _tiles.Where(pair => pair.Key.Level > 0 && pair.Value.Task is null && _frame - pair.Value.LastFrame > 360).ToArray())
{
pair.Value.Mesh?.Dispose();
_tiles.Remove(pair.Key);
}
}
private void Visit(TerrainPatchKey key, DVector3 camera, int screenHeight, Action<DVector3, GpuMesh> draw)
{
if (!Needed(key, camera)) return;
var radius = _planet.RadiusMeters;
var tile = _tiles[key];
tile.LastFrame = _frame;
var distance = Distance(key, camera);
var spacingPixels = radius * key.Width / TerrainPatchBuilder.Segments / System.Math.Max(distance, 1) * screenHeight / (2 * _tanHalfFov);
if (spacingPixels > TerrainVisibility.PixelTarget(distance) && key.Level < 19)
{
Span<TerrainPatchKey> buffer = stackalloc TerrainPatchKey[4];
var childCount = 0;
for (var i = 0; i < 4; i++)
{
var child = key.Child(i);
if (Needed(child, camera)) buffer[childCount++] = child;
}
var children = buffer[..childCount];
SortNearest(children, camera);
var ready = true;
foreach (var child in children) ready &= EnsureReady(child);
if (ready)
{
foreach (var child in children) Visit(child, camera, screenHeight, draw);
return;
}
}
if (GeometryVisible(tile.Data!, camera))
{
DrawnTiles++;
DeepestLevel = System.Math.Max(DeepestLevel, key.Level);
if (distance < NearestDistance) { NearestDistance = distance; NearestLevel = key.Level; }
draw(tile.Data!.Origin, tile.Mesh!);
}
}
private double Distance(TerrainPatchKey key, DVector3 camera) => TerrainVisibility.DistanceToPatch(key, camera, _localSurfaceRadius);
private bool Needed(TerrainPatchKey key, DVector3 camera)
=> Visible(key, camera) || (_nearSurfaceDistance > 0 && Distance(key, camera) <= _nearSurfaceDistance);
private void SortNearest(Span<TerrainPatchKey> keys, DVector3 camera)
{
for (var i = 1; i < keys.Length; i++)
{
var key = keys[i]; var distance = Distance(key, camera); var j = i;
while (j > 0 && Distance(keys[j - 1], camera) > distance) { keys[j] = keys[j - 1]; j--; }
keys[j] = key;
}
}
private bool Visible(TerrainPatchKey key, DVector3 camera)
{
var radius = _planet.RadiusMeters;
var outer = _planet.MaximumTerrainRadiusMeters;
// Include the recessed skirt, but keep its uncertainty radial instead of expanding
// the tile by the planet's full mountain height in every horizontal direction.
var spacing = radius * key.Width / TerrainPatchBuilder.Segments;
var skirt = spacing * 2 + System.Math.Min(_planet.TerrainAmplitudeMeters * .012, spacing * 2);
var lower = System.Math.Max(0, _innerRadius - skirt);
if (TerrainVisibility.OutsideFrustum(key, camera, _forward, _right, _up, _tanHalfFov, _aspect, lower, outer)) return false;
return !TerrainVisibility.BehindHorizon(key, _cameraDirection, _horizonAngle);
}
private bool GeometryVisible(TerrainPatchData data, DVector3 camera)
{
var toPatch = data.BoundsCenter - camera;
var z = DVector3.Dot(toPatch, _forward);
var bound = data.BoundsRadius;
var horizontalTan = _tanHalfFov * _aspect;
return z >= -bound && System.Math.Abs(DVector3.Dot(toPatch, _up)) <= z * _tanHalfFov + bound * System.Math.Sqrt(1 + _tanHalfFov * _tanHalfFov)
&& System.Math.Abs(DVector3.Dot(toPatch, _right)) <= z * horizontalTan + bound * System.Math.Sqrt(1 + horizontalTan * horizontalTan);
}
private bool EnsureReady(TerrainPatchKey key)
{
if (!_tiles.TryGetValue(key, out var tile))
{
if (_pending >= 16) return false;
tile = new() { LastFrame = _frame };
_tiles.Add(key, tile);
_pending++;
var token = _cancel.Token;
tile.Task = Task.Run(async () =>
{
await Workers.WaitAsync(token);
try { token.ThrowIfCancellationRequested(); return TerrainPatchBuilder.Build(_planet, key); }
finally { Workers.Release(); }
});
}
tile.LastFrame = _frame;
if (tile.Mesh is not null) return true;
if (tile.Data is null || _uploads >= 6) return false;
tile.Mesh = new(_gl, 9, tile.Data.Vertices, tile.Data.Indices);
_uploads++;
return true;
}
public void Dispose()
{
_cancel.Cancel();
foreach (var tile in _tiles.Values) tile.Mesh?.Dispose();
_tiles.Clear();
_cancel.Dispose();
}
private sealed class Tile
{
public TerrainPatchData? Data;
public Task<TerrainPatchData>? Task;
public GpuMesh? Mesh;
public int LastFrame;
}
}

View File

@@ -0,0 +1,156 @@
using System.Globalization;
using Silk.NET.OpenGL;
using Universe.App.Runtime;
namespace Universe.App.Graphics;
/// <summary>Compact live/results panel with a decimated frame-rate history graph.</summary>
internal sealed class BenchmarkOverlay : IDisposable
{
private readonly GpuMesh _panel;
private readonly GpuMesh _grid;
private readonly GpuMesh _curve;
private long _lastRevision = -1;
private int _lastTextTick = -1;
private string _lastPhase = string.Empty;
public BenchmarkOverlay(GL gl)
{
_panel = new(gl, 6, [], dynamic: true);
_grid = new(gl, 6, [], dynamic: true);
_curve = new(gl, 6, [], dynamic: true);
}
public void Draw(int width, int height, BenchmarkRunner benchmark)
{
var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.2);
var x = width - 402 * scale;
var y = 14 * scale;
const double logicalWidth = 388;
const double logicalHeight = 232;
var panelWidth = logicalWidth * scale;
var panelHeight = logicalHeight * scale;
var graphX = x + 17 * scale;
var graphY = y + 151 * scale;
var graphWidth = (logicalWidth - 34) * scale;
var graphHeight = 62 * scale;
var tick = (int)(benchmark.ElapsedSeconds * 5);
if (_lastRevision != benchmark.GraphRevision)
{
UpdateGraph(width, height, benchmark.GraphSamples, graphX, graphY, graphWidth, graphHeight, scale);
_lastRevision = benchmark.GraphRevision;
}
if (_lastTextTick != tick || _lastPhase != benchmark.Phase)
{
UpdatePanel(width, height, benchmark, x, y, panelWidth, panelHeight, scale);
_lastTextTick = tick;
_lastPhase = benchmark.Phase;
}
_panel.Draw(GLEnum.Triangles);
_grid.Draw(GLEnum.Lines);
_curve.Draw(GLEnum.LineStrip);
}
private void UpdatePanel(int width, int height, BenchmarkRunner benchmark, double x, double y,
double panelWidth, double panelHeight, double scale)
{
var vertices = new List<float>(20_000);
Rect(x, y, panelWidth, panelHeight, .018f, .028f, .05f);
Rect(x, y, 3 * scale, panelHeight, .13f, .55f, .8f);
Text(benchmark.IsRunning ? "BENCHMARK LIVE" : benchmark.Phase == "STOPPED" ? "BENCHMARK STOPPED" : "BENCHMARK COMPLETE",
x + 16 * scale, y + 11 * scale, 1.75 * scale, .76f, .91f, 1f);
Text($"PHASE {benchmark.Phase}", x + 16 * scale, y + 29 * scale, 1.32 * scale, .52f, .7f, .82f);
Text($"TARGET {benchmark.TargetLabel}", x + 16 * scale, y + 47 * scale,
1.25 * scale, .65f, .75f, .84f);
Text($"RADIUS {Number(benchmark.TargetEarthRadii)} R", x + 16 * scale, y + 64 * scale,
1.25 * scale, .65f, .75f, .84f);
Text($"TIME {Time(benchmark.ElapsedSeconds)} / {Time(benchmark.DurationSeconds)}", x + 16 * scale,
y + 81 * scale, 1.18 * scale, .65f, .75f, .84f);
Text($"CURRENT {Number(benchmark.CurrentFps)} FPS", x + 16 * scale, y + 98 * scale,
1.30 * scale, .84f, .92f, 1f);
Text($"AVERAGE {Number(benchmark.AverageFps)} FPS", x + 16 * scale, y + 115 * scale,
1.20 * scale, .7f, .83f, .91f);
Text($"MIN {Number(benchmark.MinimumFps)} 1 PCT LOW {Number(benchmark.OnePercentLowFps)}",
x + 16 * scale, y + 132 * scale, 1.02 * scale, .7f, .83f, .91f);
_panel.ReplaceVertices(vertices.ToArray());
void Rect(double px, double py, double w, double h, float r, float g, float b)
{
Vertex(px, py); Vertex(px + w, py); Vertex(px + w, py + h);
Vertex(px, py); Vertex(px + w, py + h); Vertex(px, py + h);
void Vertex(double vx, double vy)
{
vertices.Add(X(vx, width)); vertices.Add(Y(vy, height)); vertices.Add(0);
vertices.Add(r); vertices.Add(g); vertices.Add(b);
}
}
void Text(string value, double px, double py, double glyphScale, float r, float g, float b)
{
foreach (var character in value)
{
if (PauseMenu.Font.TryGetValue(character, out var glyph))
for (var row = 0; row < glyph.Length; row++)
for (var col = 0; col < glyph[row].Length; col++)
if (glyph[row][col] == '1') Rect(px + col * glyphScale, py + row * glyphScale,
glyphScale * .9, glyphScale * .9, r, g, b);
px += 6 * glyphScale;
}
}
}
private void UpdateGraph(int width, int height, IReadOnlyList<double> samples, double x, double y,
double graphWidth, double graphHeight, double scale)
{
var grid = new List<float>(1800);
var curve = new List<float>(samples.Count * 6);
var maximum = samples.Count == 0 ? 60 : System.Math.Max(60, System.Math.Ceiling(samples.Max() / 30) * 30);
maximum = System.Math.Min(maximum, 240);
for (var i = 0; i <= 4; i++)
{
var py = y + graphHeight * i / 4;
AddLine(grid, width, height, x, py, x + graphWidth, py, .12f, .19f, .25f);
}
for (var i = 0; i <= 8; i++)
{
var px = x + graphWidth * i / 8;
AddLine(grid, width, height, px, y, px, y + graphHeight, .075f, .12f, .17f);
}
for (var i = 0; i < samples.Count; i++)
{
var px = x + graphWidth * (samples.Count <= 1 ? .5 : (double)i / (samples.Count - 1));
var py = y + graphHeight * (1 - System.Math.Clamp(samples[i] / maximum, 0, 1));
curve.Add(X(px, width)); curve.Add(Y(py, height)); curve.Add(0);
curve.Add(.25f); curve.Add(.85f); curve.Add(1f);
}
_grid.ReplaceVertices(grid.ToArray());
_curve.ReplaceVertices(curve.ToArray());
}
private static void AddLine(List<float> vertices, int width, int height, double x1, double y1, double x2, double y2,
float r, float g, float b)
{
Add(x1, y1); Add(x2, y2);
void Add(double x, double y)
{
vertices.Add(X(x, width)); vertices.Add(Y(y, height)); vertices.Add(0);
vertices.Add(r); vertices.Add(g); vertices.Add(b);
}
}
private static float X(double x, int width) => (float)(2 * x / width - 1);
private static float Y(double y, int height) => (float)(1 - 2 * y / height);
private static string Number(double value) => value.ToString("F1", CultureInfo.GetCultureInfo("sv-SE"));
private static string Time(double seconds) =>
$"{(int)(seconds / 60):00}:{(int)(seconds % 60):00}";
public void Dispose()
{
_panel.Dispose();
_grid.Dispose();
_curve.Dispose();
}
}

View File

@@ -0,0 +1,28 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
internal sealed unsafe class CloudDensityTexture : IDisposable
{
private readonly GL _gl;
public uint Handle { get; }
public CloudDensityTexture(GL gl, CloudDensityMap? map)
{
_gl = gl;
Handle = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, Handle);
var samples = map?.Samples ?? [255];
fixed (byte* pixels = samples)
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R8, (uint)(map?.Width ?? 1), (uint)(map?.Height ?? 1), 0,
PixelFormat.Red, PixelType.UnsignedByte, pixels);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)(map is null ? GLEnum.Linear : GLEnum.LinearMipmapLinear));
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
if (map is not null) gl.GenerateMipmap(GLEnum.Texture2D);
}
public void Dispose() => _gl.DeleteTexture(Handle);
}

View File

@@ -0,0 +1,33 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
internal sealed unsafe class CloudNoiseTexture : IDisposable
{
private readonly GL _gl;
public uint Shape { get; }
public uint Detail { get; }
public CloudNoiseTexture(GL gl)
{
_gl = gl;
Shape = Upload(128, false);
Detail = Upload(64, true);
}
private uint Upload(int size, bool detail)
{
var handle = _gl.GenTexture();
_gl.BindTexture(GLEnum.Texture3D, handle);
var data = CloudNoise.Build(size, detail);
fixed (byte* p = data)
_gl.TexImage3D(GLEnum.Texture3D, 0, InternalFormat.Rgba8, (uint)size, (uint)size, (uint)size, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p);
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapT, (int)GLEnum.Repeat);
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapR, (int)GLEnum.Repeat);
_gl.GenerateMipmap(GLEnum.Texture3D);
return handle;
}
public void Dispose() { _gl.DeleteTexture(Shape); _gl.DeleteTexture(Detail); }
}

View File

@@ -0,0 +1,111 @@
using Silk.NET.OpenGL;
namespace Universe.App.Graphics;
internal sealed unsafe class GpuMesh : IDisposable
{
private readonly GL _gl;
private readonly uint _vao;
private readonly uint _vertexBuffer;
private readonly uint _indexBuffer;
private readonly int _strideFloats;
private readonly bool _hasIndices;
private int _vertexCount;
private int _indexCount;
private bool _disposed;
public GpuMesh(GL gl, int strideFloats, float[] vertices, uint[]? indices = null, bool dynamic = false)
{
_gl = gl;
_strideFloats = strideFloats;
_hasIndices = indices is { Length: > 0 };
_vao = gl.GenVertexArray();
_vertexBuffer = gl.GenBuffer();
_indexBuffer = _hasIndices ? gl.GenBuffer() : 0;
gl.BindVertexArray(_vao);
gl.BindBuffer(GLEnum.ArrayBuffer, _vertexBuffer);
UploadVertices(vertices, dynamic);
if (_hasIndices)
{
gl.BindBuffer(GLEnum.ElementArrayBuffer, _indexBuffer);
UploadIndices(indices!, dynamic);
}
ConfigureAttributes();
gl.BindVertexArray(0);
}
public void ReplaceVertices(float[] vertices, bool dynamic = true)
{
_gl.BindVertexArray(_vao);
_gl.BindBuffer(GLEnum.ArrayBuffer, _vertexBuffer);
UploadVertices(vertices, dynamic);
ConfigureAttributes();
_gl.BindVertexArray(0);
}
public void Draw(GLEnum primitive = GLEnum.Triangles)
{
_gl.BindVertexArray(_vao);
if (_hasIndices)
_gl.DrawElements(primitive, (uint)_indexCount, DrawElementsType.UnsignedInt, null);
else
_gl.DrawArrays(primitive, 0, (uint)_vertexCount);
_gl.BindVertexArray(0);
}
private void UploadVertices(float[] vertices, bool dynamic)
{
_vertexCount = vertices.Length / _strideFloats;
fixed (float* pointer = vertices)
{
_gl.BufferData(
GLEnum.ArrayBuffer,
(nuint)(vertices.Length * sizeof(float)),
pointer,
dynamic ? GLEnum.DynamicDraw : GLEnum.StaticDraw);
}
}
private void UploadIndices(uint[] indices, bool dynamic)
{
_indexCount = indices.Length;
fixed (uint* pointer = indices)
{
_gl.BufferData(
GLEnum.ElementArrayBuffer,
(nuint)(indices.Length * sizeof(uint)),
pointer,
dynamic ? GLEnum.DynamicDraw : GLEnum.StaticDraw);
}
}
private void ConfigureAttributes()
{
var stride = _strideFloats * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, (uint)stride, (void*)0);
if (_strideFloats >= 6)
{
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
}
if (_strideFloats >= 7)
{
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, _strideFloats >= 9 ? 3 : 1, VertexAttribPointerType.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vertexBuffer);
if (_indexBuffer != 0)
_gl.DeleteBuffer(_indexBuffer);
}
}

View File

@@ -0,0 +1,139 @@
using Silk.NET.OpenGL;
namespace Universe.App.Graphics;
/// <summary>Non-blocking GPU timestamp measurements. Results are collected several frames later.</summary>
internal sealed class GpuPassProfiler : IDisposable
{
private const int FramesInFlight = 4;
private sealed class QuerySeries
{
public readonly uint[] Starts = new uint[FramesInFlight];
public readonly uint[] Ends = new uint[FramesInFlight];
public readonly bool[] Pending = new bool[FramesInFlight];
public readonly long[] IssuedFrame = new long[FramesInFlight];
public readonly SortedDictionary<long, double> Recent = [];
public bool HasResult;
public double LatestMilliseconds;
public long LatestFrame = -1;
}
private readonly GL _gl;
private readonly Dictionary<string, QuerySeries> _series = new(StringComparer.Ordinal);
private readonly Stack<QuerySeries> _open = new();
private int _slot;
private long _frame;
private bool _enabled;
private bool _slotAvailable;
private bool _disposed;
public GpuPassProfiler(GL gl) => _gl = gl;
public void BeginFrame(bool enabled)
{
Poll();
_frame++;
_enabled = enabled;
_slot = (int)(_frame % FramesInFlight);
_slotAvailable = enabled && !_series.Values.Any(s => s.Pending[_slot]);
_open.Clear();
if (_slotAvailable) BeginPass("GPU FRAME");
}
public void EndFrame()
{
if (!_slotAvailable) return;
if (_open.Count != 1)
throw new InvalidOperationException($"Unbalanced GPU profiling scopes: {_open.Count - 1} render passes remain open.");
EndPass();
}
public void BeginPass(string name)
{
if (!_enabled || !_slotAvailable) return;
var queries = Get(name);
_gl.QueryCounter(queries.Starts[_slot], QueryCounterTarget.Timestamp);
_open.Push(queries);
}
public void EndPass()
{
if (!_enabled || !_slotAvailable) return;
if (_open.Count == 0) throw new InvalidOperationException("GPU profiling pass ended without a matching start.");
var queries = _open.Pop();
_gl.QueryCounter(queries.Ends[_slot], QueryCounterTarget.Timestamp);
queries.Pending[_slot] = true;
queries.IssuedFrame[_slot] = _frame;
}
public IReadOnlyList<KeyValuePair<string, double>> Results() => _series
.Where(p => p.Value.HasResult)
.Select(p => new KeyValuePair<string, double>(p.Key, p.Value.LatestMilliseconds))
.ToArray();
public bool TryGetAverageDifference(string baselineName, string reducedName, out double milliseconds)
{
milliseconds = 0;
if (!_series.TryGetValue(baselineName, out var baseline) || !_series.TryGetValue(reducedName, out var reduced)) return false;
var matching = baseline.Recent.Keys.Intersect(reduced.Recent.Keys).OrderDescending().Take(4).ToArray();
if (matching.Length == 0) return false;
milliseconds = matching.Average(frame => baseline.Recent[frame] - reduced.Recent[frame]);
return true;
}
private QuerySeries Get(string name)
{
if (_series.TryGetValue(name, out var found)) return found;
var created = new QuerySeries();
for (var i = 0; i < FramesInFlight; i++)
{
created.Starts[i] = _gl.GenQuery();
created.Ends[i] = _gl.GenQuery();
}
_series.Add(name, created);
return created;
}
private void Poll()
{
foreach (var queries in _series.Values)
for (var i = 0; i < FramesInFlight; i++)
{
if (!queries.Pending[i]) continue;
_gl.GetQueryObject(queries.Ends[i], GLEnum.QueryResultAvailable, out int available);
if (available == 0) continue;
_gl.GetQueryObject(queries.Starts[i], GLEnum.QueryResult, out ulong start);
_gl.GetQueryObject(queries.Ends[i], GLEnum.QueryResult, out ulong end);
if (end >= start && queries.IssuedFrame[i] >= queries.LatestFrame)
{
queries.LatestMilliseconds = (end - start) / 1_000_000d;
queries.LatestFrame = queries.IssuedFrame[i];
queries.HasResult = true;
queries.Recent[queries.IssuedFrame[i]] = queries.LatestMilliseconds;
while (queries.Recent.Count > 256) queries.Recent.Remove(queries.Recent.Keys.First());
}
queries.Pending[i] = false;
}
}
/// <summary>Releases per-system planet query objects when the streamed system changes.</summary>
public void Clear()
{
foreach (var queries in _series.Values)
for (var i = 0; i < FramesInFlight; i++)
{
_gl.DeleteQuery(queries.Starts[i]);
_gl.DeleteQuery(queries.Ends[i]);
}
_series.Clear();
_open.Clear();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Clear();
}
}

View File

@@ -0,0 +1,121 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
using Universe.Core.Math;
namespace Universe.App.Graphics;
/// <summary>Checks the actual GPU height field against the collision field; never reads the desktop.</summary>
internal static class GraphicsDiagnostics
{
public static unsafe void ValidatePlanetTerrain(GL gl, GpuMesh fullscreen, PlanetData planet, Action<ShaderProgram> bind)
{
const int size = 64;
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
using var program = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
"#version 330 core\n" + ShaderSources.Load("noise") + "\n" + ShaderSources.Load("planet-height") + """
in vec2 vUv;
out vec4 fragColor;
void main() { vec3 d=normalize(vec3(vUv*2.0-1.0,1.0)); vec3 b=bodyDirection(d); vec2 uv=planetUv(b);
fragColor=vec4(surfaceElevation(d),uv,heightMapValue(b)); }
""");
target.Bind(); gl.Disable(GLEnum.DepthTest);
program.Use(); bind(program); fullscreen.Draw();
var values = new float[size * size * 4];
fixed (float* data = values) gl.ReadPixels(0, 0, size, size, PixelFormat.Rgba, PixelType.Float, data);
var maximumError = 0d;
var maximumUvError = 0d;
var maximumMapError = 0d;
var worst = string.Empty;
for (var y = 0; y < size; y++)
for (var x = 0; x < size; x++)
{
var d = new DVector3((x + 0.5) / size * 2 - 1, (y + 0.5) / size * 2 - 1, 1).Normalized();
var cpu = TerrainHeight.Sample(planet, d) - planet.RadiusMeters;
var gpu = values[(y * size + x) * 4] * planet.RadiusMeters;
var body = planet.Orientation.ToBody(d);
var uv = HeightMap.Coordinates(body);
var map = planet.Terrain.HeightMap!.Sample(body);
maximumUvError = System.Math.Max(maximumUvError, System.Math.Max(System.Math.Abs(uv.U - values[(y * size + x) * 4 + 1]), System.Math.Abs(uv.V - values[(y * size + x) * 4 + 2])));
maximumMapError = System.Math.Max(maximumMapError, System.Math.Abs(map - values[(y * size + x) * 4 + 3]));
if (System.Math.Abs(cpu - gpu) > maximumError)
{
maximumError = System.Math.Abs(cpu - gpu);
worst = $"pixel {x},{y}: CPU {cpu:G6} m GPU {gpu:G6} m; UV {uv.U:G6},{uv.V:G6} vs {values[(y * size + x) * 4 + 1]:G6},{values[(y * size + x) * 4 + 2]:G6}; map {map:G6} vs {values[(y * size + x) * 4 + 3]:G6}";
}
}
Console.WriteLine($"Graphics validation: '{planet.Id}' heightmap + noise CPU/GPU error {maximumError:G6} m");
Console.WriteLine($"Map diagnostic UV error {maximumUvError:G6}, map error {maximumMapError:G6}; {worst}");
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
if (maximumError > System.Math.Max(0.5, planet.TerrainAmplitudeMeters * 0.002))
throw new InvalidOperationException($"GPU terrain for '{planet.Id}' does not match its collision heightmap.");
if (planet.Terrain.RegionalHeight is { } regional) ValidateRegionalTerrain(gl, fullscreen, planet, regional, bind);
}
private static unsafe void ValidateRegionalTerrain(GL gl, GpuMesh fullscreen, PlanetData planet, RegionalHeightMap region, Action<ShaderProgram> bind)
{
const int size = 64;
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
using var program = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
"#version 330 core\n" + ShaderSources.Load("noise") + "\n" + ShaderSources.Load("planet-height") + """
in vec2 vUv; out vec4 fragColor;
void main() {
float lon=radians(mix(uRegionBounds.x,uRegionBounds.z,vUv.x));
float lat=radians(mix(uRegionBounds.y,uRegionOther.x,vUv.y));
vec3 body=vec3(cos(lat)*cos(lon),sin(lat),-cos(lat)*sin(lon));
fragColor=vec4(surfaceElevation(transpose(mat3(uWorldToBody))*body),0,0,1);
}
""");
target.Bind(); gl.Disable(GLEnum.DepthTest); program.Use(); bind(program); fullscreen.Draw();
var pixels = new float[size * size * 4];
fixed (float* data = pixels) gl.ReadPixels(0, 0, size, size, PixelFormat.Rgba, PixelType.Float, data);
var maximumError = 0d;
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++)
{
var lon = (region.WestDegrees + (region.EastDegrees - region.WestDegrees) * (x + .5) / size) * System.Math.PI / 180;
var lat = (region.SouthDegrees + (region.NorthDegrees - region.SouthDegrees) * (y + .5) / size) * System.Math.PI / 180;
var body = new DVector3(System.Math.Cos(lat) * System.Math.Cos(lon), System.Math.Sin(lat), -System.Math.Cos(lat) * System.Math.Sin(lon));
var direction = planet.Orientation.ToWorld(body);
var cpu = TerrainHeight.Sample(planet, direction) - planet.RadiusMeters;
maximumError = System.Math.Max(maximumError, System.Math.Abs(cpu - pixels[(y * size + x) * 4] * planet.RadiusMeters));
}
Console.WriteLine($"Graphics validation: '{planet.Id}' regional heights CPU/GPU maximum error {maximumError:G6} m");
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
if (maximumError > 30) throw new InvalidOperationException("Regional GPU terrain differs from collision height by over 30 m.");
}
public static unsafe void ValidateTerrain(GL gl, GpuMesh fullscreen)
{
const int size = 64;
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
using var program = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
"#version 330 core\n" + ShaderSources.Load("noise") + """
in vec2 vUv;
out vec4 fragColor;
void main() { fragColor=vec4(terrainHeight(normalize(vec3(vUv*2.0-1.0,1.0)),vec3(3.125,17.25,6.5),9),preciseNoise3(vec3(vUv*9.0,4.7)),noise3(vec3(vUv*9.0,4.7)),1); }
""");
target.Bind();
gl.Disable(GLEnum.DepthTest);
program.Use(); program.Set("uNoise", 4);
fullscreen.Draw();
var values = new float[size * size * 4];
fixed (float* data = values)
gl.ReadPixels(0, 0, size, size, PixelFormat.Rgba, PixelType.Float, data);
var error = 0d;
var noiseError = 0d;
var filteredError = 0d;
for (var y = 0; y < size; y++)
for (var x = 0; x < size; x++)
{
var direction = new DVector3((x + 0.5) / size * 2 - 1, (y + 0.5) / size * 2 - 1, 1).Normalized();
var cpu = TerrainHeight.Elevation(direction, new(3.125, 17.25, 6.5));
error = System.Math.Max(error, System.Math.Abs(cpu - values[(y * size + x) * 4]));
var noise = ProceduralNoise.Sample(new DVector3((x + 0.5) / size * 9, (y + 0.5) / size * 9, 4.7));
noiseError = System.Math.Max(noiseError, System.Math.Abs(noise - values[(y * size + x) * 4 + 1]));
filteredError = System.Math.Max(filteredError, System.Math.Abs(noise - values[(y * size + x) * 4 + 2]));
}
Console.WriteLine($"Graphics validation: CPU/GPU terrain maximum normalized error {error:G6}");
Console.WriteLine($"Graphics validation: precise noise error {noiseError:G6}; hardware filtered noise error {filteredError:G6}; GPU {gl.GetStringS(GLEnum.Renderer)}");
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
if (error > 0.0003) throw new InvalidOperationException("GPU terrain does not match the collision surface.");
}
}

View File

@@ -0,0 +1,352 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Numerics;
using Silk.NET.OpenGL;
using Universe.Core.Generation;
using Universe.Core.World;
using Universe.Core.Configuration;
using Universe.Core.Math;
using Universe.App.Runtime;
namespace Universe.App.Graphics;
/// <summary>Offscreen tests of the production shaders; no desktop capture or mouse control.</summary>
internal static class GraphicsReport
{
public static void RunTerrainPerformance(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{
var system = neighborhood.ActiveSystem;
var planet = system.Planets.Single(p => p.Id == "mars");
var center = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
var camera = new FlyCamera(center, cellSize);
camera.SetInitialView(system, new StartupConfiguration { PlanetId = "mars" });
camera.RequestSurfaceView([system]);
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
var body = center.Add(planet.PositionMeters, cellSize);
var radial = camera.Position.RelativeTo(body, cellSize).Normalized();
camera.SetPlayerPose(body.Add(radial * (TerrainHeight.Sample(planet, radial) + 250), cellSize), camera.Forward, radial);
for (var i = 0; i < 600; i++)
{
renderer.Render(camera, neighborhood, 1d / 60);
if ((i + 1) % 100 == 0) Console.WriteLine($"Terrain warmup {i + 1}: tiles {renderer.TerrainTiles}, nearest level {renderer.TerrainNearestLevel}, deepest {renderer.TerrainDeepestLevel}, pending {renderer.TerrainPendingTiles}.");
}
gl.Finish();
renderer.ProfileFrame = true;
var totals = new Dictionary<string, double>();
for (var i = 0; i < 5; i++)
{
renderer.Render(camera, neighborhood, 1d / 60);
foreach (var (key, value) in renderer.FrameTimings) totals[key] = totals.GetValueOrDefault(key) + value / 5;
}
renderer.ProfileFrame = false;
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
WritePng(Path.Combine(directory, "terrain-performance.png"), width, height, Read(gl, width, height), false);
Console.WriteLine($"Terrain performance ({width}x{height}, synchronized): {string.Join(", ", totals.Select(p => $"{p.Key} {p.Value:F2} ms"))}; tiles {renderer.TerrainTiles}, nearest level {renderer.TerrainNearestLevel} at {renderer.TerrainNearestDistance:F1} m, deepest {renderer.TerrainDeepestLevel}, pending {renderer.TerrainPendingTiles}.");
}
public static void RunMenu(GL gl, UniverseRenderer renderer, FlyCamera camera, UniverseNeighborhood neighborhood, int width, int height)
{
renderer.Render(camera, neighborhood, 0); renderer.DrawMenu(0, false); gl.Finish();
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Menu GL error: {error}");
var pixels = Read(gl, width, height);
var dir = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(dir);
WritePng(Path.Combine(dir, "pause-menu.png"), width, height, pixels, false);
var scale = System.Math.Min(width / 1440d, height / 900d);
if (renderer.MenuHit(width * .5, height * .5 - 152 * scale, width, height) != 0
|| renderer.MenuHit(width * .5, height * .5 - 95 * scale, width, height) != 1
|| renderer.MenuHit(width * .5, height * .5 - 38 * scale, width, height) != 2
|| renderer.MenuHit(width * .5, height * .5 + 19 * scale, width, height) != 3
|| renderer.MenuHit(width * .5, height * .5 + 76 * scale, width, height) != 4
|| renderer.MenuHit(width * .5, height * .5 + 133 * scale, width, height) != 5
|| renderer.MenuHit(0, 0, width, height) != -1)
throw new InvalidOperationException("Menu hit testing failed.");
Console.WriteLine($"Pause menu rendered and hit testing passed. Image: {dir}");
}
public static void RunTerrain(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{
var system = neighborhood.ActiveSystem;
var earth = system.Planets.Single(p => p.Id == "earth");
var origin = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
var camera = new FlyCamera(origin, cellSize);
camera.SetInitialView(system, new StartupConfiguration { PlanetId = "earth", LongitudeDegrees = -7.5, LatitudeDegrees = 46.5,
DistanceInRadii = 1.003, SpeedMetersPerSecond = 1000 });
var radial = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Normalized();
var distance = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Length;
var altitude = distance - TerrainHeight.Sample(earth, radial);
var steps = (int)System.Math.Max(0, System.Math.Floor((altitude - 1000) / 100));
for (var i = 0; i < steps; i++) camera.Update(.1, camera.Forward, neighborhood);
var litSystem = system with { Stars = system.Stars.Select((s, i) => i == earth.HostStarIndex
? s with { PositionMeters = earth.PositionMeters + radial * UniverseScale.AstronomicalUnitMeters } : s).ToArray(),
Planets = system.Planets.Select(p => p.Id == earth.Id ? p with { Clouds = null, Ocean = null, OceanMask = null } : p).ToArray() };
var lit = neighborhood with { ActiveSystem = litSystem };
for (var i = 0; i < 60; i++) renderer.Render(camera, lit, .016);
renderer.Render(camera, lit, .016); gl.Finish();
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Terrain report GL error: {error}");
var textured = Read(gl, width, height);
var noMaterials = litSystem with { Planets = litSystem.Planets.Select(p => p.Id == earth.Id ? p with { Materials = p.Materials with { Enabled = false } } : p).ToArray() };
renderer.Render(camera, lit with { ActiveSystem = noMaterials }, .016); gl.Finish();
var plain = Read(gl, width, height);
var difference = textured.Zip(plain, (a, b) => System.Math.Abs(a - b)).Average();
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
WritePng(Path.Combine(directory, "earth-alps-materials.png"), width, height, textured, false);
var remaining = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Length - TerrainHeight.Sample(earth, radial);
if (difference < .0001) throw new InvalidOperationException($"Surface materials do not visibly change the near-ground scene (difference {difference:G5}, altitude {remaining:G5} m).");
Console.WriteLine($"Terrain report passed: regional terrain and visible material contribution {difference:G5}. Images: {directory}");
}
public static void RunClouds(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
var system = neighborhood.ActiveSystem;
var earth = system.Planets.Single(p => p.Id == "earth");
var origin = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
var camera = new FlyCamera(origin, cellSize);
camera.SetInitialView(system, new StartupConfiguration()); camera.RequestOceanView([system]);
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
var current = neighborhood;
float[] Capture(string name)
{
renderer.Render(camera, current, .1); gl.Finish();
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Cloud report GL error: {error}");
var pixels = Read(gl, width, height);
if (pixels.Any(v => !float.IsFinite(v))) throw new InvalidOperationException("Cloud scene produced non-finite pixels.");
WritePng(Path.Combine(directory, name), width, height, pixels, false);
return pixels;
}
float[] Layer()
{
var target = renderer.CloudLayer ?? throw new InvalidOperationException("Cloud volume was not rendered.");
target.Bind(); var pixels = Read(gl, target.Width, target.Height);
if (pixels.Any(v => !float.IsFinite(v))) throw new InvalidOperationException("Cloud volume produced non-finite radiance.");
var trans = pixels.Where((_, i) => i % 4 == 3).ToArray();
if (trans.Any(t => t < 0 || t > 1)) throw new InvalidOperationException("Invalid cloud transmittance.");
return pixels;
}
var day = Capture("earth-clouds-day.png"); var dayLayer = Layer();
if (dayLayer.Where((_, i) => i % 4 == 3).Min() > .98) throw new InvalidOperationException("Cloud layer has no visible density.");
var clear = system with { Planets = system.Planets.Select(p => p.Clouds is null ? p : p with { Clouds = p.Clouds with { Enabled = false } }).ToArray() };
current = neighborhood with { ActiveSystem = clear };
var clearPixels = Capture("earth-clouds-disabled.png");
if (day.Zip(clearPixels, (a, b) => System.Math.Abs(a - b)).Average() < .001) throw new InvalidOperationException("Disabling clouds did not change the scene.");
var radial = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Normalized();
var sunlight = (system.Stars[0].PositionMeters - earth.PositionMeters).Normalized();
var tangent = (sunlight - radial * DVector3.Dot(sunlight, radial)).Normalized();
var sunset = system with { Stars = system.Stars.Select((s, i) => i == earth.HostStarIndex ? s with
{ PositionMeters = earth.PositionMeters + (tangent + radial * .012).Normalized() * UniverseScale.AstronomicalUnitMeters } : s).ToArray() };
current = neighborhood with { ActiveSystem = sunset };
Capture("earth-clouds-sunset.png"); var sunsetLayer = Layer();
static double Warmth(float[] values) => values.Where((_, i) => i % 4 == 0).Sum(v => (double)v)
/ System.Math.Max(.00001, values.Where((_, i) => i % 4 == 2).Sum(v => (double)v));
var dayWarmth = Warmth(dayLayer); var sunsetWarmth = Warmth(sunsetLayer);
if (sunsetWarmth < dayWarmth * 1.1) throw new InvalidOperationException($"Cloud sunset is not warmer: day {dayWarmth}, sunset {sunsetWarmth}.");
current = neighborhood;
Capture("earth-clouds-wind-before.png"); var beforeWind = Layer();
var timer = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < 24; i++) renderer.Render(camera, current, .1);
gl.Finish(); timer.Stop(); var afterWind = Layer();
var motion = beforeWind.Where((_, i) => i % 4 == 3).Zip(afterWind.Where((_, i) => i % 4 == 3), (a, b) => System.Math.Abs(a - b)).Average();
if (motion < .000001) throw new InvalidOperationException("Cloud wind did not move the volume.");
camera.RequestCloudView([system]);
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
Capture("earth-clouds-above.png");
for (var i = 0; i < 350; i++) camera.Update(.1, -camera.Up, neighborhood);
Capture("earth-clouds-inside.png");
camera.RequestFocus([system], "earth");
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
for (var i = 0; i < 20; i++) renderer.Render(camera, current, .05);
Capture("earth-clouds-orbit.png");
var orbit = Layer(); var orbitTarget = renderer.CloudLayer!;
WritePng(Path.Combine(directory, "earth-clouds-orbit-layer.png"), orbitTarget.Width, orbitTarget.Height, orbit);
var alphaImage = orbit.Select((_, i) => i % 4 == 3 ? 1f : orbit[i / 4 * 4 + 3]).ToArray();
WritePng(Path.Combine(directory, "earth-clouds-orbit-transmission.png"), orbitTarget.Width, orbitTarget.Height, alphaImage, false);
double spatialDelta = 0; var samples = 0;
for (var y = orbitTarget.Height / 2 - 20; y < orbitTarget.Height / 2 + 20; y++)
for (var x = orbitTarget.Width / 2 - 30; x < orbitTarget.Width / 2 + 30; x++)
{
var i = (y * orbitTarget.Width + x) * 4 + 3;
spatialDelta += System.Math.Abs(orbit[i] - orbit[i + 4]); samples++;
}
spatialDelta /= samples;
if (spatialDelta > .12) throw new InvalidOperationException($"Cloud orbit view has excessive subpixel aliasing: {spatialDelta}.");
var thinCuts = 0;
for (var y = orbitTarget.Height / 2 - 95; y < orbitTarget.Height / 2 + 95; y++)
for (var x = orbitTarget.Width / 2 - 95; x < orbitTarget.Width / 2 + 95; x++)
{
var dx = x - orbitTarget.Width / 2; var dy = y - orbitTarget.Height / 2;
if (dx * dx + dy * dy > 10000) continue;
var i = (y * orbitTarget.Width + x) * 4 + 3;
if (orbit[i] > .95 && ((orbit[i - 4] < .6 && orbit[i + 4] < .6)
|| (orbit[i - orbitTarget.Width * 4] < .6 && orbit[i + orbitTarget.Width * 4] < .6))) thinCuts++;
}
if (thinCuts > 12) throw new InvalidOperationException($"Cloud depth reconstruction has thin pixel cuts: {thinCuts}.");
Console.WriteLine($"Cloud GPU report passed: sunset R/B {dayWarmth:F3} -> {sunsetWarmth:F3}, wind delta {motion:G5}, orbit variation {spatialDelta:G4}, finite ground/inside/orbit views. Full rendering {timer.Elapsed.TotalMilliseconds / 24:F1} ms/frame. Images: {directory}");
}
public static void RunOcean(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
var origin = UniversePosition.AtCellCenter(neighborhood.ActiveSystem.Coordinate, cellSize);
var camera = new FlyCamera(origin, cellSize);
camera.SetInitialView(neighborhood.ActiveSystem, new StartupConfiguration());
camera.RequestOceanView([neighborhood.ActiveSystem]);
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
for (var i = 0; i < 12; i++) renderer.Render(camera, neighborhood, 1d / 60);
float[] Capture(string name)
{
renderer.Render(camera, neighborhood, .1); gl.Finish();
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Ocean report GL error: {error}");
var pixels = Read(gl, width, height);
if (pixels.Any(v => !float.IsFinite(v))) throw new InvalidOperationException("Ocean produced non-finite pixels.");
WritePng(Path.Combine(directory, name), width, height, pixels, false);
return pixels;
}
var before = Capture("earth-ocean.png");
var timer = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < 10; i++) renderer.Render(camera, neighborhood, .1);
gl.Finish(); timer.Stop();
var after = Capture("earth-ocean-animated.png");
var difference = before.Zip(after, (a, b) => System.Math.Abs(a - b)).Average();
if (difference < .00001) throw new InvalidOperationException("Ocean animation did not change the rendered image.");
for (var i = 0; i < 18; i++) camera.Update(.1, -camera.Up, neighborhood);
Capture("earth-underwater.png");
camera.RequestFocus([neighborhood.ActiveSystem], "earth");
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
Capture("earth-ocean-orbit.png");
Console.WriteLine($"Ocean scene passed: finite frames, wave animation delta {difference:G5}, underwater and orbit views. Full rendering {timer.Elapsed.TotalMilliseconds / 10:F1} ms/frame. Images: {directory}");
}
public static void RunScene(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
var system = neighborhood.ActiveSystem;
var origin = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
var camera = new FlyCamera(origin, cellSize);
camera.SetInitialView(system, new StartupConfiguration()); camera.RequestRingView([system]);
for (var i = 0; i < 900; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
void Capture(string name)
{
renderer.Render(camera, neighborhood, 1d / 60); gl.Finish();
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Scene report GL error: {error}");
WritePng(Path.Combine(directory, name), width, height, Read(gl, width, height), false);
}
Capture("saturn-ring-scene.png");
var planet = system.Planets.Single(p => p.Id == "saturn");
var bodyCamera = planet.Orientation.ToBody(camera.Position.RelativeTo(origin, cellSize) - planet.PositionMeters);
var frame = new RingRockStreamer(new RingProfile(planet.Rings!)).Update(bodyCamera);
var rock = frame.Rocks.Where(r => r.Radius > .3).MaxBy(r => r.Radius / ((r.Position - bodyCamera).Length + 1));
var world = planet.PositionMeters + planet.Orientation.ToWorld(rock.Position);
var sun = (system.Stars[planet.HostStarIndex].PositionMeters - world).Normalized();
var side = DVector3.Cross(sun, new(0, 1, 0)).Normalized();
var radial = (sun * .62 + side * .78).Normalized();
var body = new PlanetData(0, "Diagnostic asteroid", world, rock.Radius, 0, 0, Vector3.One, Vector3.Zero, 0, 0, 0, 1) { Id = "diagnostic" };
camera.SetInitialView(system with { Planets = [body] }, new StartupConfiguration
{
PlanetId = "diagnostic",
DistanceInRadii = 3.8,
FieldOfViewDegrees = 50,
LongitudeDegrees = System.Math.Atan2(radial.Z, radial.X) * 180 / System.Math.PI,
LatitudeDegrees = System.Math.Asin(radial.Y) * 180 / System.Math.PI
});
Capture("asteroid-in-rings.png");
camera = new FlyCamera(origin.Add(new DVector3(30 * UniverseScale.AstronomicalUnitMeters, 0, 0), cellSize), cellSize);
camera.Rotate(-System.Math.PI / (2 * .0022), 0); Capture("sun-from-30au.png");
Console.WriteLine($"Integrated scene report passed: rings, asteroid close-up and sunlight at 30 AU. Images: {directory}");
}
public static void Run(GL gl, GpuMesh fullscreen, ShaderProgram rocks, ShaderProgram glow, uint emptyRing)
{
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
const int size = 768;
using (var fieldTarget = new RenderTarget(gl, 64, 64, fullPrecision: true))
using (var field = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"), "#version 330 core\n" + ShaderSources.Load("noise") + "uniform vec3 uRockMaterial;\n" + ShaderSources.Load("asteroid-shape") + "\nin vec2 vUv;out vec4 fragColor;void main(){vec3 d=normalize(vec3(vUv*2.0-1.0,1));fragColor=vec4(asteroidPosition(d,.271),asteroidHeight(d,.271));}"))
{
fieldTarget.Bind(); gl.Disable(GLEnum.DepthTest); field.Use(); field.Set("uNoise", 4); field.Set("uRockMaterial", new Vector3(.20f, .86f, .04f)); UniverseRenderer.BindAsteroidSettings(field, new()); fullscreen.Draw();
var values = Read(gl, 64, 64); var heights = values.Where((_, i) => i % 4 == 3).ToArray();
Console.WriteLine($"Asteroid field: height min {heights.Min()}, max {heights.Max()}; GL {gl.GetError()}");
Console.WriteLine($"Asteroid neighbor delta: {Enumerable.Range(0, 64 * 63).Max(i => MathF.Abs(heights[i + 64] - heights[i]))}");
}
using var asteroid = new RenderTarget(gl, size, size, true);
using var mesh = new InstancedAsteroidMesh(gl, 6);
var rotation = Quaternion.CreateFromYawPitchRoll(.3f, -.1f, 1.25f);
mesh.Upload([0, 0, 0, 1, .62f, .61f, .58f, .271f, rotation.X, rotation.Y, rotation.Z, rotation.W]);
asteroid.Bind(); gl.ClearColor(0, 0, 0, 1); gl.ClearDepth(1); gl.DepthMask(true);
gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); gl.Enable(GLEnum.DepthTest); gl.Disable(GLEnum.Blend);
rocks.Use();
rocks.SetMatrix("uViewProjection", Matrix4x4.CreatePerspectiveFieldOfView(50 * MathF.PI / 180, 1, .02f, 100000));
rocks.SetMatrix("uBodyToWorld", Matrix4x4.Identity); rocks.SetMatrix("uWorldToBody", Matrix4x4.Identity);
rocks.Set("uAnchorWorld", new Vector3(0, 0, -3.3f)); rocks.Set("uPixelScale", size / (2 * MathF.Tan(25 * MathF.PI / 180)));
rocks.Set("uFar", 100000f); rocks.Set("uRadius", 1f); rocks.Set("uCameraBody", new Vector3(0, 0, 20)); rocks.Set("uPolarScale", 1f);
rocks.Set("uNoise", 4); rocks.Set("uHasRings", 0); rocks.Set("uRingProfile", 7); rocks.Set("uRingShadowSoftness", .001f);
gl.ActiveTexture(GLEnum.Texture7); gl.BindTexture(GLEnum.Texture2D, emptyRing);
rocks.Set("uRockLod", new Vector3(600, 180, 3.5f)); rocks.Set("uRockFade", new Vector3(2600, 3500, 0));
rocks.Set("uRockMaterial", new Vector3(.20f, .86f, .04f)); rocks.Set("uRockLighting", new Vector3(.85f, .008f, 0));
rocks.Set("uLightCount", 1); rocks.Set("uLightDirections[0]", Vector3.Normalize(new(-.75f, .55f, .45f)));
rocks.Set("uLightColors[0]", Vector3.One); rocks.Set("uLightFlux[0]", 1f);
UniverseRenderer.BindAsteroidSettings(rocks, new()); mesh.Draw();
var asteroidPixels = Read(gl, size, size);
if (asteroidPixels.Any(x => !float.IsFinite(x)) || asteroidPixels.Where((_, i) => i % 4 != 3).Max() < .1)
throw new InvalidOperationException("Asteroid shader produced invalid or empty output.");
WritePng(Path.Combine(directory, "asteroid-detail.png"), size, size, asteroidPixels);
gl.Finish(); var timer = System.Diagnostics.Stopwatch.StartNew();
gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); mesh.Draw(); gl.Finish(); timer.Stop();
Console.WriteLine($"Asteroid close-up GPU render: {timer.Elapsed.TotalMilliseconds:F2} ms at {size}x{size}.");
rocks.Set("uDebugNormals", 1); gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); mesh.Draw(); WritePng(Path.Combine(directory, "asteroid-normals.png"), size, size, Read(gl, size, size));
rocks.Set("uDebugNormals", 2); gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); mesh.Draw(); WritePng(Path.Combine(directory, "asteroid-solid.png"), size, size, Read(gl, size, size)); rocks.Set("uDebugNormals", 0);
using var source = new RenderTarget(gl, size, size, true); using var target = new RenderTarget(gl, size, size);
source.Bind(); gl.DepthMask(true); gl.ClearColor(0, 0, 0, 1); gl.ClearDepth(1);
gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
target.Bind(); gl.Disable(GLEnum.DepthTest); gl.DepthMask(false); glow.Use();
gl.ActiveTexture(GLEnum.Texture0); gl.BindTexture(GLEnum.Texture2D, source.Color);
gl.ActiveTexture(GLEnum.Texture1); gl.BindTexture(GLEnum.Texture2D, source.Depth);
glow.Set("uScene", 0); glow.Set("uDepth", 1); glow.Set("uAspect", 1f); glow.Set("uStarCount", 2);
var settings = new StarGlowSettings();
for (var i = 0; i < 2; i++)
{
glow.Set($"uStarPosition[{i}]", new Vector3(i == 0 ? .25f : .75f, i == 0 ? .65f : .35f, .6f));
var intensity = (float)settings.Attenuation(UniverseScale.AstronomicalUnitMeters * (i == 0 ? 1 : 30), 1);
glow.Set($"uStarShape[{i}]", new Vector3(.0015f, intensity, 1.2f / size));
glow.Set($"uStarHalo[{i}]", new Vector3(.035f, .3f, 1.2f)); glow.Set($"uStarRays[{i}]", new Vector3(.09f, 10, 70));
glow.Set($"uStarGhosts[{i}]", new Vector3(.14f, .42f, .2f)); glow.Set($"uStarColors[{i}]", i == 0 ? new Vector3(1, .94f, .8f) : new Vector3(.72f, .82f, 1));
glow.Set($"uStarRotation[{i}]", i * .35f);
}
fullscreen.Draw(); var lit = Read(gl, size, size);
float Peak(int cx, int cy)
{
var peak = 0f; for (var y = cy - 2; y <= cy + 2; y++) for (var x = cx - 2; x <= cx + 2; x++) peak = MathF.Max(peak, lit[(y * size + x) * 4]); return peak;
}
var near = Peak(size / 4, (int)(size * .65)); var far = Peak(size * 3 / 4, (int)(size * .35));
if (near < far * 2 || far < .03f) throw new InvalidOperationException($"Glow attenuation failed: near {near}, far {far}.");
WritePng(Path.Combine(directory, "sun-glow.png"), size, size, lit);
source.Bind(); gl.DepthMask(true); gl.ClearDepth(0); gl.Clear(ClearBufferMask.DepthBufferBit); gl.ClearDepth(1);
target.Bind(); gl.DepthMask(false); fullscreen.Draw(); var occluded = Read(gl, size, size);
if (occluded.Where((_, i) => i % 4 != 3).Max() > .00001f) throw new InvalidOperationException("Solar glare leaked through an occluder.");
Console.WriteLine($"Graphics report: asteroid LOD6 {IcosphereMesh.Build(6).Indices.Length / 3} triangles; glow 1 AU={near:G5}, 30 AU={far:G5}; occlusion passed. Images: {directory}");
gl.BindFramebuffer(GLEnum.Framebuffer, 0); gl.DepthMask(true); gl.Enable(GLEnum.DepthTest);
}
private static unsafe float[] Read(GL gl, int width, int height)
{ var result = new float[width * height * 4]; fixed (float* p = result) gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.Float, p); return result; }
private static void WritePng(string path, int width, int height, float[] hdr, bool toneMap = true)
{
using var output = File.Create(path); output.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 });
var header = new byte[13]; BinaryPrimitives.WriteInt32BigEndian(header, width); BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height); header[8] = 8; header[9] = 2;
Chunk(output, "IHDR", header);
using var compressed = new MemoryStream();
using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, true))
for (var y = height - 1; y >= 0; y--)
{
zlib.WriteByte(0);
for (var x = 0; x < width; x++) for (var c = 0; c < 3; c++)
{
var value = MathF.Max(0, hdr[(y * width + x) * 4 + c]);
if (toneMap) { value *= .88f; value = System.Math.Clamp((value * (2.51f * value + .03f)) / (value * (2.43f * value + .59f) + .14f), 0, 1); value = MathF.Pow(value, 1 / 2.2f); }
zlib.WriteByte((byte)(System.Math.Clamp(value, 0, 1) * 255 + .5f));
}
}
Chunk(output, "IDAT", compressed.ToArray()); Chunk(output, "IEND", []);
}
private static void Chunk(Stream output, string type, byte[] data)
{
var name = System.Text.Encoding.ASCII.GetBytes(type); var length = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(length, data.Length);
output.Write(length); output.Write(name); output.Write(data); uint crc = 0xffffffff;
foreach (var b in name.Concat(data)) { crc ^= b; for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xedb88320 : crc >> 1; }
BinaryPrimitives.WriteUInt32BigEndian(length, ~crc); output.Write(length);
}
}

View File

@@ -0,0 +1,40 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
internal sealed unsafe class InstancedAsteroidMesh : IDisposable
{
private readonly GL _gl;
private readonly uint _vao, _vertices, _instances, _indices;
private readonly uint _indexCount;
private int _count;
public InstancedAsteroidMesh(GL gl, int subdivision)
{
_gl = gl; var mesh = IcosphereMesh.Build(subdivision); _indexCount = (uint)mesh.Indices.Length;
_vao = gl.GenVertexArray(); _vertices = gl.GenBuffer(); _instances = gl.GenBuffer(); _indices = gl.GenBuffer();
gl.BindVertexArray(_vao); gl.BindBuffer(GLEnum.ArrayBuffer, _vertices);
fixed (float* p = mesh.Vertices) gl.BufferData(GLEnum.ArrayBuffer, (nuint)(mesh.Vertices.Length * 4), p, GLEnum.StaticDraw);
Attribute(gl, 0, 3, 24, 0); Attribute(gl, 1, 3, 24, 12);
gl.BindBuffer(GLEnum.ElementArrayBuffer, _indices);
fixed (uint* p = mesh.Indices) gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(mesh.Indices.Length * 4), p, GLEnum.StaticDraw);
ConfigureInstances(gl, _instances, 1); gl.BindVertexArray(0);
}
private static void Attribute(GL gl, uint location, int count, uint stride, int offset)
{ gl.EnableVertexAttribArray(location); gl.VertexAttribPointer(location, count, VertexAttribPointerType.Float, false, stride, (void*)offset); }
internal static void ConfigureInstances(GL gl, uint buffer, uint divisor)
{
gl.BindBuffer(GLEnum.ArrayBuffer, buffer);
Attribute(gl, 2, 3, 48, 0); Attribute(gl, 3, 1, 48, 12); Attribute(gl, 4, 3, 48, 16); Attribute(gl, 5, 1, 48, 28); Attribute(gl, 6, 4, 48, 32);
for (uint i = 2; i <= 6; i++) gl.VertexAttribDivisor(i, divisor);
}
public void Upload(float[] data)
{
_count = data.Length / 12; _gl.BindBuffer(GLEnum.ArrayBuffer, _instances);
fixed (float* p = data) _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(data.Length * 4), p, GLEnum.DynamicDraw);
}
public void Draw()
{ if (_count == 0) return; _gl.BindVertexArray(_vao); _gl.DrawElementsInstanced(GLEnum.Triangles, _indexCount, DrawElementsType.UnsignedInt, null, (uint)_count); _gl.BindVertexArray(0); }
public void Dispose()
{ _gl.DeleteVertexArray(_vao); _gl.DeleteBuffer(_vertices); _gl.DeleteBuffer(_instances); _gl.DeleteBuffer(_indices); }
}

View File

@@ -0,0 +1,24 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
internal sealed unsafe class NoiseTexture : IDisposable
{
private readonly GL _gl;
public uint Handle { get; }
public NoiseTexture(GL gl)
{
_gl = gl;
Handle = gl.GenTexture();
gl.BindTexture(GLEnum.Texture3D, Handle);
fixed (float* data = ProceduralNoise.PackedGradients)
gl.TexImage3D(GLEnum.Texture3D, 0, InternalFormat.Rgba32f, ProceduralNoise.Size, ProceduralNoise.Size, ProceduralNoise.Size, 0, PixelFormat.Rgba, PixelType.Float, data);
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMinFilter, (int)GLEnum.Linear);
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapT, (int)GLEnum.Repeat);
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapR, (int)GLEnum.Repeat);
}
public void Dispose() => _gl.DeleteTexture(Handle);
}

View File

@@ -0,0 +1,89 @@
using Silk.NET.OpenGL;
namespace Universe.App.Graphics;
/// <summary>Small resolution-independent menu using a built-in bitmap font.</summary>
internal sealed class PauseMenu : IDisposable
{
private readonly GpuMesh _mesh;
public int Selected { get; set; }
public PauseMenu(GL gl) => _mesh = new(gl, 6, [], dynamic: true);
private static readonly string[] Labels = ["PLAY", "FREE CAMERA", "DEBUG OFF", "BENCHMARK EARTH", "BENCHMARK UNKNOWN", "QUIT"];
internal static readonly Dictionary<char, string[]> Font = new()
{
['A']=["01110","10001","10001","11111","10001","10001","10001"], ['C']=["01111","10000","10000","10000","10000","10000","01111"],
['D']=["11110","10001","10001","10001","10001","10001","11110"], ['E']=["11111","10000","10000","11110","10000","10000","11111"],
['F']=["11111","10000","10000","11110","10000","10000","10000"], ['I']=["11111","00100","00100","00100","00100","00100","11111"],
['B']=["11110","10001","10001","11110","10001","10001","11110"], ['G']=["01111","10000","10000","10111","10001","10001","01111"],
['H']=["10001","10001","10001","11111","10001","10001","10001"], ['N']=["10001","11001","10101","10011","10001","10001","10001"],
['V']=["10001","10001","10001","10001","10001","01010","00100"], ['W']=["10001","10001","10001","10101","10101","10101","01010"],
['K']=["10001","10010","10100","11000","10100","10010","10001"],
['L']=["10000","10000","10000","10000","10000","10000","11111"], ['M']=["10001","11011","10101","10101","10001","10001","10001"],
['O']=["01110","10001","10001","10001","10001","10001","01110"], ['P']=["11110","10001","10001","11110","10000","10000","10000"],
['Q']=["01110","10001","10001","10001","10101","10010","01101"], ['R']=["11110","10001","10001","11110","10100","10010","10001"],
['S']=["01111","10000","10000","01110","00001","00001","11110"], ['T']=["11111","00100","00100","00100","00100","00100","00100"],
['U']=["10001","10001","10001","10001","10001","10001","01110"], ['Y']=["10001","10001","01010","00100","00100","00100","00100"],
['0']=["01110","10001","10011","10101","11001","10001","01110"], ['1']=["00100","01100","00100","00100","00100","00100","01110"],
['2']=["01110","10001","00001","00010","00100","01000","11111"], ['3']=["11110","00001","00001","01110","00001","00001","11110"],
['4']=["00010","00110","01010","10010","11111","00010","00010"], ['5']=["11111","10000","10000","11110","00001","00001","11110"],
['6']=["01110","10000","10000","11110","10001","10001","01110"], ['7']=["11111","00001","00010","00100","01000","01000","01000"],
['8']=["01110","10001","10001","01110","10001","10001","01110"], ['9']=["01110","10001","10001","01111","00001","00001","01110"],
[':']=["00000","00110","00110","00000","00110","00110","00000"], ['.']=["00000","00000","00000","00000","00000","00110","00110"],
[',']=["00000","00000","00000","00000","00110","00110","00100"], ['/']=["00001","00010","00010","00100","01000","01000","10000"],
['+']=["00000","00100","00100","11111","00100","00100","00000"], ['-']=["00000","00000","00000","11111","00000","00000","00000"]
};
private static (double Scale, double X, double Y) Layout(int width, int height)
{
var s = System.Math.Min(width / 1440d, height / 900d);
return (s, width / 2d, height / 2d);
}
public int Hit(double x, double y, int width, int height)
{
var (s, cx, cy) = Layout(width, height);
if (s <= 0 || x < cx - 190 * s || x > cx + 190 * s) return -1;
for (var i = 0; i < Labels.Length; i++)
if (y >= cy + (-174 + i * 57) * s && y <= cy + (-130 + i * 57) * s) return i;
return -1;
}
public void Draw(int width, int height, bool debugEnabled)
{
var (s, cx, cy) = Layout(width, height);
var data = new List<float>();
void Rect(double x, double y, double w, double h, float r, float g, float b)
{
float X(double n) => (float)(2 * n / width - 1);
float Y(double n) => (float)(1 - 2 * n / height);
void V(double px, double py) { data.Add(X(px)); data.Add(Y(py)); data.Add(0); data.Add(r); data.Add(g); data.Add(b); }
V(x,y); V(x+w,y); V(x+w,y+h); V(x,y); V(x+w,y+h); V(x,y+h);
}
void Text(string text, double centerX, double y, double unit, float r, float g, float b)
{
var x = centerX - text.Length * 6 * unit / 2;
foreach (var c in text)
{
if (Font.TryGetValue(c, out var glyph))
for (var row = 0; row < 7; row++) for (var col = 0; col < 5; col++)
if (glyph[row][col] == '1') Rect(x + col * unit, y + row * unit, unit * .9, unit * .9, r, g, b);
x += 6 * unit;
}
}
Rect(cx - 230*s, cy - 260*s, 460*s, 520*s, .025f, .04f, .075f);
Rect(cx - 230*s, cy - 260*s, 460*s, 4*s, .25f, .65f, .9f);
Text("PAUSED", cx, cy - 226*s, 5*s, .9f, .95f, 1);
for (var i = 0; i < Labels.Length; i++)
{
var selected = i == Selected;
var y = cy + (-174 + i * 57) * s;
Rect(cx - 190*s, y, 380*s, 44*s, selected ? .12f : .055f, selected ? .25f : .10f, selected ? .36f : .16f);
if (selected) Rect(cx - 190*s, y, 4*s, 44*s, .35f, .8f, 1);
var label = i == 2 ? debugEnabled ? "DEBUG ON" : "DEBUG OFF" : Labels[i];
Text(label, cx, y + 12*s, (i >= 3 ? 3.35 : 4) * s, .92f, .95f, 1);
}
Text("P OR ESC TO RESUME", cx, cy + 220*s, 2*s, .6f, .7f, .8f);
_mesh.ReplaceVertices(data.ToArray()); _mesh.Draw(GLEnum.Triangles);
}
public void Dispose() => _mesh.Dispose();
}

View File

@@ -0,0 +1,113 @@
using System.Globalization;
using Silk.NET.OpenGL;
namespace Universe.App.Graphics;
internal sealed class PerformanceOverlay : IDisposable
{
private readonly GpuMesh _mesh;
public PerformanceOverlay(GL gl) => _mesh = new(gl, 6, [], dynamic: true);
public void Draw(int width, int height, IReadOnlyList<KeyValuePair<string, double>> samples,
double cpuMilliseconds, double fps, int terrainTiles, int terrainLod, int pendingTiles, string? probePlanetId)
{
var values = samples.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
var rows = new List<(string Label, string Value, bool Emphasis)>();
AddMetric("GPU FRAME", "GPU FRAME", true);
AddMetric("SKY / STARFIELD", "SKY / STARFIELD");
AddMetric("PLANETS TOTAL", "PLANETS TOTAL");
foreach (var key in values.Keys.Where(k => k.StartsWith("PLANET ", StringComparison.Ordinal) && k != "PLANETS TOTAL" && k != "GPU FRAME")
.OrderBy(k => k, StringComparer.Ordinal))
AddMetric(key, key);
if (probePlanetId is { } probeId)
{
var prefix = probeId.ToUpperInvariant();
AddSaving($"{prefix} SAVE MATERIALS", "SAVE MATERIALS");
AddSaving($"{prefix} SAVE CLOUD SHADOWS", "SAVE CLOUD SHADOWS");
AddSaving($"{prefix} SAVE MOUNTAIN SHADOWS", "SAVE MOUNTAIN SHADOWS");
AddSaving($"{prefix} SAVE BODY SHADOWS", "SAVE BODY SHADOWS");
AddSaving($"{prefix} SAVE SURFACE NOISE", "SAVE SURFACE NOISE");
}
AddMetric("STARS", "STARS");
AddMetric("GAS GIANTS", "GAS GIANTS");
AddMetric("RINGS / ROCKS", "RINGS / ROCKS");
AddMetric("OCEAN", "OCEAN");
AddMetric("ATMOSPHERE", "ATMOSPHERE");
AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE");
AddMetric("SUN GLOW", "SUN GLOW");
AddMetric("BLOOM / POST", "BLOOM / POST");
rows.Add(("SHADOWS IN PLANET", "SEE PLANET MS", false));
rows.Add(("CPU SUBMIT", $"{Number(cpuMilliseconds)} MS", false));
rows.Add(("FPS", $"{Number(fps, "F1")}", false));
rows.Add(("TERRAIN TILES", terrainTiles.ToString(CultureInfo.InvariantCulture), false));
rows.Add(("LOD / PENDING", $"{terrainLod} / {pendingTiles}", false));
var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.25);
var x = 14 * scale;
var top = 14 * scale;
var panelWidth = 438 * scale;
var rowHeight = 17 * scale;
var unit = 1.65 * scale;
var panelHeight = 42 * scale + rowHeight * rows.Count + 20 * scale;
var data = new List<float>(12000);
void Rect(double px, double py, double w, double h, float r, float g, float b)
{
float X(double n) => (float)(2 * n / width - 1);
float Y(double n) => (float)(1 - 2 * n / height);
void Vertex(double vx, double vy) { data.Add(X(vx)); data.Add(Y(vy)); data.Add(0); data.Add(r); data.Add(g); data.Add(b); }
Vertex(px, py); Vertex(px + w, py); Vertex(px + w, py + h);
Vertex(px, py); Vertex(px + w, py + h); Vertex(px, py + h);
}
void Text(string value, double px, double py, double glyphScale, float r, float g, float b)
{
foreach (var c in value)
{
if (PauseMenu.Font.TryGetValue(c, out var glyph))
for (var row = 0; row < glyph.Length; row++)
for (var col = 0; col < glyph[row].Length; col++)
if (glyph[row][col] == '1') Rect(px + col * glyphScale, py + row * glyphScale,
glyphScale * .9, glyphScale * .9, r, g, b);
px += 6 * glyphScale;
}
}
Rect(x, top, panelWidth, panelHeight, .018f, .026f, .045f);
Rect(x, top, 3 * scale, panelHeight, .13f, .48f, .72f);
Text("DEBUG / GPU MS", x + 16 * scale, top + 10 * scale, unit, .72f, .88f, 1f);
Text("ASYNC; PROBE 1 / 8 FRAMES", x + 16 * scale, top + 26 * scale, unit * .8, .40f, .60f, .72f);
var y = top + 49 * scale;
foreach (var (label, value, emphasis) in rows)
{
var color = emphasis ? (R: .55f, G: .88f, B: 1f) : (R: .78f, G: .84f, B: .9f);
Text(label, x + 16 * scale, y, unit, color.R, color.G, color.B);
var valueX = x + panelWidth - (value.Length * 6 * unit) - 14 * scale;
Text(value, valueX, y, unit, emphasis ? .9f : .58f, emphasis ? .95f : .7f, emphasis ? 1f : .78f);
y += rowHeight;
}
Text("SAVINGS ARE DIFFERENTIAL", x + 16 * scale, top + panelHeight - 15 * scale,
unit * .72, .36f, .52f, .65f);
_mesh.ReplaceVertices(data.ToArray());
_mesh.Draw(GLEnum.Triangles);
void AddMetric(string label, string key, bool emphasis = false)
{
rows.Add((label, values.TryGetValue(key, out var time) ? $"{Number(time)} MS" : "-- MS", emphasis));
}
void AddSaving(string label, string key)
{
var value = values.TryGetValue(key, out var delta)
? $"{(delta >= 0 ? "+" : "-")}{Number(System.Math.Abs(delta))} MS"
: "-- MS";
rows.Add((label, value, false));
}
}
private static string Number(double value, string format = "F2") =>
value.ToString(format, CultureInfo.GetCultureInfo("sv-SE"));
public void Dispose() => _mesh.Dispose();
}

View File

@@ -0,0 +1,58 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
internal sealed unsafe class PlanetTextures : IDisposable
{
private readonly GL _gl;
public uint Color { get; }
public uint Height { get; }
public uint OceanMask { get; }
public uint RegionalHeight { get; }
public PlanetTextures(GL gl, PlanetData planet)
{
_gl = gl;
Color = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, Color);
var image = planet.SurfaceTexture;
var pixels = image?.Rgba ?? [255, 255, 255, 255];
fixed (byte* data = pixels)
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.Srgb8Alpha8, (uint)(image?.Width ?? 1), (uint)(image?.Height ?? 1), 0, PixelFormat.Rgba, PixelType.UnsignedByte, data);
SetParameters(gl, true);
gl.GenerateMipmap(GLEnum.Texture2D);
Height = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, Height);
var map = planet.Terrain.HeightMap;
var heights = map?.Samples ?? [0f];
fixed (float* data = heights)
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R32f, (uint)(map?.Width ?? 1), (uint)(map?.Height ?? 1), 0, PixelFormat.Red, PixelType.Float, data);
SetParameters(gl, false);
RegionalHeight = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, RegionalHeight);
var regional = planet.Terrain.RegionalHeight;
var regionalSamples = regional?.Meters ?? [0f];
fixed (float* data = regionalSamples)
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R32f, (uint)(regional?.Width ?? 1), (uint)(regional?.Height ?? 1), 0, PixelFormat.Red, PixelType.Float, data);
SetParameters(gl, false);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.ClampToEdge);
OceanMask = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, OceanMask);
var mask = planet.OceanMask;
var samples = mask?.Samples ?? [0f];
fixed (float* data = samples)
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R32f, (uint)(mask?.Width ?? 1), (uint)(mask?.Height ?? 1), 0, PixelFormat.Red, PixelType.Float, data);
SetParameters(gl, false);
}
private static void SetParameters(GL gl, bool mipmaps)
{
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)(mipmaps ? GLEnum.LinearMipmapLinear : GLEnum.Linear));
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
}
public void Dispose() { _gl.DeleteTexture(Color); _gl.DeleteTexture(Height); _gl.DeleteTexture(OceanMask); _gl.DeleteTexture(RegionalHeight); }
}

View File

@@ -0,0 +1,67 @@
namespace Universe.App.Graphics;
internal static class PrimitiveMeshes
{
public static (float[] Vertices, uint[] Indices) Ring(float innerRadius, float outerRadius, int segments = 384)
{
var vertices = new float[(segments + 1) * 2 * 6];
var indices = new uint[segments * 6];
for (var i = 0; i <= segments; i++)
{
var angle = i * System.Math.Tau / segments;
for (var edge = 0; edge < 2; edge++)
{
var r = edge == 0 ? innerRadius : outerRadius;
var v = (i * 2 + edge) * 6;
vertices[v] = (float)System.Math.Cos(angle) * r;
vertices[v + 2] = (float)System.Math.Sin(angle) * r;
vertices[v + 4] = 1;
}
if (i == segments) continue;
var n = (uint)(i * 2); var offset = i * 6;
indices[offset] = n; indices[offset + 1] = n + 1; indices[offset + 2] = n + 2;
indices[offset + 3] = n + 1; indices[offset + 4] = n + 3; indices[offset + 5] = n + 2;
}
return (vertices, indices);
}
public static (float[] Vertices, uint[] Indices) UnitSphere(int latitudeSegments = 24, int longitudeSegments = 32)
{
var vertices = new float[(latitudeSegments + 1) * (longitudeSegments + 1) * 6];
var indices = new uint[latitudeSegments * longitudeSegments * 6];
var write = 0;
for (var latitude = 0; latitude <= latitudeSegments; latitude++)
{
var phi = System.Math.PI * latitude / latitudeSegments;
var y = System.Math.Cos(phi);
var ring = System.Math.Sin(phi);
for (var longitude = 0; longitude <= longitudeSegments; longitude++)
{
var theta = System.Math.Tau * longitude / longitudeSegments;
var x = ring * System.Math.Cos(theta);
var z = ring * System.Math.Sin(theta);
vertices[write++] = (float)x;
vertices[write++] = (float)y;
vertices[write++] = (float)z;
vertices[write++] = (float)x;
vertices[write++] = (float)y;
vertices[write++] = (float)z;
}
}
write = 0;
var row = longitudeSegments + 1;
for (var latitude = 0; latitude < latitudeSegments; latitude++)
for (var longitude = 0; longitude < longitudeSegments; longitude++)
{
var topLeft = (uint)(latitude * row + longitude);
var bottomLeft = topLeft + (uint)row;
indices[write++] = topLeft;
indices[write++] = bottomLeft;
indices[write++] = topLeft + 1;
indices[write++] = topLeft + 1;
indices[write++] = bottomLeft;
indices[write++] = bottomLeft + 1;
}
return (vertices, indices);
}
}

View File

@@ -0,0 +1,59 @@
using Silk.NET.OpenGL;
namespace Universe.App.Graphics;
internal sealed unsafe class RenderTarget : IDisposable
{
private readonly GL _gl;
public uint Framebuffer { get; }
public uint Color { get; }
public uint Depth { get; }
public int Width { get; }
public int Height { get; }
public RenderTarget(GL gl, int width, int height, bool depth = false, bool fullPrecision = false)
{
_gl = gl;
Width = width;
Height = height;
Framebuffer = gl.GenFramebuffer();
gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer);
Color = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, Color);
gl.TexImage2D(GLEnum.Texture2D, 0, fullPrecision ? InternalFormat.Rgba32f : InternalFormat.Rgba16f, (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.Float, null);
SetParameters(gl, GLEnum.Linear);
gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.ColorAttachment0, GLEnum.Texture2D, Color, 0);
if (depth)
{
Depth = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, Depth);
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.DepthComponent32f, (uint)width, (uint)height, 0, PixelFormat.DepthComponent, PixelType.Float, null);
SetParameters(gl, GLEnum.Nearest);
gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.DepthAttachment, GLEnum.Texture2D, Depth, 0);
}
var status = gl.CheckFramebufferStatus(GLEnum.Framebuffer);
if (status != GLEnum.FramebufferComplete) throw new InvalidOperationException($"Incomplete HDR framebuffer: {status}");
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
}
public void Bind()
{
_gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer);
_gl.Viewport(0, 0, (uint)Width, (uint)Height);
}
private static void SetParameters(GL gl, GLEnum filter)
{
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)filter);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)filter);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.ClampToEdge);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
}
public void Dispose()
{
_gl.DeleteTexture(Color);
if (Depth != 0) _gl.DeleteTexture(Depth);
_gl.DeleteFramebuffer(Framebuffer);
}
}

View File

@@ -0,0 +1,95 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
using Universe.Core.Math;
namespace Universe.App.Graphics;
/// <summary>Only local debris cells are stored. Geodesic mesh LODs depend on projected size.</summary>
internal sealed unsafe class RingParticles : IDisposable
{
private readonly GL _gl;
private readonly RingRockStreamer _stream;
private readonly RingRockSettings _settings;
private readonly uint _pointVao, _pointInstances;
private readonly Dictionary<int, InstancedAsteroidMesh> _meshes = [];
private readonly Dictionary<int, ulong> _signatures = [];
private RingRock[] _candidates = [];
private int[] _previousLevels = [];
private int _revision = -1, _pointCount;
private DVector3? _lastCamera;
private double _lastPixelScale;
public DVector3 Anchor { get; private set; }
public int Count => _pointCount;
public int HighDetailCount { get; private set; }
public RingParticles(GL gl, RingProfile profile)
{
_gl = gl; _stream = new(profile); _settings = profile.Rings.Rocks;
_pointVao = gl.GenVertexArray(); _pointInstances = gl.GenBuffer();
gl.BindVertexArray(_pointVao); InstancedAsteroidMesh.ConfigureInstances(gl, _pointInstances, 0); gl.BindVertexArray(0);
}
public void Update(DVector3 camera, double pixelScale)
{
var frame = _stream.Update(camera);
var changed = frame.Revision != _revision;
if (!changed && _lastCamera == camera && _lastPixelScale == pixelScale) return;
_lastCamera = camera; _lastPixelScale = pixelScale;
if (changed)
{
_revision = frame.Revision; Anchor = frame.Anchor; _pointCount = frame.Rocks.Count;
var points = new List<float>(_pointCount * 12); var candidates = new List<RingRock>();
var meshRange = _settings.MeshDistanceMeters + _settings.LodTransitionMeters + _settings.CellSizeMeters * 2;
foreach (var rock in frame.Rocks)
{
Pack(points, rock);
if ((rock.Position - camera).Length < meshRange) candidates.Add(rock);
}
_candidates = candidates.ToArray(); _previousLevels = new int[_candidates.Length];
_gl.BindBuffer(GLEnum.ArrayBuffer, _pointInstances);
var data = points.ToArray(); fixed (float* p = data) _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(data.Length * 4), p, GLEnum.DynamicDraw);
}
var detail = _settings.Asteroid;
var entries = new List<(int Index, int Level, double Pixels)>(_candidates.Length);
for (var i = 0; i < _candidates.Length; i++)
{
var rock = _candidates[i]; var distance = (rock.Position - camera).Length;
var pixels = 2 * rock.Radius * pixelScale / System.Math.Max(.05, distance);
var level = AsteroidLod.Select(pixels, distance, detail);
var previous = _previousLevels[i];
if (previous != 0 && System.Math.Abs(level - previous) == 1)
{
var boundary = detail.LodStartPixels * System.Math.Pow(detail.LodPixelMultiplier, System.Math.Min(level, previous) - detail.BaseSubdivision);
if (pixels > boundary * .88 && pixels < boundary * 1.12 && distance <= detail.HighDetailDistanceMeters) level = previous;
}
entries.Add((i, level, pixels));
}
entries.Sort((a, b) => { var result = b.Pixels.CompareTo(a.Pixels); return result != 0 ? result : a.Index.CompareTo(b.Index); });
HighDetailCount = 0;
var buckets = new Dictionary<int, List<float>>(); var signatures = new Dictionary<int, ulong>();
foreach (var entry in entries)
{
var level = entry.Level;
if (level > 3 && HighDetailCount++ >= detail.MaxHighDetailInstances) level = 3;
_previousLevels[entry.Index] = level;
if (!buckets.TryGetValue(level, out var list)) { buckets[level] = list = []; signatures[level] = 14695981039346656037UL; }
Pack(list, _candidates[entry.Index]);
signatures[level] = unchecked((signatures[level] ^ (ulong)entry.Index) * 1099511628211UL);
}
HighDetailCount = System.Math.Min(HighDetailCount, detail.MaxHighDetailInstances);
foreach (var level in _meshes.Keys.Union(buckets.Keys).ToArray())
{
var signature = signatures.GetValueOrDefault(level);
if (!changed && _signatures.TryGetValue(level, out var prior) && prior == signature) continue;
if (!_meshes.TryGetValue(level, out var mesh)) _meshes.Add(level, mesh = new(_gl, level));
mesh.Upload(buckets.TryGetValue(level, out var list) ? list.ToArray() : []); _signatures[level] = signature;
}
}
private void Pack(List<float> target, RingRock rock)
{
var p = rock.Position - Anchor;
target.AddRange([(float)p.X,(float)p.Y,(float)p.Z,(float)rock.Radius,rock.Color.X,rock.Color.Y,rock.Color.Z,rock.ShapeSeed,
rock.Rotation.X,rock.Rotation.Y,rock.Rotation.Z,rock.Rotation.W]);
}
public void DrawMeshes() { foreach (var mesh in _meshes.Values) mesh.Draw(); }
public void DrawPoints() { if (_pointCount == 0) return; _gl.BindVertexArray(_pointVao); _gl.DrawArrays(GLEnum.Points, 0, (uint)_pointCount); _gl.BindVertexArray(0); }
public void Dispose() { foreach (var mesh in _meshes.Values) mesh.Dispose(); _gl.DeleteVertexArray(_pointVao); _gl.DeleteBuffer(_pointInstances); }
}

View File

@@ -0,0 +1,43 @@
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
internal sealed unsafe class RingProfileTexture : IDisposable
{
private readonly GL _gl;
public uint Handle { get; }
public RingProfile? Profile { get; }
public RingProfileTexture(GL gl, PlanetRings? rings)
{
_gl = gl;
Profile = rings is null ? null : new(rings);
var data = Profile?.Rgba ?? new float[4];
var maxWidth = gl.GetInteger(GLEnum.MaxTextureSize);
if (data.Length / 4 > maxWidth)
{
var source = data;
var sourceWidth = source.Length / 4;
data = new float[maxWidth * 4];
for (var x = 0; x < maxWidth; x++)
{
var first = (int)((long)x * sourceWidth / maxWidth);
var last = (int)((long)(x + 1) * sourceWidth / maxWidth);
for (var i = first; i < last; i++)
for (var channel = 0; channel < 4; channel++)
data[x * 4 + channel] += source[i * 4 + channel] / (last - first);
}
Console.WriteLine($"Ring profile reduced from {sourceWidth} to GPU limit {maxWidth} texels.");
}
Handle = gl.GenTexture();
gl.ActiveTexture(GLEnum.Texture7);
gl.BindTexture(GLEnum.Texture2D, Handle);
fixed (float* p = data) gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.Rgba16f, (uint)(data.Length / 4), 1, 0, PixelFormat.Rgba, PixelType.Float, p);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.ClampToEdge);
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
gl.GenerateMipmap(GLEnum.Texture2D);
}
public void Dispose() => _gl.DeleteTexture(Handle);
}

View File

@@ -0,0 +1,96 @@
using System.Numerics;
using Silk.NET.OpenGL;
namespace Universe.App.Graphics;
internal sealed unsafe class ShaderProgram : IDisposable
{
private readonly GL _gl;
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
private bool _disposed;
private ShaderProgram(GL gl, uint handle)
{
_gl = gl;
Handle = handle;
}
public uint Handle { get; }
public static ShaderProgram Create(GL gl, string vertexSource, string fragmentSource)
{
var vertex = Compile(gl, ShaderType.VertexShader, vertexSource);
var fragment = Compile(gl, ShaderType.FragmentShader, fragmentSource);
var program = gl.CreateProgram();
gl.AttachShader(program, vertex);
gl.AttachShader(program, fragment);
gl.LinkProgram(program);
gl.GetProgram(program, GLEnum.LinkStatus, out var linked);
gl.DeleteShader(vertex);
gl.DeleteShader(fragment);
if (linked == 0)
{
var log = gl.GetProgramInfoLog(program);
gl.DeleteProgram(program);
throw new InvalidOperationException($"OpenGL shader link failed: {log}");
}
return new(gl, program);
}
public void Use() => _gl.UseProgram(Handle);
public void Set(string name, int value) => _gl.Uniform1(Location(name), value);
public void Set(string name, float value) => _gl.Uniform1(Location(name), value);
public void Set(string name, Vector3 value) => _gl.Uniform3(Location(name), value.X, value.Y, value.Z);
public void SetMatrix(string name, Matrix4x4 value)
{
var location = Location(name);
// System.Numerics stores row-vector matrices in row-major order. OpenGL consumes
// column-major data, so the same memory represents the required transposed matrix.
_gl.UniformMatrix4(location, 1, false, (float*)&value);
}
public void SetVectorArray(string name, IReadOnlyList<Vector3> values)
{
for (var i = 0; i < values.Count; i++)
Set($"{name}[{i}]", values[i]);
}
public void SetFloatArray(string name, IReadOnlyList<float> values)
{
for (var i = 0; i < values.Count; i++)
Set($"{name}[{i}]", values[i]);
}
private int Location(string name)
{
if (!_uniformLocations.TryGetValue(name, out var location))
{
location = _gl.GetUniformLocation(Handle, name);
_uniformLocations.Add(name, location);
}
return location;
}
private static uint Compile(GL gl, ShaderType type, string source)
{
var shader = gl.CreateShader(type);
gl.ShaderSource(shader, source);
gl.CompileShader(shader);
gl.GetShader(shader, GLEnum.CompileStatus, out var compiled);
if (compiled != 0)
return shader;
var log = gl.GetShaderInfoLog(shader);
gl.DeleteShader(shader);
throw new InvalidOperationException($"OpenGL {type} compilation failed: {log}");
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_gl.DeleteProgram(Handle);
}
}

View File

@@ -0,0 +1,28 @@
namespace Universe.App.Graphics;
internal static class ShaderSources
{
public static string Load(string name)
{
#if DEBUG
var sourcePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../Graphics/Shaders", name + ".glsl"));
if (File.Exists(sourcePath))
return Expand(name, File.ReadAllText(sourcePath));
#endif
using var stream = typeof(ShaderSources).Assembly.GetManifestResourceStream($"Universe.App.Graphics.Shaders.{name}.glsl")
?? throw new FileNotFoundException($"Embedded shader {name} is missing.");
using var reader = new StreamReader(stream);
var source = reader.ReadToEnd();
return Expand(name, source);
}
private static string Expand(string name, string source)
{
foreach (var include in new[] { "noise", "planet-height", "camera-ray", "ring-optics", "rock-common", "asteroid-shape", "cloud-common" })
{
var directive = $"#include \"{include}.glsl\"";
if (source.Contains(directive, StringComparison.Ordinal))
source = source.Replace(directive, name == include ? string.Empty : Load(include), StringComparison.Ordinal);
}
return source;
}
}

View File

@@ -0,0 +1,62 @@
uniform vec3 uAsteroidShape;
uniform vec3 uAsteroidNoise;
uniform vec3 uAsteroidCraters;
uniform vec3 uAsteroidSurface;
vec3 asteroidHash(vec3 p) {
// Integer hashing keeps crater positions identical in vertex and fragment stages.
uvec3 q=uvec3(ivec3(floor(p*97.0)));
q=q*1664525u+1013904223u;
q.x+=q.y*q.z;q.y+=q.z*q.x;q.z+=q.x*q.y;
q^=q>>16u;
q.x+=q.y*q.z;q.y+=q.z*q.x;q.z+=q.x*q.y;
return vec3(q)*(1.0/4294967295.0);
}
vec3 asteroidAxes(float seed) {
vec3 h=asteroidHash(vec3(seed*173.0,19.3,47.1));
vec3 axes=vec3(1.0)+(h-0.5)*uAsteroidShape.x*2.0;
return axes/pow(axes.x*axes.y*axes.z,1.0/3.0);
}
float asteroidCraters(vec3 direction,float frequency,float seed) {
vec3 offset=vec3(seed*31.7,seed*17.3,seed*23.1);
vec3 q=direction*frequency+offset;
vec3 cell=floor(q);float height=0.0;
for(int z=-1;z<=1;z++)for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++) {
vec3 lattice=cell+vec3(x,y,z),h=asteroidHash(lattice+seed*41.0);
if(h.z>uAsteroidCraters.z)continue;
vec3 center=lattice+0.5+(h-0.5)*0.30-offset;
if(abs(length(center)-frequency)>.58)continue;
float radius=mix(.24,.63,h.x);
float r=length(direction-normalize(center))*frequency/radius;
if(r>1.22)continue;
float bowl=-0.48*(1.0-r*r)*(1.0-smoothstep(.72,1.0,r));
float rim=uAsteroidSurface.x*exp(-pow((r-.96)/.095,2.0));
height+=(bowl+rim)*radius/frequency;
}
return height;
}
float asteroidHeight(vec3 direction,float seed) {
vec3 offset=vec3(seed*31.0,seed*17.0,seed*47.0);
vec3 p=direction*uAsteroidShape.y+offset;
vec3 warp=vec3(noise3(p),noise3(p+vec3(9.3,4.7,1.1)),noise3(p+vec3(2.9,17.1,7.3)));
p+=warp*.45;
float sum=0.0,amplitude=.7,weight=0.0;
for(int i=0;i<7;i++) {
if(i>=int(uAsteroidNoise.x))break;
sum+=gradientNoise3(p)*amplitude;
weight+=amplitude;
p=octaveRotation*p*2.07+vec3(7.3,13.1,5.9);amplitude*=uAsteroidNoise.y;
}
float ridges=pow(1.0-abs(noise3(direction*11.0+offset)),3.0)-.4;
float craters=asteroidCraters(direction,uAsteroidCraters.x,seed);
craters+=asteroidCraters(direction,uAsteroidCraters.x*3.1,seed+5.7)*.8;
return sum/max(.1,weight)*uRockMaterial.x+ridges*uAsteroidShape.z+craters*uAsteroidCraters.y;
}
vec3 asteroidPosition(vec3 direction,float seed) {
return direction*asteroidAxes(seed)*clamp(1.0+asteroidHeight(direction,seed),.55,1.6);
}
float asteroidMicroHeight(vec3 d,float seed,float footprint) {
vec3 p=d*uAsteroidSurface.z+seed*61.0;
float fine=(noise3(p)+noise3(p*2.7)*.32)*(1.0-smoothstep(.35,1.2,footprint*uAsteroidSurface.z));
float smallCraters=asteroidCraters(d,uAsteroidCraters.x*9.0,seed+13.1)*uAsteroidCraters.y;
return fine*uAsteroidSurface.y+smallCraters;
}

View File

@@ -0,0 +1,84 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform vec3 uForward;
uniform vec3 uRight;
uniform vec3 uUp;
uniform float uAspect;
uniform float uTanHalfFov;
uniform float uFar;
uniform vec3 uCameraPlanet;
uniform float uRadius;
uniform float uAtmosphereRadius;
uniform float uGroundRadius;
uniform vec3 uAtmosphereColor;
uniform float uDensityScale;
uniform int uLightCount;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
out vec4 fragColor;
const float PI=3.14159265359;
vec2 sphere(vec3 origin, vec3 direction, float radius) {
float b=dot(origin,direction);
float c=dot(origin,origin)-radius*radius;
float h=b*b-c;
if(h<0.0) return vec2(1e8,-1e8);
h=sqrt(h);
return vec2(-b-h,-b+h);
}
vec2 density(vec3 p) {
float altitude=max(length(p)-1.0,-0.0003);
float thickness=uAtmosphereRadius-1.0;
return exp(-altitude/vec2(thickness*0.055,thickness*0.0175));
}
vec2 sunDepth(vec3 p, vec3 direction) {
vec2 ground=sphere(p,direction,uGroundRadius);
if(ground.y>ground.x && ground.x>0.0) return vec2(1e5);
float distance=sphere(p,direction,uAtmosphereRadius).y;
float stepLength=max(0.0,distance)/7.0;
vec2 optical=vec2(0.0);
for(int i=0;i<7;i++) optical+=density(p+direction*(float(i)+0.5)*stepLength)*stepLength;
return optical;
}
void main() {
vec3 scene=texture(uScene,vUv).rgb;
vec2 xy=(vUv*2.0-1.0)*uTanHalfFov;
vec3 ray=normalize(uForward+uRight*xy.x*uAspect+uUp*xy.y);
vec2 hit=sphere(uCameraPlanet,ray,uAtmosphereRadius);
float start=max(hit.x,0.0);
float depth=texture(uDepth,vUv).r;
float worldDepth=(exp2(depth*log2(uFar+1.0))-1.0)/max(dot(ray,uForward),0.01);
float end=min(hit.y,worldDepth/uRadius);
if(end<=start) { fragColor=vec4(scene,1.0); return; }
vec3 betaR=uAtmosphereColor*110.0*uDensityScale;
vec3 betaM=vec3(4.0)*uDensityScale;
vec3 betaAbsorption=vec3(2.1,4.2,0.35)*uDensityScale;
vec2 optical=vec2(0.0);
vec3 scattering=vec3(0.0);
float ds=(end-start)/28.0;
float jitter=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);
for(int i=0;i<28;i++) {
vec3 p=uCameraPlanet+ray*(start+(float(i)+0.35+jitter*0.3)*ds);
vec2 localDensity=density(p);
vec2 stepDepth=localDensity*ds;
optical+=stepDepth*0.5;
for(int l=0;l<2;l++) {
if(l>=uLightCount) break;
vec3 ld=uLightDirections[l];
vec2 lightOptical=sunDepth(p,ld);
vec2 total=optical+lightOptical;
vec3 transmittance=exp(-min(betaR*total.x+betaM*total.y*1.1+betaAbsorption*total.x,vec3(70.0)));
float mu=dot(ray,ld);
float rayleighPhase=3.0/(16.0*PI)*(1.0+mu*mu);
float g=0.78;
float miePhase=3.0/(8.0*PI)*((1.0-g*g)*(1.0+mu*mu))/((2.0+g*g)*pow(max(0.02,1.0+g*g-2.0*g*mu),1.5));
scattering+=transmittance*(betaR*stepDepth.x*rayleighPhase+betaM*stepDepth.y*miePhase)
*mix(vec3(1.0),uLightColors[l],0.4)*uLightFlux[l]*6.5;
}
optical+=stepDepth*0.5;
}
vec3 extinction=exp(-min(betaR*optical.x+betaM*optical.y*1.1+betaAbsorption*optical.x,vec3(70.0)));
fragColor=vec4(scene*extinction+scattering,1.0);
}

View File

@@ -0,0 +1,13 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform vec3 uDirection;
out vec4 fragColor;
void main() {
vec3 color=texture(uScene,vUv).rgb*0.227027;
color+=texture(uScene,vUv+uDirection.xy*1.384615).rgb*0.316216;
color+=texture(uScene,vUv-uDirection.xy*1.384615).rgb*0.316216;
color+=texture(uScene,vUv+uDirection.xy*3.230769).rgb*0.070270;
color+=texture(uScene,vUv-uDirection.xy*3.230769).rgb*0.070270;
fragColor=vec4(color,1.0);
}

View File

@@ -0,0 +1,17 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform vec3 uTexel;
out vec4 fragColor;
void main() {
vec3 color=vec3(0.0);
for(int x=-1;x<=1;x++) for(int y=-1;y<=1;y++) {
vec3 c=texture(uScene,vUv+vec2(x,y)*uTexel.xy).rgb;
float brightness=max(c.r,max(c.g,c.b));
float soft=clamp(brightness-0.65,0.0,0.7);
soft=soft*soft/(2.8+0.0001);
float contribution=max(brightness-1.0,soft)/max(brightness,0.0001);
color+=c*contribution;
}
fragColor=vec4(color/9.0,1.0);
}

View File

@@ -0,0 +1,9 @@
uniform vec3 uForward;
uniform vec3 uRight;
uniform vec3 uUp;
uniform float uAspect;
uniform float uTanHalfFov;
vec3 cameraRay(vec2 uv) {
vec2 xy=(uv*2.0-1.0)*uTanHalfFov;
return normalize(uForward+uRight*xy.x*uAspect+uUp*xy.y);
}

View File

@@ -0,0 +1,114 @@
uniform sampler3D uCloudShape;
uniform sampler3D uCloudDetail;
uniform sampler2D uCloudDensityMap;
uniform int uCloudEnabled;
uniform int uCloudShadowEnabled;
uniform int uCloudHasDensityMap;
uniform float uCloudDensityTextureStrength;
uniform float uCloudPlanetRadius;
uniform vec3 uCloudCamera;
uniform vec3 uCloudAxisX;
uniform vec3 uCloudAxisY;
uniform vec3 uCloudAxisZ;
uniform mat4 uCloudWorldToBody;
uniform vec3 uCloudOrigins[3];
uniform vec3 uCloudLayer; // base altitude, thickness, shape scale
uniform vec3 uCloudShapeSettings; // coverage, erosion, type
uniform vec3 uCloudOptics; // density, extinction/m, shadow strength
uniform float uCloudDetailScale;
uniform float uCloudWeatherScale;
uniform float uCloudPixelAngle;
uniform vec3 uCloudAir; // atmosphere height, density, sunset strength
uniform vec3 uCloudAirColor;
uniform vec3 uCloudPrimarySun;
uniform vec3 uCloudPrimaryColor;
vec3 cloudToLocal(vec3 v) { return vec3(dot(v,uCloudAxisX),dot(v,uCloudAxisY),dot(v,uCloudAxisZ)); }
vec3 cloudToWorld(vec3 v) { return uCloudAxisX*v.x+uCloudAxisY*v.y+uCloudAxisZ*v.z; }
float cloudAltitude(vec3 p) {
return (dot(p,p)+2.0*uCloudPlanetRadius*p.y)/(length(p+vec3(0,uCloudPlanetRadius,0))+uCloudPlanetRadius);
}
vec2 cloudSphere(vec3 ro,vec3 rd,float altitude) {
float b=dot(ro+vec3(0,uCloudPlanetRadius,0),rd);
float c=dot(ro,ro)+2.0*uCloudPlanetRadius*(ro.y-altitude)-altitude*altitude;
float h=b*b-c;
if(h<0.0)return vec2(1e25,-1e25);
float q=-b-(b<0.0?-1.0:1.0)*sqrt(h);
float d=c/(abs(q)<1e-9?1e-9:q);
return vec2(min(q,d),max(q,d));
}
float cloudDensity(vec3 p,float lod,bool fine) {
float height=(cloudAltitude(p)-uCloudLayer.x)/uCloudLayer.y;
if(height<=0.0 || height>=1.0)return 0.0;
vec3 body=mat3(uCloudWorldToBody)*cloudToWorld(p);
float weather=textureLod(uCloudShape,body/uCloudWeatherScale+uCloudOrigins[2],0.0).a;
float weatherCover=smoothstep(.30,.62,weather);
float coverage=clamp(uCloudShapeSettings.x+(weatherCover-.5)*.55,0.0,1.0);
if(coverage<.005)return 0.0;
vec3 structure=textureLod(uCloudShape,body/uCloudLayer.z+uCloudOrigins[0],lod).rgb;
float shape=structure.r+(dot(structure.gb,vec2(.7,.3))-.5)*.28;
float profile=smoothstep(0.0,.09,height)*(1.0-smoothstep(mix(.28,.62,uCloudShapeSettings.z),mix(.52,1.0,uCloudShapeSettings.z),height));
float density=clamp((shape-(1.0-coverage))/max(.08,coverage)*3.1,0.0,1.0);
if(fine && density>.001) {
vec3 erosion=textureLod(uCloudDetail,body/uCloudDetailScale+uCloudOrigins[1],max(0.0,lod-1.0)).rgb;
float detail=dot(erosion,vec3(.6,.3,.1));
density=clamp(density-(1.0-detail)*uCloudShapeSettings.y*(.45+.55*(1.0-density))*(1.0-smoothstep(1.0,3.0,lod)),0.0,1.0);
}
// From orbit a pixel covers many individual puffs. Integrate their weather-scale coverage.
float distant=smoothstep(.28,.65,coverage)*.5;
density=mix(density,distant,smoothstep(2.5,5.5,lod));
if(uCloudHasDensityMap!=0) {
vec3 radialBody=normalize(mat3(uCloudWorldToBody)*cloudToWorld(p+vec3(0.0,uCloudPlanetRadius,0.0)));
vec2 mapUv=vec2(.5-atan(radialBody.z,radialBody.x)/6.28318530718,
atan(length(radialBody.xz),radialBody.y)/3.14159265359);
float mapWidth=float(textureSize(uCloudDensityMap,0).x);
float footprint=max(1.0,length(p)*uCloudPixelAngle*mapWidth/(6.28318530718*uCloudPlanetRadius));
float mapLod=clamp(log2(footprint),0.0,log2(max(1.0,mapWidth)));
float mask=textureLod(uCloudDensityMap,mapUv,mapLod).r;
float coverageMask=smoothstep(.055,.48,mask);
density*=mix(1.0,coverageMask,clamp(uCloudDensityTextureStrength,0.0,1.0));
}
return density*profile*uCloudOptics.x;
}
vec3 cloudSunTint(vec3 p,vec3 light) {
vec3 up=normalize(p+vec3(0,uCloudPlanetRadius,0));
float mu=dot(up,light);
float scaleHeight=max(100.0,uCloudAir.x*.055);
float mass=exp(-max(0.0,cloudAltitude(p))/scaleHeight)/max(.025,mu+sqrt(mu*mu+2.0*scaleHeight/uCloudPlanetRadius));
vec3 beta=vec3(.022,.055,.14)*(vec3(.7)+uCloudAirColor*.3);
return exp(-beta*mass*uCloudAir.y*uCloudAir.z);
}
float cloudShadow(vec3 worldPoint,vec3 worldLight) {
if(uCloudEnabled==0 || uCloudShadowEnabled==0 || uCloudOptics.z<=0.0)return 1.0;
vec3 ro=uCloudCamera+cloudToLocal(worldPoint),rd=cloudToLocal(worldLight);
if(cloudAltitude(ro)>uCloudLayer.x+uCloudLayer.y)return 1.0;
vec2 outer=cloudSphere(ro,rd,uCloudLayer.x+uCloudLayer.y),inner=cloudSphere(ro,rd,uCloudLayer.x);
float start=max(0.0,outer.x),end=min(outer.y,start+150000.0);
if(inner.x<0.0 && inner.y>0.0)start=max(start,inner.y);
else if(inner.x>start)end=min(end,inner.x);
if(end<=start)return 1.0;
float ds=(end-start)/6.0,optical=0.0;
float lod=clamp(log2(max(1.0,length(worldPoint)*uCloudPixelAngle*128.0/uCloudLayer.z)),1.2,7.0);
for(int i=0;i<6;i++)optical+=cloudDensity(ro+rd*(start+(float(i)+.5)*ds),lod,false)*ds;
return mix(1.0,exp(-min(20.0,optical*uCloudOptics.y)),uCloudOptics.z);
}
vec3 cloudReflection(vec3 worldPoint,vec3 worldDirection,vec3 sky) {
if(uCloudEnabled==0)return sky;
vec3 ro=uCloudCamera+cloudToLocal(worldPoint),rd=cloudToLocal(worldDirection);
vec2 outer=cloudSphere(ro,rd,uCloudLayer.x+uCloudLayer.y),inner=cloudSphere(ro,rd,uCloudLayer.x);
float start=max(0.0,outer.x),end=min(outer.y,start+100000.0);
if(inner.x<0.0 && inner.y>0.0)start=max(start,inner.y);
else if(inner.x>start)end=min(end,inner.x);
if(end<=start)return sky;
float ds=(end-start)/10.0,transmission=1.0;
vec3 light=vec3(0.0);
for(int i=0;i<10;i++) {
vec3 p=ro+rd*(start+(float(i)+.5)*ds);
float lod=clamp(log2(max(1.0,(length(worldPoint)+start+(float(i)+.5)*ds)*uCloudPixelAngle*128.0/uCloudLayer.z)),1.0,7.0);
float a=1.0-exp(-cloudDensity(p,lod,false)*ds*uCloudOptics.y);
float day=smoothstep(-.05,.12,dot(normalize(p+vec3(0,uCloudPlanetRadius,0)),uCloudPrimarySun));
vec3 color=(vec3(.15,.2,.3)*.2+uCloudPrimaryColor*cloudSunTint(p,uCloudPrimarySun)*.8)*day+vec3(.001);
light+=color*a*transmission;transmission*=1.0-a;
}
return sky*transmission+light;
}

View File

@@ -0,0 +1,31 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uClouds;
uniform sampler2D uDepth;
uniform vec3 uCloudTexel;
out vec4 fragColor;
float depthAt(vec2 uv) {
ivec2 size=textureSize(uDepth,0);
vec2 pixel=uv*vec2(size)-.5,f=fract(pixel);ivec2 base=ivec2(floor(pixel));
float value=0.0;
for(int y=0;y<2;y++)for(int x=0;x<2;x++)
value+=texelFetch(uDepth,clamp(base+ivec2(x,y),ivec2(0),size-1),0).r
*(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y);
return value;
}
void main() {
vec2 pixel=vUv/uCloudTexel.xy-.5,base=floor(pixel),blend=fract(pixel);
float centerDepth=texture(uDepth,vUv).r;
vec4 cloud=vec4(0);float total=0.0;
for(int y=0;y<2;y++)for(int x=0;x<2;x++) {
vec2 uv=(base+vec2(x,y)+.5)*uCloudTexel.xy;
float bilinear=(x==0?1.0-blend.x:blend.x)*(y==0?1.0-blend.y:blend.y);
float depth=depthAt(uv);
float weight=bilinear*exp(-abs(depth-centerDepth)*2200.0)+.00001;
cloud+=texture(uClouds,uv)*weight;total+=weight;
}
cloud/=total;
vec4 scene=texture(uScene,vUv);
fragColor=vec4(scene.rgb*cloud.a+cloud.rgb,scene.a*cloud.a);
}

View File

@@ -0,0 +1,124 @@
#version 330 core
#include "camera-ray.glsl"
#include "cloud-common.glsl"
in vec2 vUv;
uniform sampler2D uDepth;
uniform float uFar;
uniform int uCloudViewSteps;
uniform int uCloudLightSteps;
uniform float uCloudMaxDistance;
uniform vec3 uCloudLighting; // ambient, sunlight, single scattering albedo
uniform vec3 uCloudPhase; // anisotropy, silver lining, unused
uniform int uLightCount;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
out vec4 fragColor;
float opaqueClipDistance(vec2 uv) {
// Half-resolution rays land between opaque pixel centres. Interpolate inverse eye depth,
// which is linear across a projected triangle, instead of borrowing a neighbour's distance.
ivec2 size=textureSize(uDepth,0);
vec2 pixel=uv*vec2(size)-.5,f=fract(pixel);
ivec2 base=ivec2(floor(pixel));
float inverseDepth=0.0;
for(int y=0;y<2;y++)for(int x=0;x<2;x++) {
float weight=(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y);
float encoded=texelFetch(uDepth,clamp(base+ivec2(x,y),ivec2(0),size-1),0).r;
float distance=exp2(encoded*log2(uFar+1.0))-1.0;
inverseDepth+=weight/max(.001,distance);
}
return 1.0/max(1e-20,inverseDepth);
}
float phaseHG(float mu,float g) { return (1.0-g*g)/pow(max(.01,1.0+g*g-2.0*g*mu),1.5); }
float lightTransmission(vec3 p,vec3 light,float lod) {
vec2 ground=cloudSphere(p,light,0.0);
if(ground.y>ground.x && ground.x>1.0)return 0.0;
float distance=min(max(0.0,cloudSphere(p,light,uCloudLayer.x+uCloudLayer.y).y),uCloudLayer.y*12.0);
float optical=0.0;
for(int i=0;i<16;i++) {
if(i>=uCloudLightSteps)break;
float a=float(i)/float(uCloudLightSteps),b=(float(i)+1.0)/float(uCloudLightSteps);
float sampleT=distance*pow((a+b)*.5,2.0);
optical+=cloudDensity(p+light*sampleT,max(.5,lod),false)*distance*(b*b-a*a);
}
return exp(-min(25.0,optical*uCloudOptics.y));
}
vec3 sampleLighting(vec3 p,vec3 ray,float density,float lod) {
vec3 up=normalize(p+vec3(0,uCloudPlanetRadius,0));
float height=clamp((cloudAltitude(p)-uCloudLayer.x)/uCloudLayer.y,0.0,1.0);
vec3 light=vec3(.001,.0015,.0025);
for(int l=0;l<2;l++) {
if(l>=uLightCount)break;
vec3 ld=uLightDirections[l];
float sunHeight=dot(up,ld),day=smoothstep(-.075,.10,sunHeight);
vec3 tint=uLightColors[l]*cloudSunTint(p,ld);
float transmission=lightTransmission(p,ld,lod);
float mu=dot(ray,ld);
float phase=.8*phaseHG(mu,uCloudPhase.x)+.2*phaseHG(mu,-.22);
float single=transmission*(.14*phase+.15);
float multiple=pow(transmission,.32)*.38;
float edge=pow(max(0.0,mu),16.0)*sqrt(transmission)*uCloudPhase.y*.5;
light+=tint*uLightFlux[l]*day*(single+multiple+edge)*uCloudLighting.y;
vec3 sky=mix(vec3(.18,.23,.33),vec3(.60,.70,.84),height);
// Multiple scattering inherits the horizon's reddened illumination, including cloud undersides.
vec3 ambientTint=tint/max(.001,max(tint.r,max(tint.g,tint.b)));
sky*=mix(vec3(1.0),ambientTint,.85);
sky*=1.0+(1.0-clamp(ambientTint.b/max(.001,ambientTint.r),0.0,1.0))*1.5;
light+=sky*uCloudLighting.x*day*sqrt(uLightFlux[l])*(.65+.35*exp(-density*1.8));
}
return light*uCloudLighting.z;
}
void marchRange(float start,float end,vec3 ray,inout vec3 radiance,inout float transmission,inout float weightedDistance) {
if(end<=start || transmission<.01)return;
end=min(end,start+uCloudMaxDistance);
float count=clamp(ceil((end-start)/max(40.0,uCloudLayer.y/40.0)),16.0,float(uCloudViewSteps));
float ds=(end-start)/count;
float lod=clamp(log2(max(1.0,ds*128.0/uCloudLayer.z)),0.0,3.0);
// Static interleaving avoids frame-to-frame noise; the bilateral pass removes low-resolution edges.
float jitter=fract(52.9829189*fract(dot(floor(gl_FragCoord.xy),vec2(.06711056,.00583715))));
for(int i=0;i<192;i++) {
if(float(i)>=count || transmission<.01)break;
float t=start+(float(i)+.15+jitter*.7)*ds;
vec3 p=uCloudCamera+ray*t;
float pixelLod=clamp(log2(max(1.0,t*uCloudPixelAngle*128.0/uCloudLayer.z)),0.0,7.0);
float density=cloudDensity(p,max(lod,pixelLod),true);
if(density<.001)continue;
float alpha=1.0-exp(-density*ds*uCloudOptics.y);
float weight=alpha*transmission;
radiance+=sampleLighting(p,ray,density,max(lod,pixelLod))*weight;
weightedDistance+=t*weight;
transmission*=1.0-alpha;
}
}
void main() {
fragColor=vec4(0,0,0,1);
vec3 worldRay=cameraRay(vUv),ray=cloudToLocal(worldRay);
vec2 outer=cloudSphere(uCloudCamera,ray,uCloudLayer.x+uCloudLayer.y);
float opaque=opaqueClipDistance(vUv)/max(.001,dot(worldRay,uForward));
float start=max(0.0,outer.x),end=min(opaque,outer.y);
if(end<=start)return;
vec2 inner=cloudSphere(uCloudCamera,ray,uCloudLayer.x);
vec3 radiance=vec3(0);float transmission=1.0,weightedDistance=0.0;
if(inner.y>start && inner.x<end) {
marchRange(start,min(end,inner.x),ray,radiance,transmission,weightedDistance);
marchRange(max(start,inner.y),end,ray,radiance,transmission,weightedDistance);
} else marchRange(start,end,ray,radiance,transmission,weightedDistance);
float alpha=1.0-transmission;
if(alpha>.0001) {
float distance=weightedDistance/alpha;
vec2 air=cloudSphere(uCloudCamera,ray,uCloudAir.x);
float airStart=max(0.0,air.x),ds=max(0.0,distance-airStart)/6.0,column=0.0;
for(int i=0;i<6;i++) {
float altitude=cloudAltitude(uCloudCamera+ray*(airStart+(float(i)+.5)*ds));
column+=exp(-max(0.0,altitude)/max(100.0,uCloudAir.x*.055))*ds;
}
vec3 extinction=exp(-(vec3(.000004)+uCloudAirColor*.000012)*column*uCloudAir.y);
vec3 p=uCloudCamera+ray*distance;
vec3 tint=cloudSunTint(p,uCloudPrimarySun);
float warm=1.0-clamp(tint.b/max(.001,tint.r),0.0,1.0);
float day=smoothstep(-.08,.10,dot(normalize(p+vec3(0,uCloudPlanetRadius,0)),uCloudPrimarySun));
vec3 haze=mix(vec3(.45,.63,.87),vec3(.85,.28,.10),warm)*day*.75;
radiance=radiance*extinction+haze*(1.0-extinction)*alpha;
}
fragColor=vec4(radiance,transmission);
}

View File

@@ -0,0 +1,27 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uBloom;
uniform vec3 uTexel;
uniform float uExposure;
out vec4 fragColor;
vec3 tone(vec2 uv) {
vec3 c=(texture(uScene,uv).rgb+texture(uBloom,uv).rgb*0.16)*uExposure;
return clamp((c*(2.51*c+0.03))/(c*(2.43*c+0.59)+0.14),0.0,1.0);
}
void main() {
vec3 c=tone(vUv);
vec3 n=tone(vUv+vec2(0,uTexel.y)), s=tone(vUv-vec2(0,uTexel.y));
vec3 e=tone(vUv+vec2(uTexel.x,0)), w=tone(vUv-vec2(uTexel.x,0));
vec3 luma=vec3(0.299,0.587,0.114);
float l=dot(c,luma), lo=min(l,min(min(dot(n,luma),dot(s,luma)),min(dot(e,luma),dot(w,luma))));
float hi=max(l,max(max(dot(n,luma),dot(s,luma)),max(dot(e,luma),dot(w,luma))));
if(hi-lo>max(0.045,hi*0.18)) {
vec2 dir=vec2(dot(s-n,luma),dot(e-w,luma));
dir=normalize(dir+vec2(0.000001))*uTexel.xy;
c=mix(c,(tone(vUv+dir*0.45)+tone(vUv-dir*0.45))*0.5,0.62);
}
c=pow(c,vec3(1.0/2.2));
float dither=(fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453)-0.5)/255.0;
fragColor=vec4(c+dither,1.0);
}

View File

@@ -0,0 +1,7 @@
#version 330 core
layout(location=0) in vec3 aPosition;
out vec2 vUv;
void main() {
vUv=aPosition.xy*0.5+0.5;
gl_Position=vec4(aPosition.xy,0.0,1.0);
}

View File

@@ -0,0 +1,143 @@
#version 330 core
#include "noise.glsl"
#include "camera-ray.glsl"
#include "ring-optics.glsl"
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform float uFar;
uniform float uRadius;
uniform vec3 uCameraBody;
uniform mat4 uWorldToBody;
uniform vec3 uSurfaceColor;
uniform vec3 uBandColor;
uniform vec3 uPolarColor;
uniform vec3 uHazeColor;
uniform vec3 uSeedOffset;
uniform vec3 uGasShape;
uniform vec3 uGasOptics;
uniform vec3 uGasLighting;
uniform vec3 uGasBands;
uniform vec3 uGasDetail;
uniform vec3 uGasQuality;
uniform float uLightExtinction;
uniform float uStopTransmittance;
uniform float uWindSpeed;
uniform float uTime;
uniform float uBandFrequency;
uniform float uStormStrength;
uniform int uLightCount;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
uniform int uPlanetIndex;
uniform int uOccluderCount;
uniform vec3 uOccluderCenters[16];
uniform float uOccluderRadii[16];
out vec4 fragColor;
vec3 axes() { return vec3(1.0,uGasShape.y,1.0); }
vec2 intersectGas(vec3 o,vec3 d) {
vec3 a=o/axes(), b=d/axes();
float aa=dot(b,b), middle=-dot(a,b)/aa;
vec3 closest=a+b*middle;
float h=uGasShape.x*uGasShape.x-dot(closest,closest);
if(h<0.0)return vec2(1e8,-1e8);
h=sqrt(h/aa);return vec2(middle-h,middle+h);
}
float radialDensity(vec3 p) {
float r=length(p/axes());
float edge=1.0-smoothstep(uGasShape.x-max(0.0001,uGasShape.z*0.25),uGasShape.x,r);
return exp(clamp((1.0-r)/uGasShape.z,-12.0,4.0))*edge;
}
float sunTransmittance(vec3 p,vec3 ld) {
float lengthToExit=max(0.0,intersectGas(p,ld).y);
float optical=0.0;
int count=int(uGasQuality.y);
for(int i=0;i<16;i++) {
if(i>=count)break;
float a=float(i)/float(count), b=float(i+1)/float(count);
float ds=lengthToExit*(b*b-a*a);
float t=lengthToExit*(a*a+b*b)*0.5;
optical+=radialDensity(p+ld*t)*ds*uGasOptics.x;
if(optical*uLightExtinction>15.0)break;
}
return exp(-optical*uLightExtinction);
}
vec3 cloudColor(vec3 p) {
vec3 d=normalize(p/axes());
float latitude=atan(d.y,length(d.xz));
float longitude=atan(d.z,d.x)-uTime*uWindSpeed;
float flow=noise3(vec3(cos(longitude)*8.0,latitude*5.0,sin(longitude)*8.0)+uSeedOffset);
float bandPosition=latitude*uBandFrequency+flow*uGasDetail.x;
float broad=noise3(vec3(bandPosition,2.7,uSeedOffset.x));
float fine=noise3(vec3(latitude*uGasBands.y+flow*0.3,7.1,uSeedOffset.y));
float stripe=clamp(0.5+broad*uGasBands.x+fine*uGasBands.z,0.0,1.0);
vec3 color=mix(uSurfaceColor,uBandColor,stripe);
color*=1.0+noise3(d*uGasDetail.z+uSeedOffset)*uGasDetail.y;
float polar=smoothstep(0.62,1.35,abs(latitude));
color=mix(color,uPolarColor,polar*0.65);
float dx=atan(sin(longitude+0.35),cos(longitude+0.35));
vec2 storm=vec2(dx*cos(latitude),latitude+0.36)/vec2(0.20,0.075);
float spot=exp(-dot(storm,storm))*uStormStrength;
color=mix(color,uBandColor*0.60,spot);
float highAltitude=smoothstep(0.995,uGasShape.x,length(p/axes()));
return mix(color,uHazeColor,highAltitude*0.18);
}
float eclipse(vec3 p,vec3 ld) {
float visibility=1.0;
for(int j=0;j<16;j++) {
if(j>=uOccluderCount)break;
if(j==uPlanetIndex)continue;
vec3 o=uOccluderCenters[j]-p;
float t=dot(o,ld);
if(t>0.0) {
float miss=length(o-ld*t);
float soft=max(0.00001,uOccluderRadii[j]*0.02);
visibility*=smoothstep(uOccluderRadii[j]-soft,uOccluderRadii[j]+soft,miss);
}
}
return visibility;
}
void main() {
vec3 ray=cameraRay(vUv);
vec3 rd=normalize(mat3(uWorldToBody)*ray);
vec2 hit=intersectGas(uCameraBody,rd);
float sceneDepth=texture(uDepth,vUv).r;
float sceneDistance=(exp2(sceneDepth*log2(uFar+1.0))-1.0)/max(dot(ray,uForward),0.001);
float start=max(0.0,hit.x), end=min(hit.y,sceneDistance/uRadius);
if(end<=start)discard;
int count=int(uGasQuality.x);
float transmittance=1.0, firstDepth=-1.0;
vec3 scatter=vec3(0.0);
float jitter=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);
for(int i=0;i<192;i++) {
if(i>=count)break;
float a=pow(float(i)/float(count),uGasQuality.z), b=pow(float(i+1)/float(count),uGasQuality.z);
float ds=(end-start)*(b-a);
float t=start+(end-start)*mix(a,b,0.35+jitter*0.3);
vec3 p=uCameraBody+rd*t;
float rho=radialDensity(p)*(1.0+uGasDetail.y*noise3(normalize(p)*uGasDetail.z+uSeedOffset));
float alpha=1.0-exp(-rho*ds*uGasOptics.x);
vec3 illumination=vec3(uGasLighting.y);
for(int l=0;l<2;l++) {
if(l>=uLightCount)break;
vec3 ld=uLightDirections[l];
float visibility=sunTransmittance(p,ld)*eclipse(p,ld)*ringTransmission(p,ld);
float g=uGasOptics.z, mu=dot(rd,ld);
float phase=(1.0-g*g)/pow(max(0.05,1.0+g*g-2.0*g*mu),1.5);
illumination+=uLightColors[l]*uLightFlux[l]*uGasLighting.x*visibility*mix(1.0,phase,0.35);
}
scatter+=transmittance*alpha*cloudColor(p)*illumination*uGasOptics.y;
transmittance*=1.0-alpha;
if(firstDepth<0.0 && transmittance<0.65)firstDepth=t;
if(transmittance<uStopTransmittance)break;
}
if(transmittance>0.9995)discard;
fragColor=vec4(texture(uScene,vUv).rgb*transmittance+scatter,1.0);
gl_FragDepth=sceneDepth;
if(firstDepth>=0.0) {
float clipDistance=firstDepth*uRadius*max(dot(ray,uForward),0.001);
gl_FragDepth=min(sceneDepth,log2(1.0+clipDistance)/log2(1.0+uFar));
}
}

View File

@@ -0,0 +1,55 @@
uniform sampler3D uNoise;
const mat3 octaveRotation = mat3(0.0,-0.8,-0.6, 0.8,0.36,-0.48, 0.6,-0.48,0.64);
float noise3(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
f = f*f*f*(f*(f*6.0-15.0)+10.0);
return texture(uNoise, (i+f+0.5)/64.0).a;
}
float preciseNoise3(vec3 p) {
ivec3 i=ivec3(floor(p));
vec3 f=fract(p);
f=f*f*f*(f*(f*6.0-15.0)+10.0);
float a=mix(texelFetch(uNoise,i&63,0).a,texelFetch(uNoise,(i+ivec3(1,0,0))&63,0).a,f.x);
float b=mix(texelFetch(uNoise,(i+ivec3(0,1,0))&63,0).a,texelFetch(uNoise,(i+ivec3(1,1,0))&63,0).a,f.x);
float c=mix(texelFetch(uNoise,(i+ivec3(0,0,1))&63,0).a,texelFetch(uNoise,(i+ivec3(1,0,1))&63,0).a,f.x);
float d=mix(texelFetch(uNoise,(i+ivec3(0,1,1))&63,0).a,texelFetch(uNoise,(i+ivec3(1,1,1))&63,0).a,f.x);
return mix(mix(a,b,f.y),mix(c,d,f.y),f.z);
}
float gradientNoise3(vec3 p) {
ivec3 i=ivec3(floor(p));
vec3 f=fract(p), t=f*f*f*(f*(f*6.0-15.0)+10.0);
float a=mix(dot(texelFetch(uNoise,i&63,0).rgb,f),dot(texelFetch(uNoise,(i+ivec3(1,0,0))&63,0).rgb,f-vec3(1,0,0)),t.x);
float b=mix(dot(texelFetch(uNoise,(i+ivec3(0,1,0))&63,0).rgb,f-vec3(0,1,0)),dot(texelFetch(uNoise,(i+ivec3(1,1,0))&63,0).rgb,f-vec3(1,1,0)),t.x);
float c=mix(dot(texelFetch(uNoise,(i+ivec3(0,0,1))&63,0).rgb,f-vec3(0,0,1)),dot(texelFetch(uNoise,(i+ivec3(1,0,1))&63,0).rgb,f-vec3(1,0,1)),t.x);
float d=mix(dot(texelFetch(uNoise,(i+ivec3(0,1,1))&63,0).rgb,f-vec3(0,1,1)),dot(texelFetch(uNoise,(i+ivec3(1,1,1))&63,0).rgb,f-vec3(1,1,1)),t.x);
return clamp(mix(mix(a,b,t.y),mix(c,d,t.y),t.z)*1.7,-1.0,1.0);
}
float fbm(vec3 p) {
float result=0.0, amplitude=0.5;
for(int i=0;i<5;i++) {
result += noise3(p)*amplitude;
p = octaveRotation*p*2.03 + vec3(17.1,9.2,13.5);
amplitude *= 0.5;
}
return result;
}
float terrainHeight(vec3 direction, vec3 offset, int octaves) {
vec3 p = direction*3.2 + offset;
vec3 warp = vec3(preciseNoise3(p*0.82+vec3(5.2,1.3,9.7)),
preciseNoise3(p*0.82+vec3(19.1,7.7,2.4)),
preciseNoise3(p*0.82+vec3(3.4,21.8,11.6)));
float continent=preciseNoise3(p*0.47);
p=direction*52.0+offset+warp*2.6;
float weight=1.0, amplitude=0.58, sum=0.0;
for(int i=0;i<7;i++) {
if(i>=octaves) break;
float ridge = 1.0-abs(gradientNoise3(p));
ridge = ridge*ridge*ridge*weight;
sum += ridge*amplitude;
weight=0.55+0.45*ridge;
p=octaveRotation*p*2.03 + vec3(17.1,9.2,13.5);
amplitude*=0.50;
}
return (sum-0.42)*0.95+continent*0.16;
}

View File

@@ -0,0 +1,208 @@
#version 330 core
#include "noise.glsl"
#include "planet-height.glsl"
#include "camera-ray.glsl"
#include "cloud-common.glsl"
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform float uFar;
uniform float uSeaRadius;
uniform float uCameraHeight;
uniform vec3 uAxisX;
uniform vec3 uAxisY;
uniform vec3 uAxisZ;
uniform vec3 uWaveVectors[12];
uniform float uWavePhases[12];
uniform float uWaveAmplitudes[12];
uniform vec3 uDetailOrigins[2];
uniform vec3 uWaveSettings; // maximum displacement, choppiness, pixel angular size
uniform vec3 uWaterOptics; // roughness, reflection, refraction
uniform vec3 uDeepColor;
uniform vec3 uShallowColor;
uniform vec3 uAbsorption;
uniform float uFoamStrength;
uniform int uLightCount;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
uniform float uTime;
out vec4 fragColor;
const float PI=3.14159265359;
vec3 toLocal(vec3 v) { return vec3(dot(v,uAxisX),dot(v,uAxisY),dot(v,uAxisZ)); }
vec3 toWorld(vec3 v) { return uAxisX*v.x+uAxisY*v.y+uAxisZ*v.z; }
float sceneDistance(vec2 uv,vec3 ray) {
return (exp2(texture(uDepth,uv).r*log2(uFar+1.0))-1.0)/max(.001,dot(ray,uForward));
}
// Stable roots avoid subtracting two planet-sized floats when the camera is metres above water.
vec2 oceanSphere(vec3 ro,vec3 rd,float expansion) {
float b=dot(ro+vec3(0,uSeaRadius,0),rd);
float c=dot(ro,ro)+2.0*uSeaRadius*(ro.y-expansion)-expansion*expansion;
float h=b*b-c;
if(h<0.0)return vec2(1e25,-1e25);
float q=-b-(b<0.0?-1.0:1.0)*sqrt(h);
float a=q, d=c/(abs(q)<1e-12?1e-12:q);
return vec2(min(a,d),max(a,d));
}
float radialHeight(vec3 p) {
return (dot(p,p)+2.0*uSeaRadius*p.y)/(length(p+vec3(0,uSeaRadius,0))+uSeaRadius);
}
float waveHeight(vec3 p,float footprint) {
float height=0.0;
for(int i=0;i<8;i++) {
float weight=1.0-smoothstep(.35,1.0,length(uWaveVectors[i])*footprint);
float base=dot(p,uWaveVectors[i])+uWavePhases[i];
float phase=base;
for(int j=0;j<2;j++)phase=base-uWaveSettings.y*.48*cos(phase);
height+=sin(phase)*uWaveAmplitudes[i]*weight;
}
return height;
}
vec3 waveNormal(vec3 p,float footprint,out float compression) {
vec3 gradient=vec3(0); compression=0.0;
float total=0.0;
for(int i=0;i<12;i++) {
float weight=1.0-smoothstep(.35,1.0,length(uWaveVectors[i])*footprint);
float base=dot(p,uWaveVectors[i])+uWavePhases[i], phase=base;
float q=uWaveSettings.y*.48;
for(int j=0;j<3;j++)phase=base-q*cos(phase);
float a=uWaveAmplitudes[i]*weight;
gradient+=uWaveVectors[i]*(a*cos(phase)/max(.15,1.0-q*sin(phase)));
compression+=max(0.0,sin(phase))*a;
total+=a;
}
compression/=max(.001,total);
vec3 up=normalize(p+vec3(0,uSeaRadius,0));
gradient-=up*dot(gradient,up);
// Capillary ripples use a planet-anchored noise field and disappear below the pixel footprint.
vec3 bodyPoint=mat3(uWorldToBody)*toWorld(p);
vec3 bodyX=mat3(uWorldToBody)*uAxisX,bodyZ=mat3(uWorldToBody)*uAxisZ;
for(int detail=0;detail<2;detail++) {
float frequency=detail==0?.35:1.4;
float weight=1.0-smoothstep(.25,.8,footprint*frequency);
vec3 q=bodyPoint*frequency+uDetailOrigins[detail];
float gx=(noise3(q+bodyX*.04)-noise3(q-bodyX*.04))/.08;
float gz=(noise3(q+bodyZ*.04)-noise3(q-bodyZ*.04))/.08;
gradient+=vec3(gx,0,gz)*weight*(detail==0?.13:.075);
}
return normalize(up-gradient);
}
vec3 skyReflection(vec3 direction,vec3 up) {
float daylight=0.0;
for(int l=0;l<2;l++){if(l>=uLightCount)break;daylight+=sqrt(uLightFlux[l])*smoothstep(-.08,.16,dot(up,uLightDirections[l]));}
float elevation=max(0.0,dot(direction,up));
vec3 sky=mix(vec3(.56,.73,.87),vec3(.12,.32,.64),sqrt(elevation));
for(int l=0;l<2;l++) {
if(l>=uLightCount)break;
sky+=uLightColors[l]*pow(max(0.0,dot(direction,uLightDirections[l])),24.0)*.22*uLightFlux[l];
}
return sky*(.006+daylight*1.35);
}
vec3 screenReflection(vec3 position,vec3 direction,vec3 fallback) {
vec3 world=toWorld(position-vec3(0,uCameraHeight,0));
vec3 reflected=toWorld(direction);
float stepSize=3.0;
for(int i=0;i<9;i++) {
vec3 q=world+reflected*stepSize;
float z=dot(q,uForward);
if(z>.1) {
vec2 uv=.5+vec2(dot(q,uRight)/(uAspect*uTanHalfFov),dot(q,uUp)/uTanHalfFov)/(2.0*z);
if(all(greaterThan(uv,vec2(.015))) && all(lessThan(uv,vec2(.985)))) {
float depth=exp2(texture(uDepth,uv).r*log2(uFar+1.0))-1.0;
if(z>depth && z-depth<max(1.0,stepSize*.09))return texture(uScene,uv).rgb;
}
}
stepSize*=1.9;
}
return fallback;
}
void main() {
vec3 scene=texture(uScene,vUv).rgb;
float originalDepth=texture(uDepth,vUv).r;
gl_FragDepth=originalDepth;
vec3 worldRay=cameraRay(vUv), rd=toLocal(worldRay), ro=vec3(0,uCameraHeight,0);
float opaque=sceneDistance(vUv,worldRay);
float displacement=uWaveSettings.x;
bool underwater=uCameraHeight<displacement && uCameraHeight<waveHeight(ro,.02);
vec2 envelope=oceanSphere(ro,rd,displacement);
vec2 base=oceanSphere(ro,rd,0.0);
float t=base.x>0.0?base.x:base.y;
bool hit=envelope.y>0.0 && t>0.0;
if(hit && abs(uCameraHeight)<2500.0 && t<2000.0) {
vec2 inner=oceanSphere(ro,rd,-displacement);
float low=max(.015,envelope.x),high=inner.x>0.0?inner.x:envelope.y;
if(underwater) {low=max(.015,inner.y);high=envelope.y;}
float reference=radialHeight(ro+rd*low)-waveHeight(ro+rd*low,max(.03,low*uWaveSettings.z));
float endValue=radialHeight(ro+rd*high)-waveHeight(ro+rd*high,max(.03,high*uWaveSettings.z));
if(reference*endValue<0.0 && high>low) {
for(int i=0;i<18;i++) {
float mid=(low+high)*.5;
vec3 p=ro+rd*mid;
float height=radialHeight(p)-waveHeight(p,max(.03,mid*uWaveSettings.z));
if(height*reference>0.0)low=mid;else high=mid;
}
float solved=(low+high)*.5;
t=mix(solved,t,smoothstep(800.0,2000.0,t));
}
}
vec3 p=ro+rd*max(0.0,t);
vec3 radial=normalize(uAxisY+toWorld(p)/uSeaRadius);
float waterMask=uHasOceanMask!=0?oceanMaskValue(bodyDirection(radial)):1.0;
hit=hit && waterMask>.01 && t<opaque+.15;
if(!hit) {
float maskHere=uHasOceanMask!=0?oceanMaskValue(bodyDirection(uAxisY)):1.0;
if(underwater && maskHere>.5) {
vec3 transmission=exp(-uAbsorption*min(opaque,400.0));
scene=scene*transmission+uShallowColor*(1.0-transmission)*.5;
}
fragColor=vec4(scene,1);return;
}
float footprint=max(.02,t*uWaveSettings.z/max(.12,abs(dot(rd,normalize(p+vec3(0,uSeaRadius,0))))));
float compression;
vec3 n=waveNormal(p,footprint,compression), up=normalize(p+vec3(0,uSeaRadius,0));
vec3 view=-rd;
if(dot(n,view)<0.0)n=-n;
float ndv=max(.02,dot(n,view));
float fresnel=.0204+.9796*pow(1.0-ndv,5.0);
vec3 reflectionDirection=reflect(rd,n);
vec3 reflected=skyReflection(reflectionDirection,up);
reflected=cloudReflection(toWorld(p-ro),toWorld(reflectionDirection),reflected);
if(t<1500.0 && !underwater)reflected=screenReflection(p,reflectionDirection,reflected);
vec2 refractUv=clamp(vUv+vec2(dot(toWorld(n),uRight),dot(toWorld(n),uUp))*uWaterOptics.z/(1.0+t*.012),vec2(.001),vec2(.999));
float floorDistance=sceneDistance(refractUv,cameraRay(refractUv));
float thickness=max(.0,min(400.0,floorDistance-t));
vec3 transmission=exp(-uAbsorption*thickness);
vec3 scatter=mix(uShallowColor,uDeepColor,1.0-exp(-thickness*.075));
vec3 lighting=vec3(.04), highlights=vec3(0);
for(int l=0;l<2;l++) {
if(l>=uLightCount)break;
vec3 light=uLightDirections[l];
float sunHeight=dot(up,light), visible=smoothstep(-.02,.03,sunHeight);
visible*=cloudShadow(toWorld(p-ro),toWorld(light));
float ndl=max(.001,dot(n,light));
lighting+=uLightColors[l]*uLightFlux[l]*visible*(.25+.75*max(0.0,sunHeight));
vec3 halfVector=normalize(view+light);
float ndh=max(0.0,dot(n,halfVector));
float alpha=max(.045,uWaterOptics.x*uWaterOptics.x+min(.14,footprint*.001));
float a2=alpha*alpha, den=ndh*ndh*(a2-1.0)+1.0;
float distribution=a2/(PI*den*den);
float k=(alpha+1.0)*(alpha+1.0)/8.0;
float geometry=ndv/(ndv*(1.0-k)+k)*ndl/(ndl*(1.0-k)+k);
float f=.0204+.9796*pow(1.0-max(0.0,dot(view,halfVector)),5.0);
highlights+=uLightColors[l]*uLightFlux[l]*visible*min(25.0,distribution*geometry*f/(4.0*ndv))*1.8;
scatter+=uShallowColor*pow(max(0.0,dot(view,-light)),4.0)*compression*.28*visible;
}
vec3 refracted=texture(uScene,refractUv).rgb*transmission+scatter*(1.0-transmission)*lighting;
vec3 color=mix(refracted,reflected*uWaterOptics.y,fresnel)+highlights;
float shore=(1.0-smoothstep(.2,3.5,thickness))*(.5+.5*sin(uTime*1.3+t*.03));
vec3 bodyDetail=mat3(uWorldToBody)*toWorld(p)*.35+uDetailOrigins[0];
float breakup=noise3(bodyDetail+vec3(uTime*.09,0,uTime*.06))*.5+.5;
float foam=clamp((smoothstep(.54,.78,compression)+shore*.7)*uFoamStrength*smoothstep(.22,.72,breakup),0.0,.9);
color=mix(color,vec3(.72,.86,.88)*lighting,foam);
if(underwater) {
vec3 through=exp(-uAbsorption*min(t,400.0));
color=color*through+uShallowColor*(1.0-through)*.5;
}
fragColor=vec4(max(color,vec3(0)),1);
gl_FragDepth=clamp(log2(1.0+t*max(.001,dot(worldRay,uForward)))/log2(uFar+1.0),0.0,1.0);
}

View File

@@ -0,0 +1,53 @@
uniform sampler2D uHeightMap;
uniform int uHasHeightMap;
uniform float uRadius;
uniform float uAmplitude;
uniform float uNoiseFrequency;
uniform vec3 uNoiseOffset;
uniform vec3 uHeightRange;
uniform vec3 uLandMask;
uniform mat4 uWorldToBody;
uniform sampler2D uOceanMask;
uniform int uHasOceanMask;
uniform vec3 uOceanTerrain; // sea level/radius, depth/radius, enabled
uniform sampler2D uRegionalHeight;
uniform int uHasRegionalHeight;
uniform vec3 uRegionBounds; // west, south, east in degrees
uniform vec3 uRegionOther; // north, fade in degrees
vec3 bodyDirection(vec3 direction) { return normalize(mat3(uWorldToBody)*normalize(direction)); }
vec2 planetUv(vec3 d) { return vec2(0.5-atan(d.z,d.x)/6.28318530718,atan(length(d.xz),d.y)/3.14159265359); }
float mapTexel(sampler2D raster,ivec2 p, ivec2 size) {
// Bilinear sampling can cross only one column outside either longitude edge.
int x=p.x<0 ? p.x+size.x : (p.x>=size.x ? p.x-size.x : p.x);
return texelFetch(raster,ivec2(x,clamp(p.y,0,size.y-1)),0).r;
}
float rasterValue(sampler2D raster,vec2 uv) {
ivec2 size=textureSize(raster,0);
vec2 p=vec2(fract(uv.x)*float(size.x)-0.5,clamp(uv.y*float(size.y)-0.5,0.0,float(size.y-1)));
ivec2 i=ivec2(floor(p)); vec2 f=fract(p);
float a=mix(mapTexel(raster,i,size),mapTexel(raster,i+ivec2(1,0),size),f.x);
float b=mix(mapTexel(raster,i+ivec2(0,1),size),mapTexel(raster,i+ivec2(1,1),size),f.x);
return mix(a,b,f.y);
}
float heightMapValue(vec3 localDirection) { return rasterValue(uHeightMap,planetUv(localDirection)); }
float oceanMaskValue(vec3 localDirection) { return rasterValue(uOceanMask,planetUv(localDirection)); }
float surfaceElevation(vec3 worldDirection) {
vec3 d=bodyDirection(worldDirection);
float mapValue=uHasHeightMap!=0 ? heightMapValue(d) : 0.0;
float mapped=uHasHeightMap!=0 ? mix(uHeightRange.x,uHeightRange.y,pow(mapValue,uHeightRange.z)) : 0.0;
if(uHasRegionalHeight!=0) {
// Avoid subtracting nearby global UVs: the regional tile is only one degree wide.
float lon=-degrees(atan(d.z,d.x)),lat=degrees(atan(d.y,length(d.xz)));
float edge=min(min(lon-uRegionBounds.x,uRegionBounds.z-lon),min(lat-uRegionBounds.y,uRegionOther.x-lat));
if(edge>0.0) {
vec2 pos=vec2((lon-uRegionBounds.x)/(uRegionBounds.z-uRegionBounds.x),
(uRegionOther.x-lat)/(uRegionOther.x-uRegionBounds.y));
mapped=mix(mapped,texture(uRegionalHeight,clamp(pos,0.0,1.0)).r/uRadius,smoothstep(0.0,uRegionOther.y,edge));
}
}
float mask=(uHasHeightMap!=0 && uLandMask.x>0.5) ? smoothstep(uLandMask.y,uLandMask.y+uLandMask.z,mapValue) : 1.0;
float detail=uAmplitude>0.0 ? terrainHeight(d*uNoiseFrequency,uNoiseOffset,9)*uAmplitude*mask : 0.0;
float water=uHasOceanMask!=0 ? oceanMaskValue(d) : 0.0;
return mix(mapped+detail,uOceanTerrain.x-uOceanTerrain.y,water*uOceanTerrain.z);
}

View File

@@ -0,0 +1,10 @@
#version 330 core
in vec3 vColor;
uniform float uBrightness;
out vec4 fragColor;
void main() {
float r=length(gl_PointCoord*2.0-1.0);
if(r>1.0) discard;
float core=exp(-r*r*6.0);
fragColor=vec4(vColor*core*uBrightness,1.0);
}

View File

@@ -0,0 +1,15 @@
#version 330 core
layout(location=0) in vec3 aPosition;
layout(location=1) in vec3 aColor;
layout(location=2) in float aSize;
uniform mat4 uViewProjection;
uniform float uFar;
uniform float uScale;
out vec3 vColor;
void main() {
vec4 clip=uViewProjection*vec4(aPosition*uScale,1.0);
gl_Position=clip;
gl_Position.z=(2.0*log2(max(0.0001,clip.w+1.0))/log2(uFar+1.0)-1.0)*clip.w;
gl_PointSize=aSize;
vColor=aColor;
}

View File

@@ -0,0 +1,17 @@
uniform sampler2D uRingProfile;
uniform int uHasRings;
uniform vec3 uRingBounds;
uniform float uRingShadowSoftness;
vec4 ringMaterial(float radius) {
if(radius<uRingBounds.x || radius>uRingBounds.y)return vec4(0.0);
float u=(radius-uRingBounds.x)/(uRingBounds.y-uRingBounds.x);
return textureLod(uRingProfile,vec2(u,0.5),0.0);
}
float ringTransmission(vec3 bodyPosition,vec3 bodyLightDirection) {
if(uHasRings==0 || abs(bodyLightDirection.y)<0.000001)return 1.0;
float t=-bodyPosition.y/bodyLightDirection.y;
if(t<=0.0)return 1.0;
float r=length((bodyPosition+bodyLightDirection*t).xz);
float tau=(ringMaterial(r-uRingShadowSoftness).a+2.0*ringMaterial(r).a+ringMaterial(r+uRingShadowSoftness).a)*0.25;
return exp(-tau/max(abs(bodyLightDirection.y),0.035));
}

View File

@@ -0,0 +1,54 @@
#version 330 core
#include "camera-ray.glsl"
#include "ring-optics.glsl"
in vec2 vUv;
uniform float uRadius;
uniform float uFar;
uniform vec3 uCameraBody;
uniform mat4 uWorldToBody;
uniform float uPolarScale;
uniform vec3 uRingLighting;
uniform vec3 uRingPhase;
uniform vec3 uRockRange;
uniform int uLightCount;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
out vec4 fragColor;
float planetShadow(vec3 p,vec3 l) {
vec3 o=p/vec3(1,uPolarScale,1),d=l/vec3(1,uPolarScale,1);
float t=max(0.0,-dot(o,d)/dot(d,d));
float miss=length(o+d*t);
return smoothstep(1.0-uRingShadowSoftness,1.0+uRingShadowSoftness,miss);
}
void main() {
vec3 ray=cameraRay(vUv),rd=normalize(mat3(uWorldToBody)*ray);
if(abs(rd.y)<1e-9)discard;
float t=-uCameraBody.y/rd.y;
if(t<=0.0)discard;
vec3 p=uCameraBody+rd*t;
float r=length(p.xz);
if(r<uRingBounds.x || r>uRingBounds.y)discard;
// Derivatives choose the radial profile's mip level, avoiding distant moire.
float x=(r-uRingBounds.x)/(uRingBounds.y-uRingBounds.x);
vec4 material=texture(uRingProfile,vec2(x,0.5));
float optical=material.a;
if(optical<0.0001)discard;
float meters=t*uRadius;
float resolved=1.0-smoothstep(uRockRange.x,uRockRange.y,meters);
float dust=mix(1.0,uRockRange.z,resolved);
float alpha=(1.0-exp(-optical/max(abs(rd.y),0.025)))*dust;
vec3 light=vec3(uRingLighting.y);
for(int i=0;i<2;i++) {
if(i>=uLightCount)break;
vec3 l=uLightDirections[i];
float g=uRingPhase.x,mu=dot(rd,l);
float phase=(1.0-g*g)/pow(max(0.06,1.0+g*g-2.0*g*mu),1.5);
float sameSide=step(0.0,-rd.y*l.y);
float reflection=(0.20+0.80*sqrt(abs(l.y)))*sameSide;
float transmission=uRingPhase.y*phase*(1.0-sameSide)*exp(-optical*0.6);
light+=uLightColors[i]*uLightFlux[i]*uRingLighting.x*planetShadow(p,l)*(reflection+transmission);
}
fragColor=vec4(material.rgb*light,alpha);
gl_FragDepth=log2(1.0+meters*max(dot(ray,uForward),0.001))/log2(1.0+uFar);
}

View File

@@ -0,0 +1,16 @@
#version 330 core
layout(location=0) in vec3 aPosition;
uniform mat4 uModel;
uniform mat4 uViewProjection;
uniform float uFar;
out vec3 vRingPosition;
out vec3 vWorldPosition;
out float vFragDepth;
void main() {
vRingPosition=aPosition;
vWorldPosition=(uModel*vec4(aPosition,1.0)).xyz;
vec4 clip=uViewProjection*vec4(vWorldPosition,1.0);
vFragDepth=clip.w+1.0;
gl_Position=clip;
gl_Position.z=(2.0*log2(max(0.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
}

View File

@@ -0,0 +1,37 @@
uniform float uFar;
uniform float uRadius;
uniform float uPolarScale;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
uniform int uLightCount;
uniform vec3 uRockLighting;
uniform vec3 uRockMaterial;
uniform mat4 uWorldToBody;
uniform vec3 uCameraBody;
uniform vec3 uRockLod;
uniform vec3 uRockFade;
float rockMeshWeight(float distanceMeters,float pixels) {
return (1.0-smoothstep(uRockLod.x-uRockLod.y,uRockLod.x+uRockLod.y,distanceMeters))*smoothstep(uRockLod.z*0.65,uRockLod.z*1.35,pixels);
}
float rockPlanetShadow(vec3 p,vec3 l) {
vec3 o=p/vec3(1,uPolarScale,1),d=l/vec3(1,uPolarScale,1);
float t=max(0.0,-dot(o,d)/dot(d,d));
return smoothstep(1.0-uRingShadowSoftness,1.0+uRingShadowSoftness,length(o+d*t));
}
vec3 rockIllumination(vec3 color,vec3 normal,vec3 worldPosition,float seed) {
vec3 p=uCameraBody+mat3(uWorldToBody)*worldPosition/uRadius;
vec3 n=normalize(mat3(uWorldToBody)*normal);
vec3 view=normalize(mat3(uWorldToBody)*(-worldPosition));
vec3 light=color*uRockLighting.y;
for(int i=0;i<2;i++) {
if(i>=uLightCount)break;
vec3 l=uLightDirections[i];
float selfShadow=ringTransmission(p,l);
float visibility=rockPlanetShadow(p,l)*mix(0.3,1.0,selfShadow);
float diffuse=max(dot(n,l),0.0);
float ice=pow(max(dot(n,normalize(l+view)),0.0),mix(70.0,8.0,uRockMaterial.y))*uRockMaterial.z;
light+=(color*diffuse+vec3(ice))*uLightColors[i]*uLightFlux[i]*uRockLighting.x*visibility;
}
return light;
}

View File

@@ -0,0 +1,13 @@
#version 330 core
in vec3 vColor;
in float vAlpha;
in float vClipDepth;
uniform float uFar;
out vec4 fragColor;
void main() {
float r=length(gl_PointCoord*2.0-1.0);
if(r>1.0 || vAlpha<0.0005)discard;
float alpha=(1.0-smoothstep(0.4,1.0,r))*vAlpha;
fragColor=vec4(vColor,alpha);
gl_FragDepth=log2(vClipDepth)/log2(1.0+uFar);
}

View File

@@ -0,0 +1,28 @@
#version 330 core
layout(location=2)in vec3 aOffset;
layout(location=3)in float aRadius;
layout(location=4)in vec3 aColor;
layout(location=5)in float aSeed;
uniform mat4 uViewProjection;
uniform mat4 uBodyToWorld;
uniform vec3 uAnchorWorld;
uniform float uPixelScale;
#include "ring-optics.glsl"
#include "rock-common.glsl"
out vec3 vColor;
out float vAlpha;
out float vClipDepth;
void main() {
vec3 position=uAnchorWorld+mat3(uBodyToWorld)*aOffset;
float distanceMeters=length(position);
float pixels=2.0*aRadius*uPixelScale/max(0.1,distanceMeters);
float weight=1.0-rockMeshWeight(distanceMeters,pixels);
weight*=1.0-smoothstep(uRockFade.x,uRockFade.y,distanceMeters);
float size=clamp(pixels,1.0,24.0);
vAlpha=weight*min(1.0,pixels*pixels/(size*size));
vColor=rockIllumination(aColor,normalize(-position),position,aSeed);
gl_Position=uViewProjection*vec4(position,1);
vClipDepth=1.0+max(0.0,gl_Position.w);
gl_Position.z=(2.0*log2(vClipDepth)/log2(1.0+uFar)-1.0)*gl_Position.w;
gl_PointSize=size;
}

View File

@@ -0,0 +1,35 @@
#version 330 core
#include "noise.glsl"
#include "ring-optics.glsl"
#include "rock-common.glsl"
#include "asteroid-shape.glsl"
in vec3 vPosition;
in vec3 vNormal;
in vec3 vRockLocal;
in vec3 vColor;
flat in float vSeed;
in float vWeight;
in float vClipDepth;
flat in vec4 vRotation;
out vec4 fragColor;
uniform int uDebugNormals;
void main() {
float dither=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);
vec3 d=normalize(vRockLocal);
float footprint=max(length(dFdx(d)),length(dFdy(d)));
vec3 axes=asteroidAxes(vSeed);
vec3 p=asteroidPosition(d,vSeed)+d*axes*asteroidMicroHeight(d,vSeed,footprint);
vec3 localNormal=normalize(cross(dFdx(p),dFdy(p)));
if(dot(localNormal,d)<0.0)localNormal=-localNormal;
localNormal=normalize(mix(normalize(d/axes),localNormal,uAsteroidNoise.z));
vec3 rotated=localNormal+2.0*cross(vRotation.xyz,cross(vRotation.xyz,localNormal)+vRotation.w*localNormal);
vec3 normal=normalize(transpose(mat3(uWorldToBody))*rotated);
float geology=noise3(d*19.0+vSeed*119.0);
float crater=asteroidCraters(d,uAsteroidCraters.x,vSeed)*uAsteroidCraters.x;
float cavity=mix(.55,1.0,smoothstep(-.25,0.0,crater));
if(dither>vWeight)discard;
fragColor=vec4(rockIllumination(vColor*(.88+geology*.17)*cavity,normal,vPosition,vSeed),1.0);
if(uDebugNormals==1)fragColor=vec4(normal*.5+.5,1);
if(uDebugNormals==2)fragColor=vec4(1,1,1,1);
gl_FragDepth=log2(vClipDepth)/log2(1.0+uFar);
}

View File

@@ -0,0 +1,40 @@
#version 330 core
#include "noise.glsl"
layout(location=0)in vec3 aPosition;
layout(location=1)in vec3 aNormal;
layout(location=2)in vec3 aOffset;
layout(location=3)in float aRadius;
layout(location=4)in vec3 aColor;
layout(location=5)in float aSeed;
layout(location=6)in vec4 aRotation;
uniform mat4 uViewProjection;
uniform mat4 uBodyToWorld;
uniform vec3 uAnchorWorld;
uniform float uPixelScale;
#include "ring-optics.glsl"
#include "rock-common.glsl"
#include "asteroid-shape.glsl"
out vec3 vPosition;
out vec3 vNormal;
out vec3 vRockLocal;
out vec3 vColor;
flat out float vSeed;
out float vWeight;
out float vClipDepth;
flat out vec4 vRotation;
vec3 rotate(vec3 v) { return v+2.0*cross(aRotation.xyz,cross(aRotation.xyz,v)+aRotation.w*v); }
void main() {
vec3 center=uAnchorWorld+mat3(uBodyToWorld)*aOffset;
float distanceMeters=length(center);
float pixels=2.0*aRadius*uPixelScale/max(0.1,distanceMeters);
vWeight=rockMeshWeight(distanceMeters,pixels)*(1.0-smoothstep(uRockFade.x,uRockFade.y,distanceMeters));
vec3 local=rotate(asteroidPosition(normalize(aPosition),aSeed))*aRadius;
vPosition=center+mat3(uBodyToWorld)*local;
vNormal=normalize(mat3(uBodyToWorld)*rotate(aNormal));
vRockLocal=aPosition;
vRotation=aRotation;
vColor=aColor;vSeed=aSeed;
gl_Position=uViewProjection*vec4(vPosition,1);
vClipDepth=1.0+max(0.0,gl_Position.w);
gl_Position.z=(2.0*log2(vClipDepth)/log2(1.0+uFar)-1.0)*gl_Position.w;
}

View File

@@ -0,0 +1,32 @@
#version 330 core
#include "noise.glsl"
in vec2 vUv;
uniform vec3 uForward;
uniform vec3 uRight;
uniform vec3 uUp;
uniform float uAspect;
uniform float uTanHalfFov;
uniform int uLightCount;
uniform vec3 uStarDirections[2];
uniform vec3 uStarColors[2];
uniform float uStarAngles[2];
uniform float uTime;
out vec4 fragColor;
void main() {
vec2 xy=(vUv*2.0-1.0)*uTanHalfFov;
vec3 rd=normalize(uForward+uRight*xy.x*uAspect+uUp*xy.y);
float band=pow(max(0.0,1.0-abs(dot(rd,normalize(vec3(0.32,0.72,0.48))))),17.0);
float dust=smoothstep(-0.26,0.36,fbm(rd*8.0+vec3(4.1,9.2,3.4)));
vec3 color=vec3(0.00013,0.00020,0.00042)+vec3(0.0013,0.0016,0.0026)*band*dust;
for(int i=0;i<2;i++) {
if(i>=uLightCount) break;
float angle=acos(clamp(dot(rd,uStarDirections[i]),-1.0,1.0));
float r=angle/max(uStarAngles[i],0.00001);
float corona=exp(-max(0.0,r-1.0)*6.0)*0.12;
corona+=exp(-max(0.0,r-1.0)*1.5)*0.004;
corona+=0.0002*exp(-angle*28.0);
float strands=0.85+0.15*noise3(rd*18.0+uTime*0.015);
color+=uStarColors[i]*corona*strands;
}
fragColor=vec4(color,1.0);
}

View File

@@ -0,0 +1,33 @@
#version 330 core
#include "noise.glsl"
in vec3 vDirection;
in vec3 vWorldPosition;
in float vFragDepth;
uniform vec3 uColor;
uniform vec3 uNoiseOffset;
uniform float uTime;
uniform float uFar;
out vec4 fragColor;
void main() {
vec3 d=normalize(vDirection);
vec3 p=d*2.4+uNoiseOffset+vec3(0.0,uTime*0.018,uTime*0.006);
vec3 q=vec3(fbm(p),fbm(p+vec3(8.3,1.7,4.6)),fbm(p+vec3(1.2,9.4,13.7)));
vec3 w=vec3(fbm(p*1.8+q*4.2+vec3(5.2,1.3,0.7)),fbm(p*1.8+q*4.2+vec3(9.1,6.7,3.4)),fbm(p*1.8+q*4.2+vec3(2.3,8.7,9.2)));
float field=fbm(p*0.8+w*2.0+q*3.0);
float activeRegions=fbm(d*3.0+uNoiseOffset*0.2);
float filaments=pow(1.0-abs(noise3(p*4.0+w*6.0)),8.0);
float cells=noise3(d*200.0+w*10.0)*0.5+0.5;
float hot=smoothstep(-0.14,0.23,field)*(0.25+0.75*smoothstep(-0.30,0.32,activeRegions));
float spots=1.0-smoothstep(0.37,0.57,fbm(d*7.0+uNoiseOffset*0.2))*0.82;
vec3 cool=vec3(0.55,0.13,0.085);
vec3 bright=vec3(2.5,1.50,0.45);
vec3 plasma=mix(cool,bright,pow(hot,0.85));
plasma+=vec3(0.8,0.48,0.10)*filaments*0.25;
plasma*=mix(0.96,1.04,cells)*spots;
float blueStar=smoothstep(0.88,1.16,uColor.b/max(uColor.r,0.01));
plasma=mix(plasma,vec3(0.65,1.05,2.15)*(0.65+hot*2.4+filaments*0.5),blueStar);
float mu=max(dot(d,normalize(-vWorldPosition)),0.0);
float limb=0.09+0.91*pow(mu,0.80);
fragColor=vec4(plasma*limb,1.0);
gl_FragDepth=clamp(log2(max(0.0001,vFragDepth))/log2(uFar+1.0),0.0,1.0);
}

View File

@@ -0,0 +1,17 @@
#version 330 core
layout(location=0) in vec3 aPosition;
uniform mat4 uViewProjection;
uniform vec3 uCenter;
uniform float uRadius;
uniform float uFar;
out vec3 vDirection;
out vec3 vWorldPosition;
out float vFragDepth;
void main() {
vDirection=aPosition;
vWorldPosition=uCenter+aPosition*uRadius;
vec4 clip=uViewProjection*vec4(vWorldPosition,1.0);
vFragDepth=clip.w+1.0;
gl_Position=clip;
gl_Position.z=(2.0*log2(max(0.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
}

View File

@@ -0,0 +1,69 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform int uStarCount;
uniform float uAspect;
uniform vec3 uStarPosition[2]; // UV position, logarithmic front depth
uniform vec3 uStarShape[2]; // apparent radius, intensity, minimum core radius
uniform vec3 uStarHalo[2]; // halo radius, halo strength, ray strength
uniform vec3 uStarRays[2]; // ray length, ray count, ray sharpness
uniform vec3 uStarGhosts[2]; // strength, spacing, chromatic aberration
uniform vec3 uStarColors[2];
uniform float uStarRotation[2];
out vec4 fragColor;
float starVisibility(int i) {
vec2 center=uStarPosition[i].xy;
if(any(lessThan(center,vec2(0)))||any(greaterThan(center,vec2(1))))return 0.0;
float visible=0.0,total=0.0;
for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++) {
vec2 uv=center+vec2(x/uAspect,y)*uStarShape[i].x*.55;
if(any(lessThan(uv,vec2(0)))||any(greaterThan(uv,vec2(1))))continue;
float weight=(x==0 && y==0)?4.0:1.0;
visible+=step(uStarPosition[i].z-.000002,texture(uDepth,uv).r)*texture(uScene,uv).a*weight;total+=weight;
}
return visible/max(1.0,total);
}
float aperture(vec2 p,float radius) {
float angle=atan(p.y,p.x);
float hex=cos(floor(.5+angle/1.04719755)*1.04719755-angle)*length(p);
return (1.0-smoothstep(radius*.86,radius,hex))*.12+exp(-pow((hex-radius*.88)/(radius*.09),2.0))*.24;
}
vec3 sunGlare(int i) {
float intensity=uStarShape[i].y*starVisibility(i);
if(intensity<.00001)return vec3(0.0);
vec2 metric=vec2(uAspect,1.0),center=uStarPosition[i].xy;
vec2 p=(vUv-center)*metric;
float r=length(p),angle=atan(p.y,p.x)+uStarRotation[i];
float core=max(uStarShape[i].z,uStarShape[i].x*.075);
float halo=uStarHalo[i].x;
float central=14.0*exp(-r*r/max(1e-12,core*core));
float glow=exp(-r/max(.0001,halo))*uStarHalo[i].y/(1.0+r/max(core*16.0,.0001));
float rays=pow(.5+.5*cos(angle*uStarRays[i].y),uStarRays[i].z);
float smallRays=pow(.5+.5*cos(angle*(uStarRays[i].y+6.0)+1.1),uStarRays[i].z*1.5)*.25;
float taper=exp(-r/max(.0001,uStarRays[i].x))/(1.0+r/max(core*5.0,.0001));
vec3 color=uStarColors[i]*(central+glow+(rays+smallRays)*taper*uStarHalo[i].z);
vec2 axis=vec2(.5)-center;
float axisLength=length(axis*metric);
float ghostWeight=smoothstep(.025,.15,axisLength)*uStarGhosts[i].x;
for(int ghost=0;ghost<6;ghost++) {
float g=float(ghost);
vec2 ghostCenter=center+axis*(.7+g*uStarGhosts[i].y);
float radius=.013+.006*g;
vec2 q=(vUv-ghostCenter)*metric;
float chroma=uStarGhosts[i].z;
vec3 tint=.45+.45*cos(vec3(.1,2.2,4.3)+g*1.37);
vec3 spectral=vec3(aperture(q,radius*(1.0-chroma*.15)),aperture(q,radius),aperture(q,radius*(1.0+chroma*.15)));
color+=spectral*tint*ghostWeight/(1.0+g*.3);
}
vec2 ringCenter=vec2(.5)+axis*.25;
float ringDistance=length((vUv-ringCenter)*metric);
float rainbow=exp(-pow((ringDistance-.27)/.012,2.0));
color+=rainbow*(.5+.5*cos(vec3(0,2,4)+ringDistance*160.0))*ghostWeight*.035;
return color*intensity;
}
void main() {
vec3 color=texture(uScene,vUv).rgb;
for(int i=0;i<2;i++){if(i>=uStarCount)break;color+=sunGlare(i);}
fragColor=vec4(color,1.0);
}

View File

@@ -0,0 +1,190 @@
#version 330 core
#include "noise.glsl"
#include "planet-height.glsl"
#include "cloud-common.glsl"
in vec3 vWorldPosition;
in vec3 vPlanetLocal;
in vec3 vNormal;
in float vFragDepth;
in vec3 vMaterialPosition;
uniform int uMaterialsEnabled;
uniform sampler2D uFlatColor;
uniform sampler2D uFlatNormal;
uniform sampler2D uSlopeColor;
uniform sampler2D uSlopeNormal;
uniform vec3 uMaterialSettings;
uniform float uFar;
uniform sampler2D uSurfaceTexture;
uniform int uHasSurfaceTexture;
uniform int uSurfaceType;
uniform vec3 uCloudParameters;
#include "ring-optics.glsl"
uniform vec3 uSurfaceColor;
uniform vec3 uAtmosphereColor;
uniform int uPlanetIndex;
uniform int uLightCount;
uniform vec3 uLightPositions[2];
uniform vec3 uLightColors[2];
uniform float uLightLuminosities[2];
uniform int uOccluderCount;
uniform vec3 uOccluderCenters[16];
uniform float uOccluderRadii[16];
uniform int uTerrainShadows;
uniform int uDebugSkipSurfaceNoise;
uniform int uDebugDisableBodyShadows;
out vec4 fragColor;
vec4 triColor(sampler2D image,vec3 p,vec3 weights) {
return texture(image,p.yz)*weights.x+texture(image,p.xz)*weights.y+texture(image,p.xy)*weights.z;
}
vec3 triNormal(sampler2D image,vec3 p,vec3 n,vec3 weights) {
vec3 x=texture(image,p.yz).xyz*2.0-1.0;
vec3 y=texture(image,p.xz).xyz*2.0-1.0;
vec3 z=texture(image,p.xy).xyz*2.0-1.0;
return normalize(vec3(sign(n.x)*x.z,x.x,x.y)*weights.x+
vec3(y.x,sign(n.y)*y.z,y.y)*weights.y+
vec3(z.x,z.y,sign(n.z)*z.z)*weights.z);
}
float mountainShadow(vec3 p, vec3 lightDir, float sunHeight) {
if(uTerrainShadows==0 || sunHeight>0.8) return 1.0;
float visibility=1.0;
float t=0.000055;
for(int i=0;i<6;i++) {
vec3 q=p+lightDir*t;
float ground=1.0+surfaceElevation(normalize(q));
float clearance=length(q)-ground+0.000055;
visibility=min(visibility,clamp(18.0*clearance/t,0.0,1.0));
if(visibility<0.015) break;
t*=2.75;
}
return visibility;
}
vec3 cloudMaterial(vec3 d) {
float latitude=asin(clamp(d.y,-1.0,1.0));
float turbulence=fbm(d*6.0+uNoiseOffset+vec3(uCloudParameters.z*0.006,0,0));
float wisps=fbm(d*27.0+uNoiseOffset+turbulence*3.0);
if(uSurfaceType==2) return uSurfaceColor*(0.8+0.4*smoothstep(-0.5,0.5,turbulence+wisps*0.4));
float bands=sin(latitude*uCloudParameters.x+turbulence*2.5+wisps*0.8)*0.5+0.5;
float contrast=uSurfaceType==4 ? 0.20 : 0.72;
vec3 cloud=mix(uSurfaceColor*(1.0-contrast*0.5),uSurfaceColor*(1.0+contrast*0.5),bands);
vec3 pale=uSurfaceType==4 ? vec3(0.5,0.73,0.78) : vec3(0.79,0.69,0.50);
cloud=mix(cloud,pale,smoothstep(0.66,0.98,bands)*0.45);
float longitude=atan(d.z,d.x);
float dx=atan(sin(longitude+0.35),cos(longitude+0.35));
vec2 storm=vec2(dx*cos(latitude),latitude+0.36)/vec2(0.20,0.075);
float spot=exp(-dot(storm,storm))*uCloudParameters.y;
vec3 stormColor=uSurfaceType==4 ? uSurfaceColor*0.25 : vec3(0.57,0.20,0.10);
return mix(cloud,stormColor,spot)*(0.95+wisps*0.12);
}
void main() {
vec3 radial=normalize(vPlanetLocal);
vec3 localDirection=bodyDirection(radial);
vec3 textureColor=uHasSurfaceTexture!=0 ? texture(uSurfaceTexture,planetUv(localDirection)).rgb : vec3(0.0);
float ocean=(uHasSurfaceTexture!=0 && uSurfaceType==1 && textureColor.b>textureColor.g*1.2 && textureColor.b>textureColor.r*1.4) ? 1.0:0.0;
// Vertices already carry terrain normals from the collision height field. Reusing them
// avoids three full procedural height evaluations for every shaded pixel.
float elevation=length(vPlanetLocal)-1.0;
float height=uAmplitude>0.0 ? elevation/uAmplitude : 0.0;
vec3 normal=normalize(vNormal);
// Small rock relief is filtered by the pixel footprint to avoid distant sparkling.
vec3 rockPosition=radial*18000.0+uNoiseOffset;
float grainFootprint=length(fwidth(rockPosition));
float grain=0.0,erosion=0.0;
if(uDebugSkipSurfaceNoise==0) {
grain=noise3(rockPosition)*(1.0-smoothstep(0.55,1.0,grainFootprint))
+0.25*noise3(rockPosition*3.1)*(1.0-smoothstep(0.55,1.0,grainFootprint*3.1));
erosion=fbm(radial*2400.0+uNoiseOffset)*0.8;
}
float detail=grain*0.10+erosion;
vec3 sigmaX=dFdx(vWorldPosition), sigmaY=dFdy(vWorldPosition);
vec3 r1=cross(sigmaY,normal), r2=cross(normal,sigmaX);
float det=dot(sigmaX,r1);
float microWeight=1.0-smoothstep(0.4,3.0,grainFootprint);
vec3 gradient=(dFdx(detail)*r1+dFdy(detail)*r2)/max(abs(det),0.0001)*sign(det);
float microAmplitude=uHasSurfaceTexture!=0 ? min(uRadius*0.000006,uAmplitude*uRadius*0.04) : uRadius*0.000006;
normal=normalize(normal-gradient*microAmplitude*microWeight*(1.0-ocean));
if(uSurfaceType>=2 || ocean>0.5)normal=radial;
float slope=clamp(1.0-dot(normal,radial),0.0,1.0);
float geology=0.0,deposits=0.5,strata=0.5;
if(uDebugSkipSurfaceNoise==0) {
geology=fbm(radial*31.0+uNoiseOffset);
deposits=noise3(radial*170.0+uNoiseOffset);
strata=sin(height*150.0+noise3(radial*76.0)*2.2)*0.5+0.5;
}
vec3 mineral=mix(uSurfaceColor*0.55,uSurfaceColor*1.62,smoothstep(-0.45,0.5,geology));
mineral=mix(mineral,vec3(0.28,0.23,0.20),smoothstep(0.08,0.48,slope)*0.67);
mineral*=mix(0.91,1.07,strata*(0.35+slope*0.65));
mineral*=0.96+0.08*deposits+grain*0.045;
float snowLine=0.60-abs(radial.y)*0.10;
float snow=uDebugSkipSurfaceNoise!=0 ? 0.0 : smoothstep(snowLine,snowLine+0.09,height+geology*0.11)*(1.0-smoothstep(0.16,0.48,slope));
mineral=mix(mineral,vec3(0.75,0.81,0.84),snow*0.85);
float ao=mix(0.58,1.0,smoothstep(-0.16,0.38,height));
if(uHasSurfaceTexture!=0) { mineral=textureColor; ao=1.0; snow=0.0; }
if(uSurfaceType>=2) { mineral=cloudMaterial(localDirection); ao=1.0; snow=0.0; }
float materialRoughness=.8;
if(uMaterialsEnabled!=0 && ocean<.5) {
float strength=uMaterialSettings.z*(1.0-smoothstep(uMaterialSettings.y*.35,uMaterialSettings.y,length(vWorldPosition)));
if(strength>.001) {
vec3 bodyNormal=normalize(mat3(uWorldToBody)*normal);
vec3 weights=pow(abs(bodyNormal),vec3(4.0)); weights/=max(.0001,weights.x+weights.y+weights.z);
vec3 position=vMaterialPosition/uMaterialSettings.x;
float rock=smoothstep(.04,.23,slope);
vec4 material=mix(triColor(uFlatColor,position,weights),triColor(uSlopeColor,position,weights),rock);
mineral=mix(mineral,material.rgb,strength);
materialRoughness=mix(.8,material.a,strength);
vec3 detail=mix(triNormal(uFlatNormal,position,bodyNormal,weights),triNormal(uSlopeNormal,position,bodyNormal,weights),rock);
normal=normalize(mix(normal,transpose(mat3(uWorldToBody))*detail,strength*.4));
}
}
vec3 light=mineral*(vec3(0.007)+uAtmosphereColor*0.018)*ao;
vec3 viewDirection=normalize(-vWorldPosition);
for(int l=0;l<2;l++) {
if(l>=uLightCount) break;
vec3 toLight=uLightPositions[l]-vWorldPosition;
float distanceToLight=length(toLight);
vec3 ld=toLight/max(distanceToLight,1.0);
float sunHeight=dot(radial,ld);
float visibility=smoothstep(-0.025,0.018,sunHeight);
if(visibility>0.001) {
visibility*=cloudShadow(vWorldPosition,ld);
if(uDebugDisableBodyShadows==0) {
visibility*=ringTransmission(mat3(uWorldToBody)*vPlanetLocal,mat3(uWorldToBody)*ld);
for(int j=0;j<16;j++) {
if(j>=uOccluderCount) break;
if(j==uPlanetIndex) continue;
vec3 o=uOccluderCenters[j]-vWorldPosition;
float along=dot(o,ld);
if(along>0.0 && along<distanceToLight) {
float miss=length(o-ld*along);
float soft=uOccluderRadii[j]*0.045;
visibility*=smoothstep(uOccluderRadii[j]-soft,uOccluderRadii[j]+soft,miss);
}
}
}
}
if(visibility>0.01 && uSurfaceType<2) visibility*=mountainShadow(radial*(1.0+elevation)+normal*0.000060,ld,sunHeight);
float au=distanceToLight/149597870700.0;
float flux=clamp(uLightLuminosities[l]/max(au*au,0.06),0.13,7.0);
float diffuse=max(dot(normal,ld),0.0);
vec3 halfDir=normalize(viewDirection+ld);
float specular=pow(max(dot(normal,halfDir),0.0),mix(90.0,12.0,materialRoughness))*0.045;
if(ocean>0.5) {
float fresnel=0.02+0.98*pow(1.0-max(dot(normal,viewDirection),0.0),5.0);
specular=pow(max(dot(normal,halfDir),0.0),180.0)*(0.15+fresnel)*3.0;
}
vec3 sunColor=uLightColors[l];
// Low sunlight loses blue to the atmosphere before reaching the ground.
vec3 extinction=exp(-vec3(0.035,0.07,0.16)/(max(sunHeight,0.04)+0.08));
light+=(mineral*diffuse*(1.0-ocean*0.25)+specular)*sunColor*extinction*flux*1.65*visibility;
float skyHemisphere=0.22+0.78*max(dot(normal,radial),0.0);
vec3 skyIrradiance=mix(vec3(0.22,0.35,0.70),uAtmosphereColor,0.5);
light+=mineral*skyIrradiance*0.40*sqrt(max(sunHeight,0.0))*sqrt(flux)*ao*skyHemisphere;
}
fragColor=vec4(max(light,vec3(0.0)),1.0);
gl_FragDepth=clamp(log2(max(0.0001,vFragDepth))/log2(uFar+1.0),0.0,1.0);
}

View File

@@ -0,0 +1,26 @@
#version 330 core
layout(location=0) in vec3 aPosition;
layout(location=1) in vec3 aNormal;
layout(location=2) in vec3 aDirection;
uniform vec3 uChunkPosition;
uniform vec3 uChunkOrigin;
uniform float uRadius;
uniform mat4 uViewProjection;
uniform float uFar;
uniform mat4 uWorldToBody;
uniform vec3 uMaterialAnchor;
out vec3 vWorldPosition;
out vec3 vPlanetLocal;
out vec3 vNormal;
out float vFragDepth;
out vec3 vMaterialPosition;
void main() {
vWorldPosition=uChunkPosition+aPosition;
vPlanetLocal=uChunkOrigin+aPosition/uRadius;
vNormal=aNormal;
vMaterialPosition=mat3(uWorldToBody)*aPosition+uMaterialAnchor;
vec4 clip=uViewProjection*vec4(vWorldPosition,1.0);
vFragDepth=clip.w+1.0;
gl_Position=clip;
gl_Position.z=(2.0*log2(max(0.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
}

View File

@@ -0,0 +1,80 @@
using System.Text.Json;
using Silk.NET.OpenGL;
using Universe.Core.Generation;
namespace Universe.App.Graphics;
/// <summary>Content catalog and lazily uploaded reusable material textures.</summary>
internal sealed unsafe class SurfaceMaterials : IDisposable
{
private sealed record Entry(string Id, string Color, string Normal, string Roughness);
private sealed record Catalog(Entry[] Materials, string[] TerrestrialFlat, string[] RockyFlat, string[] Slopes);
private readonly GL _gl;
private readonly string _directory;
private readonly Catalog _catalog;
private readonly Dictionary<string, Entry> _entries;
private readonly Dictionary<string, (uint Color, uint Normal)> _textures = new(StringComparer.OrdinalIgnoreCase);
public SurfaceMaterials(GL gl, string directory)
{
_gl = gl; _directory = Path.GetFullPath(directory);
var path = Path.Combine(_directory, "materials.json");
_catalog = JsonSerializer.Deserialize<Catalog>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidDataException("Empty material catalog.");
_entries = _catalog.Materials.ToDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
if (_catalog.TerrestrialFlat.Length == 0 || _catalog.RockyFlat.Length == 0 || _catalog.Slopes.Length == 0
|| _catalog.TerrestrialFlat.Concat(_catalog.RockyFlat).Concat(_catalog.Slopes).Any(id => !_entries.ContainsKey(id)))
throw new InvalidDataException("Material catalog contains empty pools or unknown material IDs.");
}
public (uint FlatColor, uint FlatNormal, uint SlopeColor, uint SlopeNormal) ForPlanet(PlanetData planet)
{
var pool = planet.SurfaceType == PlanetSurfaceType.Terrestrial ? _catalog.TerrestrialFlat : _catalog.RockyFlat;
var flat = planet.Materials.FlatMaterial ?? pool[(int)(DeterministicRandom.Mix(planet.Seed ^ 0xF1A7UL) % (ulong)pool.Length)];
var slope = planet.Materials.SlopeMaterial ?? _catalog.Slopes[(int)(DeterministicRandom.Mix(planet.Seed ^ 0x5109EUL) % (ulong)_catalog.Slopes.Length)];
var a = Get(flat); var b = Get(slope);
return (a.Color, a.Normal, b.Color, b.Normal);
}
private string Resolve(string relative)
{
if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new InvalidDataException("Material paths must be relative to Content.");
var full = Path.GetFullPath(Path.Combine(_directory, relative));
if (!full.StartsWith(_directory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Material paths must stay inside Content.");
return full;
}
private (uint Color, uint Normal) Get(string id)
{
if (_textures.TryGetValue(id, out var existing)) return existing;
if (!_entries.TryGetValue(id, out var entry)) throw new InvalidDataException($"Unknown surface material '{id}'.");
var color = RasterImage.Load(Resolve(entry.Color));
var roughness = RasterImage.Load(Resolve(entry.Roughness));
var normal = RasterImage.Load(Resolve(entry.Normal));
if (color.Width != roughness.Width || color.Height != roughness.Height || color.Width != normal.Width || color.Height != normal.Height)
throw new InvalidDataException($"Material '{id}' images must have equal dimensions.");
for (var i = 0; i < color.Width * color.Height; i++) color.Rgba[i * 4 + 3] = roughness.Rgba[i * 4];
var result = (Upload(color, true), Upload(normal, false));
_textures.Add(id, result);
return result;
}
private uint Upload(RasterImage image, bool srgb)
{
var handle = _gl.GenTexture(); _gl.BindTexture(GLEnum.Texture2D, handle);
fixed (byte* pixels = image.Rgba)
_gl.TexImage2D(GLEnum.Texture2D, 0, srgb ? InternalFormat.Srgb8Alpha8 : InternalFormat.Rgba8,
(uint)image.Width, (uint)image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixels);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.Repeat);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
_gl.GenerateMipmap(GLEnum.Texture2D); return handle;
}
public void Dispose()
{
foreach (var value in _textures.Values) { _gl.DeleteTexture(value.Color); _gl.DeleteTexture(value.Normal); }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
using Universe.App;
using var application = new UniverseApplication();
application.Run();

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;
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Universe.Core\Universe.Core.csproj" />
<EmbeddedResource Include="Graphics\Shaders\*.glsl" />
<Content Include="..\..\config.json" Link="config.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\KnownSolarSystem\**\*" Link="KnownSolarSystem\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\Content\**\*.json;..\..\Content\**\*.jpg;..\..\Content\**\*.png;..\..\Content\**\*.f32" Link="Content\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing.Glfw" Version="2.23.0" />
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Silk.NET.Input.Glfw" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.Maths" Version="2.23.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,406 @@
using System.Globalization;
using System.Numerics;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using Universe.App.Graphics;
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
using Universe.Core.Configuration;
namespace Universe.App;
public sealed class UniverseApplication : IDisposable
{
private readonly IWindow _window;
private readonly UniverseGenerator _generator;
private readonly UniverseStreamer _streamer;
private readonly IReadOnlyList<string> _planetHotkeys;
private readonly StarSystemData _homeSystem;
private readonly PlanetData _homeEarth;
private readonly UniversePosition _homeCameraPosition;
private readonly DVector3 _homeCameraForward;
private readonly DVector3 _homeCameraUp;
private FlyCamera _camera;
private PlayerController? _player;
private BenchmarkRunner? _benchmark;
private bool _menuOpen;
private bool _debugMode;
private int _menuSelection;
private bool _captureBeforeMenu;
private UniverseNeighborhood _neighborhood;
private IInputContext? _input;
private IKeyboard? _keyboard;
private IMouse? _mouse;
private UniverseRenderer? _renderer;
private Vector2 _mouseDelta;
private Vector2 _lastMousePosition;
private bool _hasMousePosition;
private double _titleTimer;
private double _frameTime = 1.0 / 60;
private bool _mouseCaptured = !Environment.GetCommandLineArgs().Contains("--free-mouse");
private SectorCoordinate _renderedCoordinate;
private bool _disposed;
public UniverseApplication()
{
var configPath = Path.Combine(AppContext.BaseDirectory, "config.json");
var configuration = EngineConfiguration.Load(configPath);
_planetHotkeys = configuration.Startup.PlanetHotkeys;
var catalogPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.KnownSolarSystemsFile));
var catalog = KnownSolarSystemCatalog.Load(catalogPath, configuration.CellSizeMeters);
_generator = new(configuration.Seed, configuration.CellSizeMeters, catalog);
_streamer = new(_generator);
var initialSystem = configuration.ResolveStartup(catalog).System;
_homeSystem = catalog.GetById("sol");
_homeEarth = _homeSystem.Planets.FirstOrDefault(p => p.Id.Equals("earth", StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException("The known solar system must define an Earth planet with id 'earth'.");
var startCoordinate = initialSystem.Coordinate;
var cellCenter = UniversePosition.AtCellCenter(startCoordinate, _generator.CellSizeMeters);
var startPosition = cellCenter.Add(new DVector3(0, 0, 4 * UniverseScale.AstronomicalUnitMeters), _generator.CellSizeMeters);
_camera = new(startPosition, _generator.CellSizeMeters);
_neighborhood = _streamer.Load(startCoordinate);
_camera.SetInitialView(_neighborhood.ActiveSystem, configuration.Startup);
_homeCameraPosition = _camera.Position;
_homeCameraForward = _camera.Forward;
_homeCameraUp = _camera.Up;
_renderedCoordinate = startCoordinate;
var options = WindowOptions.Default with
{
Title = "Universe | loading",
IsVisible = !Environment.GetCommandLineArgs().Any(a => a is "--graphics-report" or "--scene-report" or "--ocean-report" or "--cloud-report" or "--terrain-report" or "--menu-report" or "--terrain-perf-report"),
Size = new Vector2D<int>(1440, 900),
VSync = true,
PreferredDepthBufferBits = 24,
API = new GraphicsAPI(ContextAPI.OpenGL, new APIVersion(3, 3))
};
_window = Window.Create(options);
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
_window.FramebufferResize += OnFramebufferResize;
_window.Closing += OnClosing;
}
public void Run() => _window.Run();
private void OnLoad()
{
var gl = _window.CreateOpenGL();
_input = _window.CreateInput();
_renderer = new(gl, _window.FramebufferSize.X, _window.FramebufferSize.Y);
if (Environment.GetCommandLineArgs().Contains("--terrain-perf-report"))
{ GraphicsReport.RunTerrainPerformance(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--menu-report"))
{ GraphicsReport.RunMenu(gl, _renderer, _camera, _neighborhood, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--terrain-report"))
{ GraphicsReport.RunTerrain(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--cloud-report"))
{ GraphicsReport.RunClouds(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--ocean-report"))
{ GraphicsReport.RunOcean(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--scene-report"))
{ GraphicsReport.RunScene(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--graphics-report")) { _window.Close(); return; }
if (_input.Keyboards.Count > 0)
{
_keyboard = _input.Keyboards[0];
_keyboard.KeyDown += OnKeyDown;
}
if (_input.Mice.Count > 0)
{
_mouse = _input.Mice[0];
_mouse.MouseMove += OnMouseMove;
_mouse.Scroll += OnScroll;
_mouse.MouseDown += OnMouseDown;
var cursor = _mouse.Cursor;
if (_mouseCaptured && cursor.IsSupported(CursorMode.Disabled))
cursor.CursorMode = CursorMode.Disabled;
}
}
private void OnUpdate(double deltaSeconds)
{
if (_keyboard is null)
return;
if (_benchmark?.IsRunning == true)
{
_benchmark.Update(deltaSeconds, _camera);
UpdateNeighborhood();
return;
}
if (_menuOpen) { _mouseDelta = Vector2.Zero; return; }
if (_mouseCaptured)
{
if (_player is null) _camera.Rotate(_mouseDelta.X, _mouseDelta.Y);
else _player.Look(_mouseDelta.X, _mouseDelta.Y);
}
_mouseDelta = Vector2.Zero;
var forward = (IsDown(Key.W) ? 1 : 0) - (IsDown(Key.S) ? 1 : 0);
var side = (IsDown(Key.D) ? 1 : 0) - (IsDown(Key.A) ? 1 : 0);
var crouch = IsDown(Key.ControlLeft) || IsDown(Key.ControlRight);
if (_player is { } player)
{
player.Update(deltaSeconds, forward, side, IsDown(Key.Space), crouch, _neighborhood);
_camera.SetPlayerPose(player.Position, player.Forward, player.Up);
}
else
{
var vertical = (IsDown(Key.Space) ? 1 : 0) - (crouch ? 1 : 0);
var input = _camera.Forward * forward + _camera.Right * side + _camera.Up * vertical;
_camera.Update(deltaSeconds, input, _neighborhood);
}
UpdateNeighborhood();
_titleTimer += deltaSeconds;
if (_titleTimer >= 0.2)
{
_titleTimer = 0;
var metersPerSecond = _player?.Speed ?? _camera.SpeedMetersPerSecond;
var lightSpeed = metersPerSecond / UniverseScale.LightSpeedMetersPerSecond;
var speed = lightSpeed >= 0.01
? $"{lightSpeed.ToString("0.##", CultureInfo.InvariantCulture)}c"
: $"{metersPerSecond.ToString("0.##", CultureInfo.InvariantCulture)} m/s";
var focus = _camera.IsFocusing ? " | focusing" : string.Empty;
var center = UniversePosition.AtCellCenter(_camera.Position.Sector, _generator.CellSizeMeters);
var nearest = _neighborhood.ActiveSystem.Planets.MinBy(p =>
(p.PositionMeters - _camera.Position.RelativeTo(center, _generator.CellSizeMeters)).Length / p.RadiusMeters);
var mode = _player is null ? "Free camera" : _player.Grounded ? "Player | grounded" : "Player | falling";
_window.Title = $"Universe | {mode} | {_neighborhood.ActiveSystem.Name} | {nearest?.Name} ({nearest?.Id}) | {_camera.Position.Sector} | {speed}{focus} | {1 / _frameTime:0} FPS | P menu | F1 mouse {(_mouseCaptured ? "locked" : "free")}{(_debugMode ? " | DEBUG" : string.Empty)}";
}
}
private void OnRender(double deltaSeconds)
{
_frameTime += (System.Math.Clamp(deltaSeconds, 0.001, 1) - _frameTime) * 0.08;
_benchmark?.RecordFrame(deltaSeconds);
if (_renderer is not null)
{
_renderer.DebugMode = _debugMode;
_renderer.DebugProbeEnabled = _debugMode && !_menuOpen;
}
_renderer?.Render(_camera, _neighborhood, _menuOpen ? 0 : deltaSeconds);
_renderer?.DrawDebugOverlay(1 / _frameTime);
if (_benchmark is { HasRun: true } benchmark) _renderer?.DrawBenchmarkOverlay(benchmark);
if (_menuOpen) _renderer?.DrawMenu(_menuSelection, _debugMode);
}
private void UpdateNeighborhood()
{
if (_camera.Position.Sector == _neighborhood.ActiveSystem.Coordinate) return;
_neighborhood = _streamer.Load(_camera.Position.Sector);
if (_renderedCoordinate == _neighborhood.ActiveSystem.Coordinate) return;
_renderedCoordinate = _neighborhood.ActiveSystem.Coordinate;
_renderer?.SetActiveSystem(_renderedCoordinate);
}
private void OnFramebufferResize(Vector2D<int> size)
{
_renderer?.Resize(size.X, size.Y);
}
private bool IsDown(Key key) => _keyboard?.IsKeyPressed(key) ?? false;
private void OnKeyDown(IKeyboard keyboard, Key key, int scanCode)
{
if (key == Key.P)
{
if (_benchmark?.IsRunning == true) _benchmark.Stop();
SetMenu(!_menuOpen);
return;
}
if (_menuOpen)
{
if (key == Key.Escape) SetMenu(false);
else if (key == Key.Up) _menuSelection = (_menuSelection + 5) % 6;
else if (key == Key.Down) _menuSelection = (_menuSelection + 1) % 6;
else if (key is Key.Enter or Key.KeypadEnter) ActivateMenu();
return;
}
if (_benchmark?.IsRunning == true && key == Key.Escape)
{
_benchmark.Stop();
SetMenu(true);
return;
}
if (_benchmark?.IsRunning == true && key != Key.F1) return;
if (key == Key.Escape)
_window.Close();
else if (_player is not null && key != Key.F1) return;
else if (key == Key.Q)
_camera.RequestFocus(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (TryGetPlanetHotkeyIndex(key, out var planetIndex))
{
if (planetIndex < _planetHotkeys.Count)
_camera.RequestFocus(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem), _planetHotkeys[planetIndex]);
}
else if (key == Key.F2)
_camera.RequestSurfaceView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (key == Key.F3)
_camera.RequestStarView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (key == Key.F4)
_camera.RequestRingView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (key == Key.F5)
_renderer?.ReloadShaders();
else if (key == Key.F6)
_camera.RequestOceanView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (key == Key.F7)
_camera.RequestCloudView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (key == Key.F1 && _mouse is not null)
{
_mouseCaptured = !_mouseCaptured;
_mouse.Cursor.CursorMode = _mouseCaptured ? CursorMode.Disabled : CursorMode.Normal;
_hasMousePosition = false;
_mouseDelta = Vector2.Zero;
}
}
private static bool TryGetPlanetHotkeyIndex(Key key, out int index)
{
index = key switch
{
Key.Number1 or Key.Keypad1 => 0,
Key.Number2 or Key.Keypad2 => 1,
Key.Number3 or Key.Keypad3 => 2,
Key.Number4 or Key.Keypad4 => 3,
Key.Number5 or Key.Keypad5 => 4,
Key.Number6 or Key.Keypad6 => 5,
Key.Number7 or Key.Keypad7 => 6,
Key.Number8 or Key.Keypad8 => 7,
Key.Number9 or Key.Keypad9 => 8,
_ => -1
};
return index >= 0;
}
private void OnMouseMove(IMouse mouse, Vector2 position)
{
if (_menuOpen)
{
var hit = _renderer?.MenuHit(position.X, position.Y, _window.Size.X, _window.Size.Y) ?? -1;
if (hit >= 0) _menuSelection = hit;
return;
}
if (!_hasMousePosition)
{
_lastMousePosition = position;
_hasMousePosition = true;
return;
}
_mouseDelta += position - _lastMousePosition;
_lastMousePosition = position;
if (_mouseDelta.LengthSquared() > 10_000)
_mouseDelta = Vector2.Zero;
}
private void OnScroll(IMouse mouse, ScrollWheel wheel)
{
if (!_menuOpen && _player is null) _camera.AdjustSpeed(wheel.Y);
}
private void OnMouseDown(IMouse mouse, MouseButton button)
{
if (!_menuOpen || button != MouseButton.Left) return;
var position = mouse.Position;
var hit = _renderer?.MenuHit(position.X, position.Y, _window.Size.X, _window.Size.Y) ?? -1;
if (hit >= 0) { _menuSelection = hit; ActivateMenu(); }
}
private void SetMenu(bool open)
{
if (_menuOpen == open) return;
_menuOpen = open;
if (open) { _captureBeforeMenu = _mouseCaptured; _mouseCaptured = false; _menuSelection = _player is null ? 1 : 0; }
else _mouseCaptured = _captureBeforeMenu;
if (_mouse is not null)
{
var mode = _mouseCaptured ? CursorMode.Disabled : CursorMode.Normal;
if (_mouse.Cursor.IsSupported(mode)) _mouse.Cursor.CursorMode = mode;
}
_hasMousePosition = false; _mouseDelta = Vector2.Zero;
}
private void ActivateMenu()
{
if (_menuSelection == 5) { _window.Close(); return; }
if (_menuSelection == 2) { _debugMode = !_debugMode; return; }
if (_menuSelection is 3 or 4)
{
try
{
StartBenchmark(unknownPlanet: _menuSelection == 4);
}
catch (InvalidOperationException error)
{
_window.Title = $"Universe | Benchmark could not start | {error.Message}";
}
return;
}
if (_menuSelection == 0)
{
_player ??= new PlayerController(_camera.Position, _camera.Forward, _camera.Up, _generator.CellSizeMeters, _neighborhood);
_camera.SetPlayerPose(_player.Position, _player.Forward, _player.Up);
}
else _player = null;
SetMenu(false);
}
private void StartBenchmark(bool unknownPlanet)
{
var system = _homeSystem;
var planet = _homeEarth;
if (unknownPlanet)
{
var candidate = _neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem)
.Where(s => s.Id.StartsWith("procedural:", StringComparison.Ordinal))
.SelectMany(s => s.Planets.Where(p => !p.IsGasPlanet && p.ParentPlanetIndex is null
&& p.RadiusMeters > _homeEarth.RadiusMeters * 1.05)
.Select(p => (System: s, Planet: p)))
.OrderByDescending(body => body.Planet.RadiusMeters)
.ThenByDescending(body => body.Planet.TerrainAmplitudeMeters)
.ThenBy(body => body.System.Coordinate.X)
.ThenBy(body => body.System.Coordinate.Y)
.ThenBy(body => body.System.Coordinate.Z)
.FirstOrDefault();
if (candidate.Planet is null)
throw new InvalidOperationException("No generated planet larger than Earth is available in the neighboring systems.");
system = candidate.System;
planet = candidate.Planet;
}
_debugMode = false;
_player = null;
_benchmark = new(system, planet, _generator.CellSizeMeters, _camera,
_homeCameraPosition, _homeCameraForward, _homeCameraUp);
UpdateNeighborhood();
SetMenu(false);
}
private void OnClosing()
{
_renderer?.Dispose();
_renderer = null;
_input?.Dispose();
_input = null;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_renderer?.Dispose();
_input?.Dispose();
_window.Dispose();
}
}

View File

@@ -0,0 +1,64 @@
using System.Globalization;
using Universe.Core.World;
using Universe.Core.Generation;
namespace Universe.Core.Configuration;
public sealed record StartupConfiguration
{
public string SystemId { get; init; } = "sol";
public string PlanetId { get; init; } = "earth";
/// <summary>Planet ids assigned to number keys 1 through 9, in key order.</summary>
public List<string> PlanetHotkeys { get; init; } = [];
public double DistanceInRadii { get; init; } = 3;
public double LongitudeDegrees { get; init; } = 20;
public double LatitudeDegrees { get; init; } = 20;
public double SpeedMetersPerSecond { get; init; } = 200_000;
public double FieldOfViewDegrees { get; init; } = 67.65;
}
public sealed record EngineConfiguration
{
public string UniverseSeed { get; init; } = "0x554E495645525345";
public double CellSizeAu { get; init; } = 100;
public string KnownSolarSystemsFile { get; init; } = "KnownSolarSystem/solar-systems.json";
public StartupConfiguration Startup { get; init; } = new();
public double CellSizeMeters => CellSizeAu * UniverseScale.AstronomicalUnitMeters;
public ulong Seed => UniverseSeed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ulong.Parse(UniverseSeed.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture)
: ulong.Parse(UniverseSeed, CultureInfo.InvariantCulture);
public (StarSystemData System, PlanetData Planet) ResolveStartup(KnownSolarSystemCatalog catalog)
{
var system = catalog.GetById(Startup.SystemId);
var planet = system.Planets.FirstOrDefault(p => p.Id == Startup.PlanetId)
?? throw new InvalidDataException($"Startup planet '{Startup.PlanetId}' does not exist in system '{system.Id}'.");
foreach (var hotkeyPlanetId in Startup.PlanetHotkeys)
if (!system.Planets.Any(p => string.Equals(p.Id, hotkeyPlanetId, StringComparison.OrdinalIgnoreCase)))
throw new InvalidDataException($"Planet hotkey target '{hotkeyPlanetId}' does not exist in startup system '{system.Id}'.");
return (system, planet);
}
public static EngineConfiguration Load(string path)
{
var settings = JsonSettings.Read<EngineConfiguration>(path);
if (string.IsNullOrWhiteSpace(settings.UniverseSeed)) throw new InvalidDataException("config.json: universeSeed cannot be empty.");
try { _ = settings.Seed; }
catch (Exception error) when (error is FormatException or OverflowException or ArgumentNullException)
{ throw new InvalidDataException("config.json: universeSeed must be an unsigned integer string, decimal or 0x-prefixed hexadecimal.", error); }
if (!double.IsFinite(settings.CellSizeAu) || settings.CellSizeAu <= 0 || !double.IsFinite(settings.CellSizeMeters))
throw new InvalidDataException("config.json: cellSizeAu must be finite and positive.");
var startup = settings.Startup ?? throw new InvalidDataException("config.json: startup cannot be null.");
if (string.IsNullOrWhiteSpace(startup.SystemId) || string.IsNullOrWhiteSpace(startup.PlanetId)
|| startup.PlanetHotkeys is null || startup.PlanetHotkeys.Count > 9
|| startup.PlanetHotkeys.Any(string.IsNullOrWhiteSpace)
|| !double.IsFinite(startup.DistanceInRadii) || startup.DistanceInRadii < 1.05
|| !double.IsFinite(startup.LongitudeDegrees) || !double.IsFinite(startup.LatitudeDegrees) || System.Math.Abs(startup.LatitudeDegrees) > 90
|| !double.IsFinite(startup.SpeedMetersPerSecond) || startup.SpeedMetersPerSecond < 0.1
|| startup.SpeedMetersPerSecond > UniverseScale.LightSpeedMetersPerSecond * 1_000_000
|| !double.IsFinite(startup.FieldOfViewDegrees) || startup.FieldOfViewDegrees is < 30 or > 120)
throw new InvalidDataException("config.json: invalid startup target, planet hotkeys, view angles, distance or flight speed. Planet hotkeys must contain at most nine non-empty planet ids.");
if (string.IsNullOrWhiteSpace(settings.KnownSolarSystemsFile)) throw new InvalidDataException("config.json: knownSolarSystemsFile is required.");
return settings;
}
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Universe.Core.Configuration;
internal static class JsonSettings
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static T Read<T>(string path) where T : class
{
try
{
using var stream = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(stream, Options) ?? throw new InvalidDataException($"Empty JSON file: {path}");
}
catch (JsonException error)
{
throw new InvalidDataException($"Invalid JSON in '{path}' at {error.Path}: {error.Message}", error);
}
}
}

View File

@@ -0,0 +1,278 @@
using System.Numerics;
using System.Text;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Core.Configuration;
/// <summary>Explicit, validated systems override procedural generation at their assigned sectors.</summary>
public sealed class KnownSolarSystemCatalog
{
private readonly Dictionary<SectorCoordinate, StarSystemData> _bySector = [];
private readonly Dictionary<string, StarSystemData> _byId = new(StringComparer.Ordinal);
private readonly Dictionary<string, RasterImage> _colors = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HeightMap> _heights = new(StringComparer.OrdinalIgnoreCase);
private readonly string _assetDirectory;
public IReadOnlyCollection<StarSystemData> Systems => _byId.Values;
private KnownSolarSystemCatalog(string directory) => _assetDirectory = directory;
public static KnownSolarSystemCatalog Load(string path, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters)
{
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0) throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
var fullPath = Path.GetFullPath(path);
var document = JsonSettings.Read<SolarSystemDocument>(fullPath);
if (document.SchemaVersion != 1 || document.Systems is not { Count: > 0 })
throw new InvalidDataException("Known solar systems require schemaVersion 1 and at least one system.");
var catalog = new KnownSolarSystemCatalog(Path.GetDirectoryName(fullPath)!);
foreach (var definition in document.Systems)
{
var system = catalog.Build(definition, cellSizeMeters);
if (!catalog._byId.TryAdd(system.Id, system)) throw new InvalidDataException($"Duplicate solar system id '{system.Id}'.");
if (!catalog._bySector.TryAdd(system.Coordinate, system)) throw new InvalidDataException($"Two known systems occupy sector {system.Coordinate}.");
}
return catalog;
}
public bool TryGet(SectorCoordinate coordinate, out StarSystemData system) => _bySector.TryGetValue(coordinate, out system!);
public StarSystemData GetById(string id) => _byId.TryGetValue(id, out var system) ? system
: throw new InvalidDataException($"Startup system '{id}' does not exist in KnownSolarSystem.");
private StarSystemData Build(SolarSystemDefinition definition, double cellSize)
{
ValidateId(definition.Id);
if (string.IsNullOrWhiteSpace(definition.Name) || definition.Sector is null || definition.Stars is not { Count: >= 1 and <= 2 }
|| definition.Planets is not { Count: > 0 }) throw new InvalidDataException($"Incomplete system '{definition.Id}' (one or two stars required).");
var coordinate = new SectorCoordinate(definition.Sector.X, definition.Sector.Y, definition.Sector.Z);
var offset = Vector(definition.CenterOffsetMeters);
var ids = new HashSet<string>(StringComparer.Ordinal);
var stars = new List<StarData>();
foreach (var star in definition.Stars)
{
AddId(ids, star.Id); Positive(star.RadiusMeters, "star radius"); Positive(star.LuminositySolar, "luminosity");
if (string.IsNullOrWhiteSpace(star.Name)) throw new InvalidDataException($"Missing name for '{star.Id}'.");
VisualSettingsValidator.StarGlow(star.Glow, star.Id);
stars.Add(new(stars.Count, star.Name, offset + Vector(star.PositionMeters), star.RadiusMeters, star.LuminositySolar,
Color(star.Color), star.Seed ?? StableSeed(definition.Id + "/" + star.Id))
{ Id = star.Id, Glow = star.Glow });
}
var planets = new List<PlanetData>();
foreach (var planet in definition.Planets) planets.Add(BuildPlanet(planet, definition.Id, stars, planets.Count, null, ids));
for (var i = 0; i < definition.Planets.Count; i++)
{
var parent = planets[i];
foreach (var moon in definition.Planets[i].Moons ?? throw new InvalidDataException("moons cannot be null."))
{
if (moon.Moons is { Count: > 0 }) throw new InvalidDataException("Nested satellites are not supported.");
var body = BuildPlanet(moon, definition.Id, stars, planets.Count, parent, ids);
if ((body.PositionMeters - parent.PositionMeters).Length <= body.MaximumTerrainRadiusMeters + parent.MaximumTerrainRadiusMeters)
throw new InvalidDataException($"Moon '{body.Id}' overlaps its parent.");
planets.Add(body);
}
}
if (planets.Count > 16) throw new InvalidDataException($"'{definition.Id}' exceeds the current limit of 16 planets and moons.");
foreach (var star in stars) CheckBounds(star.PositionMeters, star.RadiusMeters, cellSize, star.Id);
foreach (var planet in planets)
{
CheckBounds(planet.PositionMeters, System.Math.Max(planet.MaximumTerrainRadiusMeters, planet.Rings?.OuterRadiusMeters ?? 0), cellSize, planet.Id);
if (stars.Any(s => (s.PositionMeters - planet.PositionMeters).Length <= s.RadiusMeters + planet.MaximumTerrainRadiusMeters))
throw new InvalidDataException($"Planet '{planet.Id}' overlaps a star.");
}
return new(coordinate, definition.Seed ?? StableSeed(definition.Id), stars, planets) { Id = definition.Id, Name = definition.Name };
}
private PlanetData BuildPlanet(PlanetDefinition p, string systemId, List<StarData> stars, int index, PlanetData? parent, HashSet<string> ids)
{
AddId(ids, p.Id); Positive(p.RadiusMeters, "planet radius");
var terrain = p.Terrain ?? throw new InvalidDataException($"Missing terrain for '{p.Id}'.");
var air = p.Atmosphere ?? throw new InvalidDataException($"Missing atmosphere for '{p.Id}'.");
if (string.IsNullOrWhiteSpace(p.Name) || p.Orbit is null || !Enum.IsDefined(p.SurfaceType) || !Enum.IsDefined(terrain.NoiseMask))
throw new InvalidDataException($"Invalid planet '{p.Id}'.");
var host = parent is null ? stars.FindIndex(s => s.Id == p.HostStarId) : parent.HostStarIndex;
if (!Finite(p.CloudBandFrequency, p.StormStrength) || p.CloudBandFrequency <= 0 || p.StormStrength is < 0 or > 1)
throw new InvalidDataException($"Invalid cloud bands or storm strength on '{p.Id}'.");
if (host < 0) throw new InvalidDataException($"Unknown hostStarId '{p.HostStarId}' on '{p.Id}'.");
Positive(terrain.ProceduralNoiseFrequency, "noise frequency"); Positive(terrain.HeightmapGamma, "heightmap gamma"); Positive(terrain.LandBlend, "land blend");
if (!Finite(terrain.HeightmapMinMeters, terrain.HeightmapMaxMeters, terrain.LandThreshold, p.AxialTiltDegrees, p.RotationDegrees,
terrain.ProceduralNoiseAmplitudeMeters, air.HeightMeters, air.Density)
|| terrain.HeightmapMinMeters > terrain.HeightmapMaxMeters || terrain.ProceduralNoiseAmplitudeMeters < 0
|| terrain.ProceduralNoiseFrequency > 512 || air.HeightMeters < 0 || air.Density < 0 || p.AxialTiltDegrees is < 0 or > 180)
throw new InvalidDataException($"Invalid terrain, atmosphere or orientation on '{p.Id}'.");
var heightmap = terrain.Heightmap is null ? null : LoadHeight(terrain.Heightmap);
var region = terrain.RegionalHeight is null ? null : LoadRegionalHeight(terrain.RegionalHeight);
var materials = p.Materials ?? throw new InvalidDataException($"'{p.Id}': materials cannot be null.");
if (!Finite(materials.ScaleMeters, materials.FadeDistanceMeters, materials.Strength) || materials.ScaleMeters <= 0
|| materials.FadeDistanceMeters <= 0 || materials.Strength is < 0 or > 1)
throw new InvalidDataException($"'{p.Id}': invalid material settings.");
VisualSettingsValidator.Gas(p.Gas, p.Id);
if (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant && (heightmap is not null || region is not null || terrain.ProceduralNoiseAmplitudeMeters != 0))
throw new InvalidDataException($"'{p.Id}': gas planets use gas volume settings, not a terrain heightmap or terrain amplitude.");
if (terrain.NoiseMask == TerrainNoiseMask.HeightmapLand && heightmap is null)
throw new InvalidDataException($"'{p.Id}': heightmapLand noise mask requires a heightmap.");
var position = (parent?.PositionMeters ?? stars[host].PositionMeters) + OrbitPosition(p.Orbit);
var surfaceTexture = p.SurfaceTexture is null ? null : LoadColor(p.SurfaceTexture);
var planetSeed = p.Seed ?? StableSeed(systemId + "/" + p.Id);
var clouds = p.Clouds ?? CloudSettings.ForAtmosphere(planetSeed, air.HeightMeters, air.Density, p.SurfaceType);
if (clouds is not null) VisualSettingsValidator.Clouds(clouds, p.Id, air.HeightMeters, air.Density, p.SurfaceType);
var cloudDensityMap = clouds?.DensityTexturePath is { Length: > 0 } cloudPath ? LoadContentDensityMap(cloudPath) : null;
if (p.Ocean is { } ocean)
{
VisualSettingsValidator.Ocean(ocean, p.Id);
if (p.RadiusMeters + ocean.SeaLevelMeters - ocean.DepthMeters <= 0)
throw new InvalidDataException($"'{p.Id}': ocean floor must remain outside the planet centre.");
if (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant)
throw new InvalidDataException($"'{p.Id}': an ocean requires a solid planet.");
if (ocean.Enabled && ocean.MaskFromSurfaceTexture && surfaceTexture is null)
throw new InvalidDataException($"'{p.Id}': ocean.maskFromSurfaceTexture requires surfaceTexture.");
}
var result = new PlanetData(index, p.Name, position, p.RadiusMeters, terrain.ProceduralNoiseAmplitudeMeters,
air.HeightMeters, Color(p.SurfaceColor), Color(air.Color), p.Orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters,
p.Orbit.PhaseDegrees * System.Math.PI / 180, host, p.Seed ?? StableSeed(systemId + "/" + p.Id))
{
Id = p.Id,
Description = p.Description,
ParentPlanetIndex = parent?.Index,
SurfaceType = p.SurfaceType,
Gas = p.Gas,
CloudBandFrequency = p.CloudBandFrequency,
StormStrength = p.StormStrength,
SurfaceTexture = surfaceTexture,
Materials = materials,
Ocean = p.Ocean,
Clouds = clouds,
CloudDensityMap = cloudDensityMap,
OceanMask = p.Ocean is { Enabled: true, MaskFromSurfaceTexture: true } ? OceanSurface.CreateMask(surfaceTexture!) : null,
AtmosphereDensity = air.Density,
AtmosphereComposition = air.Composition,
Orientation = new(p.AxialTiltDegrees, p.RotationDegrees),
Terrain = new()
{
HeightMap = heightmap,
RegionalHeight = region,
HeightmapMinMeters = terrain.HeightmapMinMeters,
HeightmapMaxMeters = terrain.HeightmapMaxMeters,
HeightmapGamma = terrain.HeightmapGamma,
NoiseFrequency = terrain.ProceduralNoiseFrequency,
NoiseMask = terrain.NoiseMask,
LandThreshold = terrain.LandThreshold,
LandBlend = terrain.LandBlend
}
};
if (p.Rings is { } rings)
{
Positive(rings.InnerRadiusMeters, "ring inner radius"); Positive(rings.OuterRadiusMeters, "ring outer radius");
if (rings.InnerRadiusMeters <= result.MaximumTerrainRadiusMeters || rings.OuterRadiusMeters <= rings.InnerRadiusMeters
|| !Finite(rings.Opacity, rings.GapInnerRadiusMeters, rings.GapOuterRadiusMeters) || rings.Opacity is < 0 or > 1
|| rings.GapInnerRadiusMeters < 0 || rings.GapOuterRadiusMeters < rings.GapInnerRadiusMeters)
throw new InvalidDataException($"Invalid rings on '{p.Id}'.");
result = result with
{
Rings = new(rings.InnerRadiusMeters, rings.OuterRadiusMeters, Color(rings.Color), rings.Opacity,
rings.GapInnerRadiusMeters, rings.GapOuterRadiusMeters)
{
Seed = rings.Seed ?? StableSeed(systemId + "/" + p.Id + "/rings"),
ThicknessMeters = rings.ThicknessMeters,
Appearance = rings.Appearance,
Bands = rings.Bands,
Gaps = rings.Gaps,
Rocks = rings.Rocks,
Preview = rings.Preview
}
};
VisualSettingsValidator.Rings(result.Rings, p.Id);
}
return result;
}
private static DVector3 OrbitPosition(OrbitDefinition orbit)
{
Positive(orbit.SemiMajorAxisAu, "semi-major axis");
if (!Finite(orbit.Eccentricity, orbit.PhaseDegrees, orbit.InclinationDegrees, orbit.AscendingNodeDegrees, orbit.PeriapsisDegrees)
|| orbit.Eccentricity is < 0 or >= 1) throw new InvalidDataException("Only finite elliptical orbits with eccentricity in [0,1) are supported.");
var phase = orbit.PhaseDegrees * System.Math.PI / 180;
var distance = orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters * (1 - orbit.Eccentricity * orbit.Eccentricity)
/ (1 + orbit.Eccentricity * System.Math.Cos(phase));
var angle = phase + orbit.PeriapsisDegrees * System.Math.PI / 180;
var inclination = orbit.InclinationDegrees * System.Math.PI / 180;
var node = orbit.AscendingNodeDegrees * System.Math.PI / 180;
var x = System.Math.Cos(angle) * distance;
var z = System.Math.Sin(angle) * distance;
var y = z * System.Math.Sin(inclination);
z *= System.Math.Cos(inclination);
return new(x * System.Math.Cos(node) - z * System.Math.Sin(node), y, x * System.Math.Sin(node) + z * System.Math.Cos(node));
}
private string AssetPath(string relative)
{
if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new InvalidDataException("Asset paths must be relative to KnownSolarSystem.");
var path = Path.GetFullPath(Path.Combine(_assetDirectory, relative));
if (!path.StartsWith(_assetDirectory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Asset paths must stay inside KnownSolarSystem.");
if (!File.Exists(path)) throw new FileNotFoundException($"KnownSolarSystem asset not found: {relative}", path);
return path;
}
private CloudDensityMap LoadContentDensityMap(string relative)
{
var content = Path.GetFullPath(Path.Combine(_assetDirectory, "..", "Content"));
if (Path.IsPathRooted(relative)) throw new InvalidDataException("Cloud density texture paths must be relative to Content.");
var path = Path.GetFullPath(Path.Combine(content, relative));
if (!path.StartsWith(content.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Cloud density texture paths must remain inside Content.");
if (!File.Exists(path)) throw new FileNotFoundException($"Cloud density texture not found: {relative}", path);
return CloudDensityMap.Load(path);
}
private RasterImage LoadColor(string path)
{
var full = AssetPath(path);
if (!_colors.TryGetValue(full, out var image)) _colors.Add(full, image = RasterImage.Load(full));
return image;
}
private RegionalHeightMap LoadRegionalHeight(RegionalHeightDefinition definition)
{
if (definition.Width is < 2 or > 16384 || definition.Height is < 2 or > 16384
|| !Finite(definition.WestDegrees, definition.EastDegrees, definition.NorthDegrees, definition.SouthDegrees, definition.FadeDegrees)
|| definition.WestDegrees < -180 || definition.EastDegrees > 180 || definition.SouthDegrees < -90 || definition.NorthDegrees > 90
|| definition.EastDegrees <= definition.WestDegrees || definition.NorthDegrees <= definition.SouthDegrees || definition.FadeDegrees <= 0)
throw new InvalidDataException("Invalid regional heightmap bounds or dimensions.");
var content = Path.GetFullPath(Path.Combine(_assetDirectory, "..", "Content"));
if (string.IsNullOrWhiteSpace(definition.File) || Path.IsPathRooted(definition.File)) throw new InvalidDataException("Regional heightmap path must be relative to Content.");
var path = Path.GetFullPath(Path.Combine(content, definition.File));
if (!path.StartsWith(content.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Regional heightmap path must remain inside Content.");
var bytes = File.ReadAllBytes(path);
if (bytes.Length != checked(definition.Width * definition.Height * 4) || !BitConverter.IsLittleEndian)
throw new InvalidDataException("Regional heightmap must be little-endian float32 with the declared dimensions.");
var values = new float[definition.Width * definition.Height];
Buffer.BlockCopy(bytes, 0, values, 0, bytes.Length);
if (values.Any(v => !float.IsFinite(v) || v < -12000 || v > 12000)) throw new InvalidDataException("Invalid regional elevation values.");
return new() { Width = definition.Width, Height = definition.Height, Meters = values, MaximumMeters = values.Max(), MinimumMeters = values.Min(),
WestDegrees = definition.WestDegrees, SouthDegrees = definition.SouthDegrees, EastDegrees = definition.EastDegrees,
NorthDegrees = definition.NorthDegrees, FadeDegrees = definition.FadeDegrees };
}
private HeightMap LoadHeight(string path)
{
var full = AssetPath(path);
if (!_heights.TryGetValue(full, out var image)) _heights.Add(full, image = HeightMap.Load(full));
return image;
}
private static ulong StableSeed(string value)
{
var hash = 14695981039346656037UL;
foreach (var b in Encoding.UTF8.GetBytes(value)) hash = unchecked((hash ^ b) * 1099511628211UL);
return DeterministicRandom.Mix(hash);
}
private static DVector3 Vector(double[] values) => values is { Length: 3 } && Finite(values)
? new(values[0], values[1], values[2]) : throw new InvalidDataException("Position vectors require three finite numbers.");
private static Vector3 Color(float[] values) => values is { Length: 3 } && values.All(v => float.IsFinite(v) && v is >= 0 and <= 1)
? new(values[0], values[1], values[2]) : throw new InvalidDataException("Colors require three numbers in [0,1].");
private static bool Finite(params double[] values) => values.All(double.IsFinite);
private static void Positive(double value, string label)
{ if (!double.IsFinite(value) || value <= 0) throw new InvalidDataException($"{label} must be finite and positive."); }
private static void ValidateId(string id)
{ if (string.IsNullOrWhiteSpace(id) || !id.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) throw new InvalidDataException($"Invalid id '{id}'."); }
private static void AddId(HashSet<string> ids, string id)
{ ValidateId(id); if (!ids.Add(id)) throw new InvalidDataException($"Duplicate body id '{id}'."); }
private static void CheckBounds(DVector3 p, double extent, double cell, string id)
{ if (System.Math.Max(System.Math.Abs(p.X), System.Math.Max(System.Math.Abs(p.Y), System.Math.Abs(p.Z))) + extent >= cell / 2) throw new InvalidDataException($"'{id}' does not fit inside its configured sector; increase cellSizeAu."); }
}

View File

@@ -0,0 +1,123 @@
using Universe.Core.Generation;
namespace Universe.Core.Configuration;
public sealed record SolarSystemDocument
{
public int SchemaVersion { get; init; } = 1;
public string Source { get; init; } = string.Empty;
public string Notes { get; init; } = string.Empty;
public required List<SolarSystemDefinition> Systems { get; init; }
}
public sealed record SectorDefinition(long X, long Y, long Z);
public sealed record SolarSystemDefinition
{
public required string Id { get; init; }
public required string Name { get; init; }
public required SectorDefinition Sector { get; init; }
public ulong? Seed { get; init; }
public double[] CenterOffsetMeters { get; init; } = [0, 0, 0];
public required List<StarDefinition> Stars { get; init; }
public required List<PlanetDefinition> Planets { get; init; }
}
public sealed record StarDefinition
{
public StarGlowSettings Glow { get; init; } = new();
public required string Id { get; init; }
public required string Name { get; init; }
public double[] PositionMeters { get; init; } = [0, 0, 0];
public required double RadiusMeters { get; init; }
public double LuminositySolar { get; init; } = 1;
public float[] Color { get; init; } = [1, 0.94f, 0.85f];
public ulong? Seed { get; init; }
}
public sealed record OrbitDefinition
{
public required double SemiMajorAxisAu { get; init; }
public double Eccentricity { get; init; }
public double PhaseDegrees { get; init; }
public double InclinationDegrees { get; init; }
public double AscendingNodeDegrees { get; init; }
public double PeriapsisDegrees { get; init; }
}
public sealed record AtmosphereDefinition
{
public double HeightMeters { get; init; }
public double Density { get; init; } = 1;
public float[] Color { get; init; } = [0.13f, 0.32f, 1];
public string Composition { get; init; } = string.Empty;
}
public sealed record TerrainDefinition
{
public string? Heightmap { get; init; }
public RegionalHeightDefinition? RegionalHeight { get; init; }
public double HeightmapMinMeters { get; init; }
public double HeightmapMaxMeters { get; init; } = 8848;
public double HeightmapGamma { get; init; } = 1;
public double ProceduralNoiseAmplitudeMeters { get; init; }
public double ProceduralNoiseFrequency { get; init; } = 1;
public TerrainNoiseMask NoiseMask { get; init; }
public double LandThreshold { get; init; } = 0.02;
public double LandBlend { get; init; } = 0.04;
}
public sealed record RegionalHeightDefinition
{
public required string File { get; init; }
public int Width { get; init; }
public int Height { get; init; }
public double WestDegrees { get; init; }
public double SouthDegrees { get; init; }
public double EastDegrees { get; init; }
public double NorthDegrees { get; init; }
public double FadeDegrees { get; init; } = .04;
}
public sealed record RingsDefinition
{
public ulong? Seed { get; init; }
public double ThicknessMeters { get; init; } = 40;
public RingAppearanceSettings Appearance { get; init; } = new();
public RingBandSettings[] Bands { get; init; } = [];
public RingGapSettings[] Gaps { get; init; } = [];
public RingRockSettings Rocks { get; init; } = new();
public RingPreviewSettings Preview { get; init; } = new();
public required double InnerRadiusMeters { get; init; }
public required double OuterRadiusMeters { get; init; }
public float[] Color { get; init; } = [0.65f, 0.58f, 0.43f];
public double Opacity { get; init; } = 0.75;
public double GapInnerRadiusMeters { get; init; }
public double GapOuterRadiusMeters { get; init; }
}
public sealed record PlanetDefinition
{
public required string Id { get; init; }
public required string Name { get; init; }
public string Description { get; init; } = string.Empty;
public string HostStarId { get; init; } = "sun";
public required double RadiusMeters { get; init; }
public required OrbitDefinition Orbit { get; init; }
public ulong? Seed { get; init; }
public double AxialTiltDegrees { get; init; }
public double RotationDegrees { get; init; }
public PlanetSurfaceType SurfaceType { get; init; }
public GasVolumeSettings Gas { get; init; } = new();
public double CloudBandFrequency { get; init; } = 28;
public double StormStrength { get; init; }
public float[] SurfaceColor { get; init; } = [0.38f, 0.30f, 0.22f];
public string? SurfaceTexture { get; init; }
public TerrainMaterialSettings Materials { get; init; } = new();
public OceanSettings? Ocean { get; init; }
public CloudSettings? Clouds { get; init; }
public TerrainDefinition Terrain { get; init; } = new();
public AtmosphereDefinition Atmosphere { get; init; } = new();
public RingsDefinition? Rings { get; init; }
public List<PlanetDefinition> Moons { get; init; } = [];
}

View File

@@ -0,0 +1,135 @@
using Universe.Core.Generation;
namespace Universe.Core.Configuration;
internal static class VisualSettingsValidator
{
public static void Clouds(CloudSettings c, string id, double atmosphereHeight, double atmosphereDensity, PlanetSurfaceType type)
{
Range(c.BaseAltitudeMeters, 0, 1000000, id, "clouds.baseAltitudeMeters");
Range(c.ThicknessMeters, 100, 1000000, id, "clouds.thicknessMeters");
Range(c.Coverage, 0, 1, id, "clouds.coverage"); Range(c.Density, 0, 10, id, "clouds.density");
Range(c.ShapeScaleMeters, 1000, 1000000, id, "clouds.shapeScaleMeters");
Range(c.DetailScaleMeters, 100, c.ShapeScaleMeters, id, "clouds.detailScaleMeters");
Range(c.Erosion, 0, .9, id, "clouds.erosion"); Range(c.CloudType, 0, 1, id, "clouds.cloudType");
Range(c.WindSpeedMetersPerSecond, 0, 1000, id, "clouds.windSpeedMetersPerSecond");
Range(c.WindDirectionDegrees, -360, 360, id, "clouds.windDirectionDegrees");
Range(c.ExtinctionPerMeter, .000001, .1, id, "clouds.extinctionPerMeter");
Range(c.ScatteringAlbedo, 0, 1, id, "clouds.scatteringAlbedo"); Range(c.Anisotropy, 0, .9, id, "clouds.anisotropy");
Range(c.SilverLiningStrength, 0, 3, id, "clouds.silverLiningStrength");
Range(c.AmbientStrength, 0, 3, id, "clouds.ambientStrength"); Range(c.SunlightStrength, 0, 10, id, "clouds.sunlightStrength");
Range(c.SunsetStrength, 0, 5, id, "clouds.sunsetStrength"); Range(c.ShadowStrength, 0, 1, id, "clouds.shadowStrength");
Range(c.ViewSteps, 16, 192, id, "clouds.viewSteps"); Range(c.LightSteps, 2, 16, id, "clouds.lightSteps");
Range(c.ResolutionScale, .25, 1, id, "clouds.resolutionScale");
Range(c.MaxViewDistanceMeters, 10000, 2000000, id, "clouds.maxViewDistanceMeters");
Range(c.DensityTextureStrength, 0, 1, id, "clouds.densityTextureStrength");
if (c.DensityTexturePath is not null && string.IsNullOrWhiteSpace(c.DensityTexturePath))
throw new InvalidDataException($"'{id}': clouds.densityTexturePath cannot be blank; omit it to use procedural coverage.");
if (c.Enabled && (atmosphereDensity <= 0 || c.BaseAltitudeMeters + c.ThicknessMeters > atmosphereHeight
|| type is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant))
throw new InvalidDataException($"'{id}': enabled clouds require a solid planet with an atmosphere containing the entire cloud layer.");
}
public static void Ocean(OceanSettings o, string id)
{
Range(o.SeaLevelMeters, -100000, 100000, id, "ocean.seaLevelMeters");
Range(o.DepthMeters, 1, 100000, id, "ocean.depthMeters");
Range(o.WaveAmplitudeMeters, 0, 30, id, "ocean.waveAmplitudeMeters");
Range(o.WaveLengthMeters, 10, 2000, id, "ocean.waveLengthMeters");
Range(o.WindDirectionDegrees, -360, 360, id, "ocean.windDirectionDegrees");
Range(o.WaveSpeed, 0, 10, id, "ocean.waveSpeed"); Range(o.Choppiness, 0, .9, id, "ocean.choppiness");
Range(o.Roughness, .04, 1, id, "ocean.roughness"); Range(o.ReflectionStrength, 0, 3, id, "ocean.reflectionStrength");
Range(o.RefractionStrength, 0, .1, id, "ocean.refractionStrength"); Range(o.FoamStrength, 0, 3, id, "ocean.foamStrength");
Color(o.DeepColor, id); Color(o.ShallowColor, id); Color(o.Absorption, id);
}
public static void StarGlow(StarGlowSettings g, string id)
{
if (g is null) throw new InvalidDataException($"'{id}': glow cannot be null.");
Range(g.Intensity, 0, 50, id, "glow.intensity"); Range(g.ReferenceDistanceAu, .001, 1000, id, "glow.referenceDistanceAu");
Range(g.DistanceFalloff, .1, 4, id, "glow.distanceFalloff"); Range(g.ApparentRadiusMultiplier, 1, 10, id, "glow.apparentRadiusMultiplier");
Range(g.MinimumCorePixels, .1, 10, id, "glow.minimumCorePixels");
Range(g.HaloRadiusDegrees, .01, 30, id, "glow.haloRadiusDegrees"); Range(g.HaloStrength, 0, 10, id, "glow.haloStrength");
Range(g.RayCount, 2, 32, id, "glow.rayCount"); Range(g.RayLengthDegrees, .01, 45, id, "glow.rayLengthDegrees");
Range(g.RaySharpness, 1, 500, id, "glow.raySharpness"); Range(g.RayStrength, 0, 20, id, "glow.rayStrength");
Range(g.GhostStrength, 0, 5, id, "glow.ghostStrength"); Range(g.GhostSpacing, .05, 2, id, "glow.ghostSpacing");
Range(g.ChromaticAberration, 0, 1, id, "glow.chromaticAberration");
}
public static void Gas(GasVolumeSettings g, string id)
{
if (g is null) throw new InvalidDataException($"'{id}': gas settings cannot be null.");
Range(g.OuterRadiusScale, 1.0001, 1.2, id, "gas.outerRadiusScale"); Range(g.PolarRadiusScale, 0.5, 1.2, id, "gas.polarRadiusScale");
Range(g.Density, 0.1, 10000, id, "gas.density"); Range(g.ScaleHeight, 0.0001, 0.2, id, "gas.scaleHeight");
Range(g.ViewSteps, 16, 192, id, "gas.viewSteps"); Range(g.LightSteps, 2, 16, id, "gas.lightSteps");
Range(g.SampleDistribution, 1, 5, id, "gas.sampleDistribution"); Range(g.StopTransmittance, 0.0001, 0.1, id, "gas.stopTransmittance");
Range(g.ScatteringAlbedo, 0, 1, id, "gas.scatteringAlbedo"); Range(g.Anisotropy, -0.9, 0.9, id, "gas.anisotropy");
Range(g.LightExtinction, 0.01, 2, id, "gas.lightExtinction");
Range(g.SunlightStrength, 0, 50, id, "gas.sunlightStrength"); Range(g.AmbientStrength, 0, 2, id, "gas.ambientStrength");
Range(g.BandContrast, 0, 1, id, "gas.bandContrast"); Range(g.FineBandFrequency, 1, 2000, id, "gas.fineBandFrequency");
Range(g.FineBandContrast, 0, 1, id, "gas.fineBandContrast"); Range(g.CloudTurbulence, 0, 2, id, "gas.cloudTurbulence");
Range(g.DetailStrength, 0, 1, id, "gas.detailStrength"); Range(g.NoiseScale, 1, 1000, id, "gas.noiseScale"); Range(g.WindSpeed, -10, 10, id, "gas.windSpeed");
if (g.BandColor is not null) Color(g.BandColor, id); if (g.PolarColor is not null) Color(g.PolarColor, id); if (g.HazeColor is not null) Color(g.HazeColor, id);
}
public static void Rings(PlanetRings r, string id)
{
if (r.Appearance is null || r.Rocks is null || r.Preview is null || r.Bands is null || r.Gaps is null)
throw new InvalidDataException($"'{id}': ring appearance, rocks, preview, bands and gaps cannot be null.");
Range(r.ThicknessMeters, 0.1, 100000, id, "rings.thicknessMeters");
var a = r.Appearance;
Range(a.ProfileResolution, 256, 65536, id, "rings.appearance.profileResolution"); Range(a.OpticalDepth, 0, 20, id, "rings.appearance.opticalDepth");
Range(a.FineBandCount, 1, 65536, id, "rings.appearance.fineBandCount"); Range(a.MicroBandCount, 1, 65536, id, "rings.appearance.microBandCount");
Range(a.BandContrast, 0, 1, id, "rings.appearance.bandContrast"); Range(a.SunlightStrength, 0, 50, id, "rings.appearance.sunlightStrength");
Range(a.AmbientStrength, 0, 2, id, "rings.appearance.ambientStrength"); Range(a.ForwardScattering, 0, 0.95, id, "rings.appearance.forwardScattering");
Range(a.BacklitStrength, 0, 10, id, "rings.appearance.backlitStrength"); Range(a.ShadowSoftnessMeters, 1, 10000000, id, "rings.appearance.shadowSoftnessMeters");
Range(a.DustOpacity, 0, 1, id, "rings.appearance.dustOpacity"); Color(a.OuterColor, id);
foreach (var band in r.Bands)
{
if (band is null) throw new InvalidDataException($"'{id}': null ring band.");
Interval(band.InnerRadiusMeters, band.OuterRadiusMeters, r, id); Range(band.Density, 0, 20, id, "ring band density"); Color(band.Color, id);
}
foreach (var gap in r.Gaps)
{
if (gap is null) throw new InvalidDataException($"'{id}': null ring gap.");
Interval(gap.InnerRadiusMeters, gap.OuterRadiusMeters, r, id); Range(gap.DensityMultiplier, 0, 1, id, "ring gap densityMultiplier");
}
var p = r.Rocks;
var d = p.Asteroid ?? throw new InvalidDataException($"'{id}': rocks.asteroid cannot be null.");
Range(d.BaseSubdivision, 1, 3, id, "asteroid.baseSubdivision"); Range(d.MaxSubdivision, d.BaseSubdivision, 6, id, "asteroid.maxSubdivision");
Range(d.LodStartPixels, 4, 100, id, "asteroid.lodStartPixels"); Range(d.LodPixelMultiplier, 1.2, 5, id, "asteroid.lodPixelMultiplier");
Range(d.HighDetailDistanceMeters, 1, p.MeshDistanceMeters, id, "asteroid.highDetailDistanceMeters"); Range(d.MaxHighDetailInstances, 1, 64, id, "asteroid.maxHighDetailInstances");
Range(d.Elongation, 0, .7, id, "asteroid.elongation"); Range(d.NoiseFrequency, .5, 16, id, "asteroid.noiseFrequency");
Range(d.NoiseOctaves, 1, 7, id, "asteroid.noiseOctaves"); Range(d.NoisePersistence, .1, .8, id, "asteroid.noisePersistence");
Range(d.RidgeStrength, 0, .3, id, "asteroid.ridgeStrength"); Range(d.CraterFrequency, 2, 40, id, "asteroid.craterFrequency");
Range(d.CraterStrength, 0, 2, id, "asteroid.craterStrength"); Range(d.CraterDensity, 0, 1, id, "asteroid.craterDensity");
Range(d.CraterRimHeight, 0, .5, id, "asteroid.craterRimHeight"); Range(d.SurfaceDetailStrength, 0, .03, id, "asteroid.surfaceDetailStrength");
Range(d.SurfaceDetailFrequency, 10, 1000, id, "asteroid.surfaceDetailFrequency"); Range(d.NormalStrength, 0, 1, id, "asteroid.normalStrength");
Range(p.CellSizeMeters, 1, 10000, id, "rings.rocks.cellSizeMeters"); Range(p.ParticlesPerCell, 1, 4096, id, "rings.rocks.particlesPerCell");
Range(p.StreamRadiusMeters, p.CellSizeMeters, p.CellSizeMeters * 32, id, "rings.rocks.streamRadiusMeters");
Range(p.MaximumInstances, 1, 500000, id, "rings.rocks.maximumInstances");
Range(p.MinRadiusMeters, 0.001, 1000, id, "rings.rocks.minRadiusMeters"); Range(p.MaxRadiusMeters, p.MinRadiusMeters, 1000, id, "rings.rocks.maxRadiusMeters");
Range(p.SizeExponent, 0.1, 10, id, "rings.rocks.sizeExponent"); Range(p.DensityScale, 0, 20, id, "rings.rocks.densityScale");
Range(p.MeshDistanceMeters, 1, p.StreamRadiusMeters, id, "rings.rocks.meshDistanceMeters"); Range(p.MeshLodPixels, 1, 20, id, "rings.rocks.meshLodPixels");
Range(p.LodTransitionMeters, 1, p.StreamRadiusMeters, id, "rings.rocks.lodTransitionMeters");
Range(p.FadeStartMeters, 0, p.StreamRadiusMeters - 0.1, id, "rings.rocks.fadeStartMeters");
Range(p.ShapeIrregularity, 0, 0.8, id, "rings.rocks.shapeIrregularity"); Range(p.Roughness, 0, 1, id, "rings.rocks.roughness");
Range(p.IceStrength, 0, 5, id, "rings.rocks.iceStrength"); Range(p.SunlightStrength, 0, 50, id, "rings.rocks.sunlightStrength");
Range(p.AmbientStrength, 0, 2, id, "rings.rocks.ambientStrength"); Color(p.MinColor, id); Color(p.MaxColor, id);
var v = r.Preview;
Range(v.OverviewDistanceInOuterRadii, 1.2, 10, id, "rings.preview.overviewDistanceInOuterRadii");
Range(v.OverviewElevationDegrees, -85, 85, id, "rings.preview.overviewElevationDegrees");
Range(v.OverviewSunAngleDegrees, -180, 180, id, "rings.preview.overviewSunAngleDegrees");
Range(v.RollDegrees, -180, 180, id, "rings.preview.rollDegrees");
Range(v.RadiusFraction, 0, 1, id, "rings.preview.radiusFraction"); Range(v.HeightMeters, 0.1, p.StreamRadiusMeters, id, "rings.preview.heightMeters");
Range(v.AzimuthDegrees, -360, 360, id, "rings.preview.azimuthDegrees"); Range(v.YawOffsetDegrees, -180, 180, id, "rings.preview.yawOffsetDegrees");
Range(v.PitchDegrees, -85, 85, id, "rings.preview.pitchDegrees"); Range(v.FieldOfViewDegrees, 30, 120, id, "rings.preview.fieldOfViewDegrees");
Range(v.FlightSeconds, 1, 60, id, "rings.preview.flightSeconds"); Range(v.SpeedMetersPerSecond, 0.1, 1000000, id, "rings.preview.speedMetersPerSecond");
}
private static void Interval(double inner, double outer, PlanetRings rings, string id)
{
Range(inner, rings.InnerRadiusMeters, rings.OuterRadiusMeters, id, "ring interval innerRadiusMeters");
Range(outer, inner + 0.001, rings.OuterRadiusMeters, id, "ring interval outerRadiusMeters");
}
private static void Color(float[] color, string id)
{ if (color is not { Length: 3 } || color.Any(c => !float.IsFinite(c) || c < 0 || c > 1)) throw new InvalidDataException($"'{id}': colors require three finite values in [0,1]."); }
private static void Range(double value, double min, double max, string id, string label)
{ if (!double.IsFinite(value) || value < min || value > max) throw new InvalidDataException($"'{id}': {label} must be in [{min},{max}]."); }
}

View File

@@ -0,0 +1,35 @@
namespace Universe.Core.Generation;
public sealed record AsteroidDetailSettings
{
public int BaseSubdivision { get; init; } = 1;
public int MaxSubdivision { get; init; } = 6;
public double LodStartPixels { get; init; } = 14;
public double LodPixelMultiplier { get; init; } = 2.3;
public double HighDetailDistanceMeters { get; init; } = 80;
public int MaxHighDetailInstances { get; init; } = 12;
public double Elongation { get; init; } = .55;
public double NoiseFrequency { get; init; } = 1.1;
public int NoiseOctaves { get; init; } = 3;
public double NoisePersistence { get; init; } = .40;
public double RidgeStrength { get; init; } = .012;
public double CraterFrequency { get; init; } = 10;
public double CraterStrength { get; init; } = .28;
public double CraterDensity { get; init; } = .72;
public double CraterRimHeight { get; init; } = .06;
public double SurfaceDetailStrength { get; init; } = .0002;
public double SurfaceDetailFrequency { get; init; } = 150;
public double NormalStrength { get; init; } = .8;
}
public static class AsteroidLod
{
public static int Select(double projectedDiameterPixels, double distanceMeters, AsteroidDetailSettings settings)
{
var level = settings.BaseSubdivision;
var threshold = settings.LodStartPixels;
while (level < settings.MaxSubdivision && projectedDiameterPixels > threshold)
{ level++; threshold *= settings.LodPixelMultiplier; }
return distanceMeters > settings.HighDetailDistanceMeters ? System.Math.Min(level, 3) : level;
}
}

View File

@@ -0,0 +1,66 @@
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Core.Generation;
public sealed record StarData(
int Index,
string Name,
DVector3 PositionMeters,
double RadiusMeters,
double LuminositySolar,
System.Numerics.Vector3 Color,
ulong Seed)
{
public string Id { get; init; } = $"star-{Index}";
public StarGlowSettings Glow { get; init; } = new();
}
public sealed record PlanetData(
int Index,
string Name,
DVector3 PositionMeters,
double RadiusMeters,
double TerrainAmplitudeMeters,
double AtmosphereHeightMeters,
System.Numerics.Vector3 SurfaceColor,
System.Numerics.Vector3 AtmosphereColor,
double OrbitRadiusMeters,
double OrbitPhaseRadians,
int HostStarIndex,
ulong Seed)
{
public string Id { get; init; } = $"planet-{Index}";
public string Description { get; init; } = string.Empty;
public PlanetSurfaceType SurfaceType { get; init; }
public bool IsGasPlanet => SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant;
public GasVolumeSettings Gas { get; init; } = new();
public double CloudBandFrequency { get; init; } = 28;
public double StormStrength { get; init; }
public RasterImage? SurfaceTexture { get; init; }
public TerrainMaterialSettings Materials { get; init; } = new();
public OceanSettings? Ocean { get; init; }
public CloudSettings? Clouds { get; init; }
public CloudDensityMap? CloudDensityMap { get; init; }
public HeightMap? OceanMask { get; init; }
public PlanetTerrain Terrain { get; init; } = PlanetTerrain.Default;
public PlanetOrientation Orientation { get; init; } = PlanetOrientation.Identity;
public PlanetRings? Rings { get; init; }
public double AtmosphereDensity { get; init; } = 1;
public string AtmosphereComposition { get; init; } = string.Empty;
public double MaximumTerrainRadiusMeters => RadiusMeters + System.Math.Max(TerrainAmplitudeMeters * 1.2
+ System.Math.Max(Terrain.RegionalHeight?.MaximumMeters ?? 0,
Terrain.HeightMap is null ? 0 : System.Math.Max(0, System.Math.Max(Terrain.HeightmapMinMeters, Terrain.HeightmapMaxMeters))),
Ocean is { Enabled: true } water ? water.SeaLevelMeters + water.WaveAmplitudeMeters * 3 : 0);
public int? ParentPlanetIndex { get; init; }
}
public sealed record StarSystemData(
SectorCoordinate Coordinate,
ulong Seed,
IReadOnlyList<StarData> Stars,
IReadOnlyList<PlanetData> Planets)
{
public string Id { get; init; } = $"procedural:{Coordinate.X},{Coordinate.Y},{Coordinate.Z}";
public string Name { get; init; } = "Proceduriellt solsystem";
}

View File

@@ -0,0 +1,17 @@
using StbImageSharp;
namespace Universe.Core.Generation;
/// <summary>Single-channel equirectangular cloud coverage map; white means cloudy, black means clear.</summary>
public sealed record CloudDensityMap(int Width, int Height, byte[] Samples, string? SourcePath = null)
{
public static CloudDensityMap Load(string path)
{
using var stream = File.OpenRead(path);
var image = ImageResult.FromStream(stream, ColorComponents.Grey);
if (image.Width < 2 || image.Height < 2 || image.Width > 16384 || image.Height > 16384
|| image.Data.Length != checked(image.Width * image.Height))
throw new InvalidDataException($"Invalid cloud density map dimensions in '{path}': {image.Width}x{image.Height}.");
return new(image.Width, image.Height, image.Data, Path.GetFullPath(path));
}
}

View File

@@ -0,0 +1,70 @@
namespace Universe.Core.Generation;
/// <summary>Periodic fractal value/Worley volumes, generated once and sampled cheaply by the GPU.</summary>
public static class CloudNoise
{
public static byte[] Build(int size, bool detail)
{
if (size is < 4 or > 128) throw new ArgumentOutOfRangeException(nameof(size));
var data = new byte[size * size * size * 4];
Parallel.For(0, size, z =>
{
for (var y = 0; y < size; y++)
for (var x = 0; x < size; x++)
{
var px = (x + .5) / size; var py = (y + .5) / size; var pz = (z + .5) / size;
var a = Cellular(px, py, pz, 4); var b = Cellular(px, py, pz, 8); var c = Cellular(px, py, pz, 16);
var perlin = Value(px, py, pz, 4) * .55 + Value(px, py, pz, 8) * .25
+ Value(px, py, pz, 16) * .13 + Value(px, py, pz, 32) * .07;
var offset = ((z * size + y) * size + x) * 4;
static byte Pack(double v) => (byte)System.Math.Clamp(System.Math.Round(v * 255), 0, 255);
data[offset] = Pack(detail ? a : (perlin - (1 - (a * .65 + b * .25 + c * .1)) * .28) / .8);
data[offset + 1] = Pack(b); data[offset + 2] = Pack(c);
data[offset + 3] = detail ? (byte)255 : Pack(Value(px, py, pz, 16) * .65 + Value(px, py, pz, 32) * .35);
}
});
return data;
}
public static double Cellular(double x, double y, double z, int period)
{
x *= period; y *= period; z *= period;
var ix = (int)System.Math.Floor(x); var iy = (int)System.Math.Floor(y); var iz = (int)System.Math.Floor(z);
var nearest = 4d;
for (var dz = -1; dz <= 1; dz++)
for (var dy = -1; dy <= 1; dy++)
for (var dx = -1; dx <= 1; dx++)
{
var cx = ix + dx; var cy = iy + dy; var cz = iz + dz;
var h = Hash(cx, cy, cz, period);
var px = cx + .15 + .7 * Unit(h);
var py = cy + .15 + .7 * Unit(Mix(h + 1));
var pz = cz + .15 + .7 * Unit(Mix(h + 2));
var distance = (px - x) * (px - x) + (py - y) * (py - y) + (pz - z) * (pz - z);
nearest = System.Math.Min(nearest, distance);
}
return 1 - System.Math.Min(1, System.Math.Sqrt(nearest));
}
public static double Value(double x, double y, double z, int period)
{
x *= period; y *= period; z *= period;
var ix = (int)System.Math.Floor(x); var iy = (int)System.Math.Floor(y); var iz = (int)System.Math.Floor(z);
static double Fade(double t) => t * t * t * (t * (t * 6 - 15) + 10);
var tx = Fade(x - ix); var ty = Fade(y - iy); var tz = Fade(z - iz);
var result = 0d;
for (var dz = 0; dz < 2; dz++)
for (var dy = 0; dy < 2; dy++)
for (var dx = 0; dx < 2; dx++)
result += Unit(Hash(ix + dx, iy + dy, iz + dz, period)) * (dx == 0 ? 1 - tx : tx)
* (dy == 0 ? 1 - ty : ty) * (dz == 0 ? 1 - tz : tz);
return result;
}
private static double Unit(uint h) => (h & 0xFFFFFF) / 16777215d;
private static uint Hash(int x, int y, int z, int period)
{
static uint Wrap(int v, int p) => (uint)((v % p + p) % p);
return Mix(Wrap(x, period) * 73856093u ^ Wrap(y, period) * 19349663u ^ Wrap(z, period) * 83492791u ^ 0xC10D5u);
}
private static uint Mix(uint h) { h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15; h *= 0x846ca68bu; return h ^ (h >> 16); }
}

View File

@@ -0,0 +1,77 @@
using System.Numerics;
using Universe.Core.Math;
namespace Universe.Core.Generation;
public sealed record CloudSettings
{
public bool Enabled { get; init; } = true;
public ulong? Seed { get; init; }
/// <summary>Optional equirectangular grayscale weather mask, relative to Content/.</summary>
public string? DensityTexturePath { get; init; }
/// <summary>Blend strength for the optional grayscale weather mask; black is clear and white is cloudy.</summary>
public double DensityTextureStrength { get; init; } = 1;
public double BaseAltitudeMeters { get; init; } = 1800;
public double ThicknessMeters { get; init; } = 5500;
public double Coverage { get; init; } = .56;
public double Density { get; init; } = 1;
public double ShapeScaleMeters { get; init; } = 42000;
public double DetailScaleMeters { get; init; } = 6500;
public double Erosion { get; init; } = .28;
public double CloudType { get; init; } = .8;
public double WindSpeedMetersPerSecond { get; init; } = 18;
public double WindDirectionDegrees { get; init; } = 25;
public double ExtinctionPerMeter { get; init; } = .0007;
public double ScatteringAlbedo { get; init; } = .96;
public double Anisotropy { get; init; } = .62;
public double SilverLiningStrength { get; init; } = .7;
public double AmbientStrength { get; init; } = .6;
public double SunlightStrength { get; init; } = 1;
public double SunsetStrength { get; init; } = 1.4;
public double ShadowStrength { get; init; } = .7;
public int ViewSteps { get; init; } = 48;
public int LightSteps { get; init; } = 4;
public double ResolutionScale { get; init; } = .5;
public double MaxViewDistanceMeters { get; init; } = 220000;
/// <summary>Uses a separate seed stream so adding weather never moves stars or planets.</summary>
public static CloudSettings? ForAtmosphere(ulong seed, double height, double density, PlanetSurfaceType type)
{
if (height < 500 || density < .09 || type is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant) return null;
var random = new DeterministicRandom(seed ^ 0x434C4F554453UL);
var strength = System.Math.Clamp(density / 1.3, 0, 1);
var baseHeight = System.Math.Clamp(height * (.018 + random.NextDouble() * .018), 100, System.Math.Min(10000, height * .4));
return new()
{
BaseAltitudeMeters = baseHeight,
ThicknessMeters = System.Math.Min(height - baseHeight, 1500 + strength * 6000 + random.NextDouble() * 2000),
Coverage = System.Math.Clamp(.08 + strength * (.4 + random.NextDouble() * .4), .04, .94),
Density = .2 + strength * (1 + random.NextDouble() * .5),
CloudType = random.NextDouble(),
WindSpeedMetersPerSecond = 5 + random.NextDouble() * (15 + strength * 35),
WindDirectionDegrees = random.NextDouble() * 360,
ShapeScaleMeters = 26000 + random.NextDouble() * 35000
};
}
}
public static class CloudLighting
{
public static Vector3 SunTransmission(double sunCosine, double altitude, double atmosphereHeight,
double radius, double atmosphereDensity, Vector3 atmosphereColor, double strength)
{
var scaleHeight = System.Math.Max(100, atmosphereHeight * .055);
var airMass = System.Math.Exp(-System.Math.Max(0, altitude) / scaleHeight)
/ System.Math.Max(.025, sunCosine + System.Math.Sqrt(sunCosine * sunCosine + 2 * scaleHeight / radius));
var optical = (float)(airMass * atmosphereDensity * strength);
return new(System.MathF.Exp(-(.022f * (.7f + atmosphereColor.X * .3f)) * optical),
System.MathF.Exp(-(.055f * (.7f + atmosphereColor.Y * .3f)) * optical),
System.MathF.Exp(-(.14f * (.7f + atmosphereColor.Z * .3f)) * optical));
}
public static DVector3 Wind(CloudSettings settings)
{
var angle = settings.WindDirectionDegrees * System.Math.PI / 180;
return new DVector3(System.Math.Cos(angle), .05, System.Math.Sin(angle)).Normalized() * settings.WindSpeedMetersPerSecond;
}
}

View File

@@ -0,0 +1,26 @@
namespace Universe.Core.Generation;
/// <summary>SplitMix64-based generator with stable results across .NET versions.</summary>
public struct DeterministicRandom(ulong seed)
{
private ulong _state = seed;
public ulong NextUInt64()
{
var z = (_state += 0x9E3779B97F4A7C15UL);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
return z ^ (z >> 31);
}
public double NextDouble() => (NextUInt64() >> 11) * (1d / (1UL << 53));
public int NextInt(int inclusiveMinimum, int exclusiveMaximum) =>
inclusiveMinimum + (int)(NextUInt64() % (uint)(exclusiveMaximum - inclusiveMinimum));
public static ulong Mix(ulong value)
{
value = (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9UL;
value = (value ^ (value >> 27)) * 0x94D049BB133111EBUL;
return value ^ (value >> 31);
}
}

View File

@@ -0,0 +1,100 @@
namespace Universe.Core.Generation;
public sealed record GasVolumeSettings
{
public double OuterRadiusScale { get; init; } = 1.015;
public double PolarRadiusScale { get; init; } = 0.95;
public double Density { get; init; } = 420;
public double ScaleHeight { get; init; } = 0.008;
public int ViewSteps { get; init; } = 96;
public int LightSteps { get; init; } = 7;
public double SampleDistribution { get; init; } = 3;
public double StopTransmittance { get; init; } = 0.003;
public double ScatteringAlbedo { get; init; } = 0.94;
public double LightExtinction { get; init; } = 0.35;
public double SunlightStrength { get; init; } = 7;
public double AmbientStrength { get; init; } = 0.025;
public double Anisotropy { get; init; } = 0.18;
public double BandContrast { get; init; } = 0.18;
public double FineBandFrequency { get; init; } = 260;
public double FineBandContrast { get; init; } = 0.06;
public double CloudTurbulence { get; init; } = 0.12;
public double DetailStrength { get; init; } = 0.025;
public double NoiseScale { get; init; } = 65;
public double WindSpeed { get; init; } = 0.012;
public float[]? BandColor { get; init; }
public float[]? PolarColor { get; init; }
public float[]? HazeColor { get; init; }
}
public sealed record RingBandSettings
{
public double InnerRadiusMeters { get; init; }
public double OuterRadiusMeters { get; init; }
public double Density { get; init; } = 1;
public float[] Color { get; init; } = [0.68f, 0.65f, 0.57f];
}
public sealed record RingGapSettings
{
public double InnerRadiusMeters { get; init; }
public double OuterRadiusMeters { get; init; }
public double DensityMultiplier { get; init; }
}
public sealed record RingAppearanceSettings
{
public int ProfileResolution { get; init; } = 32768;
public double OpticalDepth { get; init; } = 0.9;
public double FineBandCount { get; init; } = 1200;
public double MicroBandCount { get; init; } = 12000;
public double BandContrast { get; init; } = 0.65;
public double SunlightStrength { get; init; } = 3.4;
public double AmbientStrength { get; init; } = 0.035;
public double ForwardScattering { get; init; } = 0.65;
public double BacklitStrength { get; init; } = 0.8;
public double ShadowSoftnessMeters { get; init; } = 150000;
public double DustOpacity { get; init; } = 0.08;
public float[] OuterColor { get; init; } = [0.70f, 0.68f, 0.60f];
}
public sealed record RingRockSettings
{
public AsteroidDetailSettings Asteroid { get; init; } = new();
public bool Enabled { get; init; } = true;
public double CellSizeMeters { get; init; } = 256;
public int ParticlesPerCell { get; init; } = 256;
public double StreamRadiusMeters { get; init; } = 3500;
public int MaximumInstances { get; init; } = 100000;
public double MinRadiusMeters { get; init; } = 0.06;
public double MaxRadiusMeters { get; init; } = 4;
public double SizeExponent { get; init; } = 2.8;
public double DensityScale { get; init; } = 1;
public double MeshDistanceMeters { get; init; } = 600;
public double MeshLodPixels { get; init; } = 3.5;
public double LodTransitionMeters { get; init; } = 180;
public double FadeStartMeters { get; init; } = 2600;
public double ShapeIrregularity { get; init; } = 0.27;
public double Roughness { get; init; } = 0.7;
public double IceStrength { get; init; } = 0.5;
public double SunlightStrength { get; init; } = 4;
public double AmbientStrength { get; init; } = 0.04;
public float[] MinColor { get; init; } = [0.28f, 0.28f, 0.26f];
public float[] MaxColor { get; init; } = [0.85f, 0.84f, 0.77f];
}
public sealed record RingPreviewSettings
{
public double OverviewDistanceInOuterRadii { get; init; } = 1.9;
public double OverviewElevationDegrees { get; init; } = 24;
public double OverviewSunAngleDegrees { get; init; } = 55;
public double RollDegrees { get; init; } = -12;
public double RadiusFraction { get; init; } = 0.88;
public double AzimuthDegrees { get; init; } = -80;
public double HeightMeters { get; init; } = 55;
public double YawOffsetDegrees { get; init; } = 35;
public double PitchDegrees { get; init; } = -12;
public double FieldOfViewDegrees { get; init; } = 100;
public double FlightSeconds { get; init; } = 8;
public double SpeedMetersPerSecond { get; init; } = 40;
}

View File

@@ -0,0 +1,41 @@
using System.Numerics;
namespace Universe.Core.Generation;
/// <summary>Shared vertices on a closed, seam-free geodesic sphere, subdivided uniformly.</summary>
public sealed record IcosphereMesh(float[] Vertices, uint[] Indices)
{
public static IcosphereMesh Build(int subdivisions)
{
if (subdivisions is < 0 or > 6) throw new ArgumentOutOfRangeException(nameof(subdivisions));
var t = (1 + MathF.Sqrt(5)) / 2;
var vertices = new List<Vector3>{new(-1,t,0),new(1,t,0),new(-1,-t,0),new(1,-t,0),new(0,-1,t),new(0,1,t),
new(0,-1,-t),new(0,1,-t),new(t,0,-1),new(t,0,1),new(-t,0,-1),new(-t,0,1)};
for (var i = 0; i < vertices.Count; i++) vertices[i] = Vector3.Normalize(vertices[i]);
var indices = new List<uint>{0,11,5,0,5,1,0,1,7,0,7,10,0,10,11,1,5,9,5,11,4,11,10,2,10,7,6,7,1,8,
3,9,4,3,4,2,3,2,6,3,6,8,3,8,9,4,9,5,2,4,11,6,2,10,8,6,7,9,8,1};
for (var level = 0; level < subdivisions; level++)
{
var midpoints = new Dictionary<ulong, uint>();
uint Mid(uint a, uint b)
{
var key = ((ulong)System.Math.Min(a, b) << 32) | System.Math.Max(a, b);
if (midpoints.TryGetValue(key, out var index)) return index;
index = (uint)vertices.Count; vertices.Add(Vector3.Normalize(vertices[(int)a] + vertices[(int)b]));
midpoints.Add(key, index); return index;
}
var next = new List<uint>(indices.Count * 4);
for (var i = 0; i < indices.Count; i += 3)
{
var a = indices[i]; var b = indices[i + 1]; var c = indices[i + 2];
var ab = Mid(a, b); var bc = Mid(b, c); var ca = Mid(c, a);
next.AddRange([a, ab, ca, b, bc, ab, c, ca, bc, ab, bc, ca]);
}
indices = next;
}
var packed = new float[vertices.Count * 6];
for (var i = 0; i < vertices.Count; i++)
for (var c = 0; c < 3; c++) packed[i * 6 + c] = packed[i * 6 + 3 + c] = vertices[i][c];
return new(packed, indices.ToArray());
}
}

View File

@@ -0,0 +1,81 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>Metres, seconds and linear RGB. A null ocean on a planet disables water.</summary>
public sealed record OceanSettings
{
public bool Enabled { get; init; } = true;
public bool MaskFromSurfaceTexture { get; init; } = true;
public double SeaLevelMeters { get; init; }
public double DepthMeters { get; init; } = 4000;
public double WaveAmplitudeMeters { get; init; } = 1.6;
public double WaveLengthMeters { get; init; } = 72;
public double WindDirectionDegrees { get; init; } = 35;
public double WaveSpeed { get; init; } = 1;
public double Choppiness { get; init; } = .65;
public double Roughness { get; init; } = .14;
public double ReflectionStrength { get; init; } = 1;
public double RefractionStrength { get; init; } = .018;
public double FoamStrength { get; init; } = .65;
public float[] DeepColor { get; init; } = [.006f, .045f, .075f];
public float[] ShallowColor { get; init; } = [.025f, .28f, .24f];
public float[] Absorption { get; init; } = [.12f, .045f, .022f];
}
public readonly record struct OceanWave(DVector3 Direction, double WaveNumber, double Amplitude, double AngularFrequency);
public static class OceanSurface
{
public const int WaveCount = 12;
public static OceanWave[] Spectrum(OceanSettings settings, ulong seed)
{
var random = new DeterministicRandom(seed ^ 0x4F4345414EUL);
var waves = new OceanWave[WaveCount];
for (var i = 0; i < waves.Length; i++)
{
var angle = (settings.WindDirectionDegrees + (random.NextDouble() - .5) * 115 + i * 23) * System.Math.PI / 180;
var direction = new DVector3(System.Math.Cos(angle), .23 * System.Math.Sin(angle * 1.7 + i), System.Math.Sin(angle)).Normalized();
var wavelength = settings.WaveLengthMeters * System.Math.Pow(.63, i);
var k = System.Math.Tau / wavelength;
var amplitude = settings.WaveAmplitudeMeters * System.Math.Pow(.62, i);
waves[i] = new(direction, k, amplitude, System.Math.Sqrt(9.81 * k) * settings.WaveSpeed);
}
return waves;
}
/// <summary>Blue ocean pixels become a shared CPU/GPU mask; land and white ice stay dry.</summary>
public static HeightMap CreateMask(RasterImage image)
{
var width = System.Math.Min(image.Width, 2048);
var height = System.Math.Max(2, (int)System.Math.Round((double)image.Height * width / image.Width));
var values = new float[width * height];
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
float water = 0;
for (var sy = 0; sy < 2; sy++)
for (var sx = 0; sx < 2; sx++)
{
var px = System.Math.Min(image.Width - 1, (int)((x + (sx + .5) / 2) * image.Width / width));
var py = System.Math.Min(image.Height - 1, (int)((y + (sy + .5) / 2) * image.Height / height));
var offset = (py * image.Width + px) * 4;
var r = image.Rgba[offset]; var g = image.Rgba[offset + 1]; var b = image.Rgba[offset + 2];
water += b > g * 1.15 && b > r * 1.35 ? .25f : 0;
}
values[y * width + x] = water;
}
// Smooth the coast before sampling; GPU UVs have float precision while collision uses doubles.
var horizontal = new float[values.Length]; var filtered = new float[values.Length];
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
horizontal[y * width + x] = (values[y * width + (x + width - 1) % width] + 2 * values[y * width + x]
+ values[y * width + (x + 1) % width]) * .25f;
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
filtered[y * width + x] = (horizontal[System.Math.Max(0, y - 1) * width + x] + 2 * horizontal[y * width + x]
+ horizontal[System.Math.Min(height - 1, y + 1) * width + x]) * .25f;
return new(width, height, filtered);
}
}

View File

@@ -0,0 +1,70 @@
using System.Numerics;
using Universe.Core.Math;
namespace Universe.Core.Generation;
public enum PlanetSurfaceType { Rocky, Terrestrial, CloudCovered, GasGiant, IceGiant }
public enum TerrainNoiseMask { Everywhere, HeightmapLand }
public sealed record PlanetTerrain
{
public static readonly PlanetTerrain Default = new();
public HeightMap? HeightMap { get; init; }
public RegionalHeightMap? RegionalHeight { get; init; }
public double HeightmapMinMeters { get; init; }
public double HeightmapMaxMeters { get; init; }
public double HeightmapGamma { get; init; } = 1;
public double NoiseFrequency { get; init; } = 1;
public TerrainNoiseMask NoiseMask { get; init; }
public double LandThreshold { get; init; } = 0.02;
public double LandBlend { get; init; } = 0.04;
}
public sealed record PlanetRings(double InnerRadiusMeters, double OuterRadiusMeters, Vector3 Color, double Opacity,
double GapInnerRadiusMeters = 0, double GapOuterRadiusMeters = 0)
{
public ulong Seed { get; init; } = 1;
public double ThicknessMeters { get; init; } = 40;
public RingAppearanceSettings Appearance { get; init; } = new();
public RingBandSettings[] Bands { get; init; } = [];
public RingGapSettings[] Gaps { get; init; } = [];
public RingRockSettings Rocks { get; init; } = new();
public RingPreviewSettings Preview { get; init; } = new();
}
/// <summary>Fixed body orientation. Terrain, texture UVs, ring planes and collision use this same transform.</summary>
public sealed class PlanetOrientation
{
public static readonly PlanetOrientation Identity = new(0, 0);
private readonly double _sinTilt, _cosTilt, _sinSpin, _cosSpin;
public double AxialTiltDegrees { get; }
public double RotationDegrees { get; }
public Matrix4x4 BodyToWorld { get; }
public Matrix4x4 WorldToBody { get; }
public PlanetOrientation(double axialTiltDegrees, double rotationDegrees)
{
AxialTiltDegrees = axialTiltDegrees;
RotationDegrees = rotationDegrees;
var tilt = axialTiltDegrees * System.Math.PI / 180;
var spin = rotationDegrees * System.Math.PI / 180;
(_sinTilt, _cosTilt) = System.Math.SinCos(tilt);
(_sinSpin, _cosSpin) = System.Math.SinCos(spin);
BodyToWorld = Matrix4x4.CreateRotationY((float)spin) * Matrix4x4.CreateRotationZ((float)tilt);
WorldToBody = Matrix4x4.Transpose(BodyToWorld);
}
public DVector3 ToBody(DVector3 d)
{
var x = _cosTilt * d.X + _sinTilt * d.Y;
var y = -_sinTilt * d.X + _cosTilt * d.Y;
return new(_cosSpin * x - _sinSpin * d.Z, y, _sinSpin * x + _cosSpin * d.Z);
}
public DVector3 ToWorld(DVector3 d)
{
var x = _cosSpin * d.X + _sinSpin * d.Z;
var z = -_sinSpin * d.X + _cosSpin * d.Z;
return new(_cosTilt * x - _sinTilt * d.Y, _sinTilt * x + _cosTilt * d.Y, z);
}
}

View File

@@ -0,0 +1,78 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>A periodic value field. The identical lattice is uploaded as a 3D GPU texture.</summary>
public static class ProceduralNoise
{
public const int Size = 64;
private const int Mask = Size - 1;
public static readonly float[] Values = CreateValues();
public static readonly float[] PackedGradients = CreateGradients();
public static DVector3 SeedOffset(ulong seed) => new(
(seed & 1023) / 32d, ((seed >> 20) & 1023) / 32d, ((seed >> 40) & 1023) / 32d);
public static double Sample(DVector3 p)
{
var x = (int)System.Math.Floor(p.X);
var y = (int)System.Math.Floor(p.Y);
var z = (int)System.Math.Floor(p.Z);
var fx = Fade(p.X - x);
var fy = Fade(p.Y - y);
var fz = Fade(p.Z - z);
var a = Lerp(At(x, y, z), At(x + 1, y, z), fx);
var b = Lerp(At(x, y + 1, z), At(x + 1, y + 1, z), fx);
var c = Lerp(At(x, y, z + 1), At(x + 1, y, z + 1), fx);
var d = Lerp(At(x, y + 1, z + 1), At(x + 1, y + 1, z + 1), fx);
return Lerp(Lerp(a, b, fy), Lerp(c, d, fy), fz);
}
public static DVector3 RotateOctave(DVector3 p) => new(
0.80 * p.Y + 0.60 * p.Z,
-0.80 * p.X + 0.36 * p.Y - 0.48 * p.Z,
-0.60 * p.X - 0.48 * p.Y + 0.64 * p.Z);
public static double GradientSample(DVector3 p)
{
var x = (int)System.Math.Floor(p.X); var y = (int)System.Math.Floor(p.Y); var z = (int)System.Math.Floor(p.Z);
var f = new DVector3(p.X - x, p.Y - y, p.Z - z);
var t = new DVector3(Fade(f.X), Fade(f.Y), Fade(f.Z));
var a = Lerp(GradientDot(x, y, z, f), GradientDot(x + 1, y, z, f - new DVector3(1, 0, 0)), t.X);
var b = Lerp(GradientDot(x, y + 1, z, f - new DVector3(0, 1, 0)), GradientDot(x + 1, y + 1, z, f - new DVector3(1, 1, 0)), t.X);
var c = Lerp(GradientDot(x, y, z + 1, f - new DVector3(0, 0, 1)), GradientDot(x + 1, y, z + 1, f - new DVector3(1, 0, 1)), t.X);
var d = Lerp(GradientDot(x, y + 1, z + 1, f - new DVector3(0, 1, 1)), GradientDot(x + 1, y + 1, z + 1, f - new DVector3(1, 1, 1)), t.X);
return System.Math.Clamp(Lerp(Lerp(a, b, t.Y), Lerp(c, d, t.Y), t.Z) * 1.7, -1, 1);
}
private static double GradientDot(int x, int y, int z, DVector3 d)
{
var i = (((z & Mask) * Size + (y & Mask)) * Size + (x & Mask)) * 4;
return PackedGradients[i] * d.X + PackedGradients[i + 1] * d.Y + PackedGradients[i + 2] * d.Z;
}
private static float At(int x, int y, int z) => Values[((z & Mask) * Size + (y & Mask)) * Size + (x & Mask)];
private static double Fade(double t) => t * t * t * (t * (t * 6 - 15) + 10);
private static double Lerp(double a, double b, double t) => a + (b - a) * t;
private static float[] CreateValues()
{
var values = new float[Size * Size * Size];
var random = new DeterministicRandom(0x4E4F4953453344UL);
for (var i = 0; i < values.Length; i++)
values[i] = (float)(random.NextDouble() * 2 - 1);
return values;
}
private static float[] CreateGradients()
{
var data = new float[Values.Length * 4];
var random = new DeterministicRandom(0x4752414449454E54UL);
for (var i = 0; i < Values.Length; i++)
{
var g = new DVector3(random.NextDouble() * 2 - 1, random.NextDouble() * 2 - 1, random.NextDouble() * 2 - 1).Normalized();
data[i * 4] = (float)g.X; data[i * 4 + 1] = (float)g.Y; data[i * 4 + 2] = (float)g.Z; data[i * 4 + 3] = Values[i];
}
return data;
}
}

View File

@@ -0,0 +1,68 @@
using StbImageSharp;
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>Unmodified image rows, north at the top. Color and height maps share equirectangular UVs.</summary>
public sealed record RasterImage(int Width, int Height, byte[] Rgba, string? SourcePath = null)
{
public static RasterImage Load(string path)
{
using var stream = File.OpenRead(path);
var image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
if (image.Width < 2 || image.Height < 2 || image.Width > 16384 || image.Height > 16384)
throw new InvalidDataException($"Invalid map dimensions in '{path}': {image.Width}x{image.Height}.");
return new(image.Width, image.Height, image.Data, Path.GetFullPath(path));
}
}
public sealed class HeightMap
{
public int Width { get; }
public int Height { get; }
public float[] Samples { get; }
public string? SourcePath { get; }
public HeightMap(int width, int height, float[] samples, string? sourcePath = null)
{
if (width < 2 || height < 2 || samples.Length != checked(width * height)
|| samples.Any(value => !float.IsFinite(value) || value < 0 || value > 1))
throw new ArgumentException("A heightmap must contain width*height finite values in [0,1].");
Width = width; Height = height; Samples = samples; SourcePath = sourcePath;
}
public static HeightMap Load(string path)
{
var image = RasterImage.Load(path);
var data = new float[image.Width * image.Height];
for (var i = 0; i < data.Length; i++) data[i] = image.Rgba[i * 4] / 255f;
return new(image.Width, image.Height, data, image.SourcePath);
}
public static (double U, double V) Coordinates(DVector3 direction)
{
var d = direction.Normalized();
return (0.5 - System.Math.Atan2(d.Z, d.X) / System.Math.Tau,
System.Math.Atan2(System.Math.Sqrt(d.X * d.X + d.Z * d.Z), d.Y) / System.Math.PI);
}
public double Sample(DVector3 bodyDirection)
{
var (u, v) = Coordinates(bodyDirection);
return SampleUv(u, v);
}
public double SampleUv(double u, double v)
{
var x = (u - System.Math.Floor(u)) * Width - 0.5;
var y = System.Math.Clamp(v * Height - 0.5, 0, Height - 1);
var x0 = (int)System.Math.Floor(x);
var y0 = (int)System.Math.Floor(y);
var tx = x - x0; var ty = y - y0;
var a = At(x0, y0) * (1 - tx) + At(x0 + 1, y0) * tx;
var b = At(x0, y0 + 1) * (1 - tx) + At(x0 + 1, y0 + 1) * tx;
return a * (1 - ty) + b * ty;
}
private float At(int x, int y) => Samples[System.Math.Clamp(y, 0, Height - 1) * Width + ((x % Width + Width) % Width)];
}

View File

@@ -0,0 +1,46 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>A north-up, geographic tile of metre heights. The fade joins it to the global map.</summary>
public sealed class RegionalHeightMap
{
public required int Width { get; init; }
public required int Height { get; init; }
public required float[] Meters { get; init; }
public required double WestDegrees { get; init; }
public required double SouthDegrees { get; init; }
public required double EastDegrees { get; init; }
public required double NorthDegrees { get; init; }
public required double FadeDegrees { get; init; }
public required double MaximumMeters { get; init; }
public required double MinimumMeters { get; init; }
public (double Height, double Weight) Sample(DVector3 direction)
{
var (u, v) = HeightMap.Coordinates(direction);
var lon = (u - .5) * 360;
var lat = 90 - v * 180;
var edge = System.Math.Min(System.Math.Min(lon - WestDegrees, EastDegrees - lon),
System.Math.Min(lat - SouthDegrees, NorthDegrees - lat));
if (edge <= 0) return (0, 0);
var weight = System.Math.Clamp(edge / FadeDegrees, 0, 1);
weight = weight * weight * (3 - 2 * weight);
var x = System.Math.Clamp((lon - WestDegrees) / (EastDegrees - WestDegrees) * Width - .5, 0, Width - 1);
var y = System.Math.Clamp((NorthDegrees - lat) / (NorthDegrees - SouthDegrees) * Height - .5, 0, Height - 1);
var x0 = (int)x; var y0 = (int)y; var x1 = System.Math.Min(x0 + 1, Width - 1); var y1 = System.Math.Min(y0 + 1, Height - 1);
var a = Meters[y0 * Width + x0] * (1 - (x - x0)) + Meters[y0 * Width + x1] * (x - x0);
var b = Meters[y1 * Width + x0] * (1 - (x - x0)) + Meters[y1 * Width + x1] * (x - x0);
return (a * (1 - (y - y0)) + b * (y - y0), weight);
}
}
public sealed record TerrainMaterialSettings
{
public bool Enabled { get; init; } = true;
public string? FlatMaterial { get; init; }
public string? SlopeMaterial { get; init; }
public double ScaleMeters { get; init; } = 8;
public double FadeDistanceMeters { get; init; } = 2000;
public double Strength { get; init; } = .8;
}

View File

@@ -0,0 +1,64 @@
using System.Numerics;
namespace Universe.Core.Generation;
public readonly record struct RingMaterial(Vector3 Color, double OpticalDepth);
/// <summary>The same seeded radial optical-depth profile drives distant rings, shadows and rock density.</summary>
public sealed class RingProfile
{
public PlanetRings Rings { get; }
public float[] Rgba { get; }
public int Resolution => Rgba.Length / 4;
public RingProfile(PlanetRings rings)
{
Rings = rings;
Rgba = new float[rings.Appearance.ProfileResolution * 4];
var offset = (rings.Seed & 65535) / 131d;
var outer = Vector(rings.Appearance.OuterColor);
for (var i = 0; i < Resolution; i++)
{
var u = (i + 0.5) / Resolution;
var radius = rings.InnerRadiusMeters + u * (rings.OuterRadiusMeters - rings.InnerRadiusMeters);
var color = Vector3.Lerp(rings.Color, outer, (float)u);
var density = rings.Bands.Length == 0 ? 1d : 0d;
foreach (var band in rings.Bands)
if (radius >= band.InnerRadiusMeters && radius <= band.OuterRadiusMeters)
{ density = band.Density; color = Vector(band.Color); }
var broad = Noise(u * 37 + offset);
var fine = Noise(u * rings.Appearance.FineBandCount + offset * 3.1);
var micro = Noise(u * rings.Appearance.MicroBandCount + offset * 7.3);
var structure = System.Math.Clamp(1 + rings.Appearance.BandContrast * (broad * 0.30 + fine * 0.60 + micro * 0.35), 0.04, 2);
density *= structure * rings.Opacity * rings.Appearance.OpticalDepth;
color *= (float)(0.82 + 0.18 * structure);
if (radius > rings.GapInnerRadiusMeters && radius < rings.GapOuterRadiusMeters) density = 0;
foreach (var gap in rings.Gaps)
if (radius > gap.InnerRadiusMeters && radius < gap.OuterRadiusMeters) density *= gap.DensityMultiplier;
Rgba[i * 4] = color.X; Rgba[i * 4 + 1] = color.Y; Rgba[i * 4 + 2] = color.Z; Rgba[i * 4 + 3] = (float)density;
}
}
public RingMaterial Sample(double radius)
{
if (radius < Rings.InnerRadiusMeters || radius > Rings.OuterRadiusMeters) return new(Vector3.Zero, 0);
// Preserve truly empty gaps even when the radial texture is minified.
if (radius > Rings.GapInnerRadiusMeters && radius < Rings.GapOuterRadiusMeters) return new(Vector3.Zero, 0);
foreach (var gap in Rings.Gaps)
if (gap.DensityMultiplier == 0 && radius > gap.InnerRadiusMeters && radius < gap.OuterRadiusMeters) return new(Vector3.Zero, 0);
var p = (radius - Rings.InnerRadiusMeters) / (Rings.OuterRadiusMeters - Rings.InnerRadiusMeters) * Resolution - 0.5;
var i = (int)System.Math.Floor(p); var f = (float)(p - i);
var a = System.Math.Clamp(i, 0, Resolution - 1) * 4;
var b = System.Math.Clamp(i + 1, 0, Resolution - 1) * 4;
return new(Vector3.Lerp(new(Rgba[a], Rgba[a + 1], Rgba[a + 2]), new(Rgba[b], Rgba[b + 1], Rgba[b + 2]), f), Rgba[a + 3] * (1 - f) + Rgba[b + 3] * f);
}
private static double Noise(double x)
{
var i = (long)System.Math.Floor(x); var f = x - i;
f = f * f * (3 - 2 * f);
static double H(long n) => (DeterministicRandom.Mix(unchecked((ulong)n)) >> 11) * (2d / (1UL << 53)) - 1;
return H(i) * (1 - f) + H(i + 1) * f;
}
private static Vector3 Vector(float[] color) => new(color[0], color[1], color[2]);
}

View File

@@ -0,0 +1,90 @@
using System.Numerics;
using Universe.Core.Math;
namespace Universe.Core.Generation;
public readonly record struct RingCell(long X, long Z);
public readonly record struct RingRock(DVector3 Position, double Radius, Vector3 Color, Quaternion Rotation, float ShapeSeed);
public sealed record RingRockFrame(DVector3 Anchor, IReadOnlyList<RingRock> Rocks, int Revision);
/// <summary>Deterministic local debris cells in the ring's own frame; distant cells are evicted.</summary>
public sealed class RingRockStreamer(RingProfile profile)
{
private readonly Dictionary<RingCell, RingRock[]> _cells = [];
private RingCell _lastCell = new(long.MinValue, long.MinValue);
private long _lastHeight = long.MinValue;
private RingRockFrame _frame = new(DVector3.Zero, [], 0);
public int CachedCells => _cells.Count;
public double DistanceToRing(DVector3 camera)
{
var r = System.Math.Sqrt(camera.X * camera.X + camera.Z * camera.Z);
var dr = r < profile.Rings.InnerRadiusMeters ? profile.Rings.InnerRadiusMeters - r : System.Math.Max(0, r - profile.Rings.OuterRadiusMeters);
var dy = System.Math.Max(0, System.Math.Abs(camera.Y) - profile.Rings.ThicknessMeters * 0.5);
return System.Math.Sqrt(dr * dr + dy * dy);
}
public RingRockFrame Update(DVector3 camera)
{
var options = profile.Rings.Rocks;
if (!options.Enabled || DistanceToRing(camera) > options.StreamRadiusMeters)
{
if (_frame.Rocks.Count > 0 || _cells.Count > 0)
{ _cells.Clear(); _lastCell = new(long.MinValue, long.MinValue); _lastHeight = long.MinValue; _frame = new(DVector3.Zero, [], _frame.Revision + 1); }
return _frame;
}
var cell = new RingCell((long)System.Math.Floor(camera.X / options.CellSizeMeters), (long)System.Math.Floor(camera.Z / options.CellSizeMeters));
var height = (long)System.Math.Floor(camera.Y / options.CellSizeMeters);
if (cell == _lastCell && height == _lastHeight) return _frame;
_lastCell = cell; _lastHeight = height;
var radius = (int)System.Math.Ceiling(options.StreamRadiusMeters / options.CellSizeMeters) + 1;
var candidates = new List<RingCell>();
for (var z = -radius; z <= radius; z++) for (var x = -radius; x <= radius; x++) candidates.Add(new(cell.X + x, cell.Z + z));
double Distance(RingCell c)
{
var x = (c.X + 0.5) * options.CellSizeMeters - camera.X; var z = (c.Z + 0.5) * options.CellSizeMeters - camera.Z;
return x * x + z * z;
}
candidates.Sort((a, b) => { var d = Distance(a).CompareTo(Distance(b)); return d != 0 ? d : a.X != b.X ? a.X.CompareTo(b.X) : a.Z.CompareTo(b.Z); });
var result = new List<RingRock>(System.Math.Min(options.MaximumInstances, 50000));
var range = (options.StreamRadiusMeters + options.CellSizeMeters) * (options.StreamRadiusMeters + options.CellSizeMeters);
foreach (var candidate in candidates)
{
if (Distance(candidate) > range) continue;
if (!_cells.TryGetValue(candidate, out var rocks)) _cells.Add(candidate, rocks = GenerateCell(candidate));
foreach (var rock in rocks)
{
if ((rock.Position - camera).LengthSquared > range) continue;
result.Add(rock);
if (result.Count >= options.MaximumInstances) break;
}
if (result.Count >= options.MaximumInstances) break;
}
foreach (var key in _cells.Keys.Where(c => System.Math.Abs(c.X - cell.X) > radius + 1 || System.Math.Abs(c.Z - cell.Z) > radius + 1).ToArray()) _cells.Remove(key);
_frame = new(new(cell.X * options.CellSizeMeters, 0, cell.Z * options.CellSizeMeters), result, _frame.Revision + 1);
return _frame;
}
public RingRock[] GenerateCell(RingCell cell)
{
var rings = profile.Rings; var options = rings.Rocks;
var seed = DeterministicRandom.Mix(rings.Seed ^ unchecked((ulong)cell.X) * 0xD6E8FEB86659FD93UL ^ unchecked((ulong)cell.Z) * 0xA5A3564E27F8862FUL);
var random = new DeterministicRandom(seed);
var result = new List<RingRock>();
var min = new Vector3(options.MinColor[0], options.MinColor[1], options.MinColor[2]);
var max = new Vector3(options.MaxColor[0], options.MaxColor[1], options.MaxColor[2]);
for (var i = 0; i < options.ParticlesPerCell; i++)
{
var x = (cell.X + random.NextDouble()) * options.CellSizeMeters;
var z = (cell.Z + random.NextDouble()) * options.CellSizeMeters;
var radius = System.Math.Sqrt(x * x + z * z);
var material = profile.Sample(radius);
if (material.OpticalDepth <= 0 || random.NextDouble() >= 1 - System.Math.Exp(-material.OpticalDepth * options.DensityScale)) continue;
var y = (random.NextDouble() - 0.5) * rings.ThicknessMeters;
var size = options.MinRadiusMeters + (options.MaxRadiusMeters - options.MinRadiusMeters) * System.Math.Pow(random.NextDouble(), options.SizeExponent);
var color = Vector3.Lerp(min, max, (float)random.NextDouble());
var rotation = Quaternion.CreateFromYawPitchRoll((float)(random.NextDouble() * System.Math.Tau), (float)(random.NextDouble() * System.Math.Tau), (float)(random.NextDouble() * System.Math.Tau));
result.Add(new(new(x, y, z), size, color, rotation, (float)random.NextDouble()));
}
return result.ToArray();
}
}

View File

@@ -0,0 +1,28 @@
namespace Universe.Core.Generation;
public sealed record StarGlowSettings
{
public bool Enabled { get; init; } = true;
public double Intensity { get; init; } = 1.8;
public double ReferenceDistanceAu { get; init; } = 1;
public double DistanceFalloff { get; init; } = .65;
public double ApparentRadiusMultiplier { get; init; } = 1;
public double MinimumCorePixels { get; init; } = 1.2;
public double HaloRadiusDegrees { get; init; } = 1.4;
public double HaloStrength { get; init; } = .30;
public int RayCount { get; init; } = 10;
public double RayLengthDegrees { get; init; } = 4.5;
public double RaySharpness { get; init; } = 70;
public double RayStrength { get; init; } = 1.2;
public double GhostStrength { get; init; } = .14;
public double GhostSpacing { get; init; } = .42;
public double ChromaticAberration { get; init; } = .20;
public double Attenuation(double distanceMeters, double luminositySolar)
{
if (!Enabled) return 0;
var distanceAu = System.Math.Max(0, distanceMeters / World.UniverseScale.AstronomicalUnitMeters);
return Intensity * System.Math.Sqrt(System.Math.Max(0, luminositySolar)) /
(1 + System.Math.Pow(distanceAu / ReferenceDistanceAu, DistanceFalloff));
}
}

View File

@@ -0,0 +1,69 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
public sealed record TerrainChunkData(float[] Vertices, uint[] Indices)
{
public int VertexCount => Vertices.Length / 6;
}
/// <summary>Builds one cube-sphere face at a chosen LOD. Six faces form a planet surface.</summary>
public static class TerrainChunkBuilder
{
public static TerrainChunkData BuildFace(PlanetData planet, int face, int segments)
{
if ((uint)face >= 6)
throw new ArgumentOutOfRangeException(nameof(face));
if (segments < 2 || segments > 256)
throw new ArgumentOutOfRangeException(nameof(segments));
var side = segments + 1;
var vertices = new float[side * side * 6];
var indices = new uint[segments * segments * 6];
var write = 0;
for (var y = 0; y <= segments; y++)
for (var x = 0; x <= segments; x++)
{
var u = x / (double)segments * 2 - 1;
var v = y / (double)segments * 2 - 1;
var direction = FaceDirection(face, u, v).Normalized();
var position = direction * TerrainHeight.Sample(planet, direction);
var normal = direction;
vertices[write++] = (float)position.X;
vertices[write++] = (float)position.Y;
vertices[write++] = (float)position.Z;
vertices[write++] = (float)normal.X;
vertices[write++] = (float)normal.Y;
vertices[write++] = (float)normal.Z;
}
write = 0;
for (var y = 0; y < segments; y++)
for (var x = 0; x < segments; x++)
{
var topLeft = (uint)(y * side + x);
var topRight = topLeft + 1;
var bottomLeft = topLeft + (uint)side;
var bottomRight = bottomLeft + 1;
indices[write++] = topLeft;
indices[write++] = bottomLeft;
indices[write++] = topRight;
indices[write++] = topRight;
indices[write++] = bottomLeft;
indices[write++] = bottomRight;
}
return new(vertices, indices);
}
public static DVector3 FaceDirection(int face, double u, double v) => face switch
{
0 => new(1, v, -u),
1 => new(-1, v, u),
2 => new(u, 1, -v),
3 => new(u, -1, v),
4 => new(u, v, 1),
_ => new(-u, v, -1)
};
}

View File

@@ -0,0 +1,76 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>Domain-warped ridged multifractal terrain, shared by collision, meshes and GLSL.</summary>
public static class TerrainHeight
{
public static double Sample(PlanetData planet, DVector3 direction)
{
var unitDirection = direction.Normalized();
if (unitDirection == DVector3.Zero) unitDirection = new(0, 1, 0);
var bodyDirection = planet.Orientation.ToBody(unitDirection);
var terrain = planet.Terrain;
var map = terrain.HeightMap?.Sample(bodyDirection) ?? 0;
var mappedHeight = terrain.HeightMap is null ? 0 : terrain.HeightmapMinMeters
+ (terrain.HeightmapMaxMeters - terrain.HeightmapMinMeters) * System.Math.Pow(map, terrain.HeightmapGamma);
if (terrain.RegionalHeight is { } region)
{
var (regionalHeight, weight) = region.Sample(bodyDirection);
mappedHeight += (regionalHeight - mappedHeight) * weight;
}
var mask = 1d;
if (terrain.NoiseMask == TerrainNoiseMask.HeightmapLand && terrain.HeightMap is not null)
{
var t = System.Math.Clamp((map - terrain.LandThreshold) / terrain.LandBlend, 0, 1);
mask = t * t * (3 - 2 * t);
}
var noise = planet.TerrainAmplitudeMeters == 0 ? 0 : Elevation(bodyDirection * terrain.NoiseFrequency,
ProceduralNoise.SeedOffset(planet.Seed)) * planet.TerrainAmplitudeMeters * mask;
var elevation = mappedHeight + noise;
if (planet.Ocean is { Enabled: true } ocean && planet.OceanMask is { } water)
{
var wet = water.Sample(bodyDirection);
elevation += (ocean.SeaLevelMeters - ocean.DepthMeters - elevation) * wet;
}
return planet.RadiusMeters + elevation;
}
public static double Elevation(DVector3 direction, DVector3 offset)
{
var p = direction * 3.2 + offset;
var warp = new DVector3(
ProceduralNoise.Sample(p * 0.82 + new DVector3(5.2, 1.3, 9.7)),
ProceduralNoise.Sample(p * 0.82 + new DVector3(19.1, 7.7, 2.4)),
ProceduralNoise.Sample(p * 0.82 + new DVector3(3.4, 21.8, 11.6)));
var continental = ProceduralNoise.Sample(p * 0.47);
p = direction * 52 + offset + warp * 2.6;
var amplitude = 0.58;
var weight = 1d;
var sum = 0d;
for (var i = 0; i < 7; i++)
{
var ridge = 1 - System.Math.Abs(ProceduralNoise.GradientSample(p));
ridge = ridge * ridge * ridge * weight;
sum += ridge * amplitude;
weight = 0.55 + 0.45 * ridge;
p = ProceduralNoise.RotateOctave(p) * 2.03 + new DVector3(17.1, 9.2, 13.5);
amplitude *= 0.50;
}
return (sum - 0.42) * 0.95 + continental * 0.16;
}
public static DVector3 Normal(PlanetData planet, DVector3 direction, double epsilon = 0.000025)
{
var d = direction.Normalized();
var axis = System.Math.Abs(d.Y) < 0.9 ? new DVector3(0, 1, 0) : new DVector3(1, 0, 0);
var tangent = DVector3.Cross(axis, d).Normalized();
var bitangent = DVector3.Cross(d, tangent);
var a = (d + tangent * epsilon).Normalized();
var b = (d - tangent * epsilon).Normalized();
var c = (d + bitangent * epsilon).Normalized();
var e = (d - bitangent * epsilon).Normalized();
var normal = DVector3.Cross(a * Sample(planet, a) - b * Sample(planet, b), c * Sample(planet, c) - e * Sample(planet, e)).Normalized();
return DVector3.Dot(normal, d) < 0 ? -normal : normal;
}
}

View File

@@ -0,0 +1,29 @@
namespace Universe.Core.Generation;
public static class TerrainLodSelector
{
private static readonly int[] SegmentCounts = [4, 8, 16, 32, 64, 128];
public static int SelectLevel(double cameraDistanceMeters, double planetRadiusMeters)
{
if (planetRadiusMeters <= 0 || !double.IsFinite(planetRadiusMeters))
throw new ArgumentOutOfRangeException(nameof(planetRadiusMeters));
var distanceInRadii = System.Math.Max(0, cameraDistanceMeters) / planetRadiusMeters;
return distanceInRadii switch
{
< 1.35 => 5,
< 2.8 => 4,
< 6 => 3,
< 18 => 2,
< 50 => 1,
_ => 0
};
}
public static int SegmentsForLevel(int level)
{
if ((uint)level >= SegmentCounts.Length)
throw new ArgumentOutOfRangeException(nameof(level));
return SegmentCounts[level];
}
}

View File

@@ -0,0 +1,93 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
public readonly record struct TerrainPatchKey(int Face, int Level, int X, int Y)
{
public double Width => 2d / (1 << Level);
public DVector3 Direction => TerrainChunkBuilder.FaceDirection(Face, -1 + (X + 0.5) * Width, -1 + (Y + 0.5) * Width).Normalized();
public TerrainPatchKey Child(int index) => new(Face, Level + 1, X * 2 + (index & 1), Y * 2 + (index >> 1));
}
public sealed record TerrainPatchData(DVector3 Origin, float[] Vertices, uint[] Indices)
{
public DVector3 BoundsCenter { get; init; }
public double BoundsRadius { get; init; }
}
/// <summary>Camera-relative quad-tree tiles with shared edge normals and recessed seam coverage.</summary>
public static class TerrainPatchBuilder
{
public const int Segments = 24;
public static TerrainPatchData Build(PlanetData planet, TerrainPatchKey key)
{
var side = Segments + 1;
var expanded = side + 2;
var positions = new DVector3[expanded * expanded];
var origin = key.Direction * planet.RadiusMeters;
var startU = -1 + key.X * key.Width;
var startV = -1 + key.Y * key.Width;
for (var y = -1; y <= side; y++)
for (var x = -1; x <= side; x++)
{
var d = TerrainChunkBuilder.FaceDirection(key.Face, startU + key.Width * x / Segments, startV + key.Width * y / Segments).Normalized();
positions[(y + 1) * expanded + x + 1] = d * TerrainHeight.Sample(planet, d);
}
var vertices = new List<float>((side * side + side * 4) * 9);
for (var y = 0; y < side; y++)
for (var x = 0; x < side; x++)
{
var index = (y + 1) * expanded + x + 1;
var p = positions[index];
var n = DVector3.Cross(positions[index + 1] - positions[index - 1], positions[index + expanded] - positions[index - expanded]).Normalized();
if (DVector3.Dot(n, p) < 0) n = -n;
AddVertex(vertices, p - origin, n, p.Normalized());
}
var indices = new List<uint>(Segments * Segments * 6 + Segments * 24);
for (var y = 0; y < Segments; y++)
for (var x = 0; x < Segments; x++)
{
var a = (uint)(y * side + x);
indices.AddRange([a, a + (uint)side, a + 1, a + 1, a + (uint)side, a + (uint)side + 1]);
}
var spacing = planet.RadiusMeters * key.Width / Segments;
// A fixed fraction of mountain amplitude creates hundred-metre walls around
// metre-sized tiles. Keep enough overlap for seams while shrinking it with LOD.
var skirtDepth = spacing * 2 + System.Math.Min(planet.TerrainAmplitudeMeters * .012, spacing * 2);
for (var edge = 0; edge < 4; edge++)
{
var skirtStart = (uint)(vertices.Count / 9);
for (var t = 0; t < side; t++)
{
var source = edge switch { 0 => t, 1 => t * side + Segments, 2 => Segments * side + t, _ => t * side };
var p = new DVector3(vertices[source * 9], vertices[source * 9 + 1], vertices[source * 9 + 2]) + origin;
var n = new DVector3(vertices[source * 9 + 3], vertices[source * 9 + 4], vertices[source * 9 + 5]);
var d = p.Normalized();
AddVertex(vertices, p - d * skirtDepth - origin, n, d);
if (t == Segments) continue;
var next = source + (edge is 0 or 2 ? 1 : side);
var b = skirtStart + (uint)t;
indices.AddRange([(uint)source, b, (uint)next, (uint)next, b, b + 1]);
}
}
var vertexData = vertices.ToArray();
var min = new DVector3(double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
var max = new DVector3(double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity);
for (var i = 0; i < vertexData.Length; i += 9)
{
min = new(System.Math.Min(min.X, vertexData[i]), System.Math.Min(min.Y, vertexData[i + 1]), System.Math.Min(min.Z, vertexData[i + 2]));
max = new(System.Math.Max(max.X, vertexData[i]), System.Math.Max(max.Y, vertexData[i + 1]), System.Math.Max(max.Z, vertexData[i + 2]));
}
var center = (min + max) * .5;
var radius = 0d;
for (var i = 0; i < vertexData.Length; i += 9)
radius = System.Math.Max(radius, (new DVector3(vertexData[i], vertexData[i + 1], vertexData[i + 2]) - center).Length);
return new(origin, vertexData, indices.ToArray()) { BoundsCenter = origin + center, BoundsRadius = radius + .01 };
}
private static void AddVertex(List<float> target, DVector3 p, DVector3 n, DVector3 d)
{
target.AddRange([(float)p.X, (float)p.Y, (float)p.Z, (float)n.X, (float)n.Y, (float)n.Z, (float)d.X, (float)d.Y, (float)d.Z]);
}
}

View File

@@ -0,0 +1,80 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>Conservative horizon and distance estimates for cube-sphere patches.</summary>
public static class TerrainVisibility
{
public static double InnerRadius(PlanetData planet)
{
var mapFloor = planet.Terrain.HeightMap is null ? 0 : System.Math.Min(0, planet.Terrain.HeightmapMinMeters);
mapFloor = System.Math.Min(mapFloor, planet.Terrain.RegionalHeight?.MinimumMeters ?? 0);
// The procedural terrain is bounded by less than 1.2 times its amplitude.
var floor = planet.RadiusMeters + mapFloor - planet.TerrainAmplitudeMeters * 1.2;
if (planet.Ocean is { Enabled: true } ocean && planet.OceanMask is not null)
floor = System.Math.Min(floor, planet.RadiusMeters + ocean.SeaLevelMeters - ocean.DepthMeters);
return System.Math.Max(0, floor);
}
public static double HorizonAngle(DVector3 camera, double innerRadius, double outerRadius)
{
var distance = camera.Length;
if (innerRadius <= 0 || outerRadius < innerRadius || distance <= innerRadius) return System.Math.PI;
return System.Math.Acos(System.Math.Clamp(innerRadius / distance, 0, 1))
+ System.Math.Acos(System.Math.Clamp(innerRadius / outerRadius, 0, 1));
}
public static bool BehindHorizon(TerrainPatchKey patch, DVector3 cameraDirection, double horizonAngle)
{
// Normalizing a cube-face point is at most 1-Lipschitz in face coordinates.
// The centre-to-corner distance is width/sqrt(2), so this cap contains the whole patch.
var angularRadius = patch.Width * .708;
var limit = horizonAngle + angularRadius + .001;
if (limit >= System.Math.PI) return false;
return DVector3.Dot(cameraDirection, patch.Direction) < System.Math.Cos(limit);
}
public static bool OutsideFrustum(TerrainPatchKey key, DVector3 camera, DVector3 forward, DVector3 right, DVector3 up,
double verticalTan, double aspect, double lowerRadius, double upperRadius)
{
var direction = key.Direction;
var midpoint = (upperRadius + lowerRadius) * .5;
var radialExtent = (upperRadius - lowerRadius) * .5;
var tangentExtent = upperRadius * key.Width * .708;
var toPatch = direction * midpoint - camera;
bool Outside(DVector3 plane)
{
var bound = tangentExtent * plane.Length + radialExtent * System.Math.Abs(DVector3.Dot(direction, plane));
return DVector3.Dot(toPatch, plane) < -bound;
}
var horizontalTan = verticalTan * aspect;
return Outside(forward) || Outside(forward * verticalTan + up) || Outside(forward * verticalTan - up)
|| Outside(forward * horizontalTan + right) || Outside(forward * horizontalTan - right);
}
public static double DistanceToPatch(TerrainPatchKey key, DVector3 camera, double radius)
{
// Project the camera onto the cube face, then clamp to the patch's actual rectangle.
// Unlike a bounding sphere, this distinguishes the tile underfoot from its neighbours.
var (denominator, u, v) = key.Face switch
{
0 => (camera.X, -camera.Z, camera.Y), 1 => (-camera.X, camera.Z, camera.Y),
2 => (camera.Y, camera.X, -camera.Z), 3 => (-camera.Y, camera.X, camera.Z),
4 => (camera.Z, camera.X, camera.Y), _ => (-camera.Z, -camera.X, camera.Y)
};
if (denominator <= 0) return (camera - key.Direction * radius).Length;
var startU = -1 + key.X * key.Width;
var startV = -1 + key.Y * key.Width;
var nearestU = System.Math.Clamp(u / denominator, startU, startU + key.Width);
var nearestV = System.Math.Clamp(v / denominator, startV, startV + key.Width);
var direction = TerrainChunkBuilder.FaceDirection(key.Face, nearestU, nearestV).Normalized();
return (camera - direction * radius).Length;
}
public static double PixelTarget(double distanceMeters)
{
var t = System.Math.Clamp((distanceMeters - 500) / 15_000, 0, 1);
t = t * t * (3 - 2 * t);
return 2.5 + t * 9.5;
}
}

View File

@@ -0,0 +1,152 @@
using System.Numerics;
using Universe.Core.Math;
using Universe.Core.World;
using Universe.Core.Configuration;
namespace Universe.Core.Generation;
public sealed class UniverseGenerator
{
private readonly KnownSolarSystemCatalog? _knownSystems;
private static readonly Vector3[] StarColors =
[new(1f, 0.83f, 0.62f), new(1f, 0.94f, 0.83f), new(0.72f, 0.84f, 1f), new(1f, 0.56f, 0.32f)];
private static readonly Vector3[] PlanetColors =
[new(0.38f, 0.29f, 0.21f), new(0.30f, 0.32f, 0.29f), new(0.43f, 0.26f, 0.16f), new(0.26f, 0.30f, 0.35f), new(0.47f, 0.41f, 0.30f)];
private static readonly Vector3[] AtmosphereColors =
[new(0.13f, 0.32f, 1f), new(0.16f, 0.42f, 1f), new(0.09f, 0.22f, 0.88f), new(0.80f, 0.48f, 0.25f)];
public UniverseGenerator(ulong universeSeed, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters, KnownSolarSystemCatalog? knownSystems = null)
{
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0)
throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
UniverseSeed = universeSeed;
CellSizeMeters = cellSizeMeters;
_knownSystems = knownSystems;
}
public ulong UniverseSeed { get; }
public double CellSizeMeters { get; }
public StarSystemData Generate(SectorCoordinate coordinate)
{
if (_knownSystems is not null && _knownSystems.TryGet(coordinate, out var known)) return known;
var systemSeed = SeedFor(coordinate);
var random = new DeterministicRandom(systemSeed);
var noiseOffset = ProceduralNoise.SeedOffset(systemSeed);
// Jitter the barycentre inside the cell in all three axes, without moving bodies out of it.
var barycentre = new DVector3(
ProceduralNoise.Sample(noiseOffset + new DVector3(7.1, 1.4, 9.2)),
ProceduralNoise.Sample(noiseOffset + new DVector3(17.7, 6.3, 3.1)),
ProceduralNoise.Sample(noiseOffset + new DVector3(2.6, 22.3, 11.8))) * (CellSizeMeters * 0.18);
var starCount = random.NextDouble() < 0.46 ? 2 : 1;
var binaryAxis = UnitVector(ref random);
var separation = (0.025 + random.NextDouble() * 0.17) * UniverseScale.AstronomicalUnitMeters;
var massRatio = 0.25 + random.NextDouble() * 0.6;
var stars = new List<StarData>(starCount);
for (var i = 0; i < starCount; i++)
{
var scale = 0.65 + random.NextDouble() * 0.85;
var position = starCount == 1 ? barycentre : barycentre + binaryAxis * separation * (i == 0 ? -massRatio : 1 - massRatio);
stars.Add(new(i, $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} Star {i + 1}", position,
UniverseScale.SolarRadiusMeters * scale, System.Math.Pow(scale, 3.5),
StarColors[random.NextInt(0, StarColors.Length)], DeterministicRandom.Mix(systemSeed + (ulong)i + 1))
{ Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:star:{i}" });
}
var planetCount = random.NextInt(5, 10);
var planets = new List<PlanetData>(planetCount);
var orbitAu = 0.38 + random.NextDouble() * 0.48;
var planeNormal = UnitVector(ref random);
var barycentreExtent = System.Math.Max(System.Math.Abs(barycentre.X), System.Math.Max(System.Math.Abs(barycentre.Y), System.Math.Abs(barycentre.Z)));
var maximumOrbit = (CellSizeMeters * 0.47 - barycentreExtent) / UniverseScale.AstronomicalUnitMeters / 1.32;
for (var i = 0; i < planetCount; i++)
{
if (i > 0) orbitAu *= 1.38 + random.NextDouble() * 0.57;
if (orbitAu > maximumOrbit) break;
var seed = DeterministicRandom.Mix(systemSeed ^ (ulong)(i + 0x100));
var orbitNormal = (planeNormal + UnitVector(ref random) * (0.12 + random.NextDouble() * 0.48)).Normalized();
var reference = System.Math.Abs(orbitNormal.Y) < 0.9 ? new DVector3(0, 1, 0) : new DVector3(1, 0, 0);
var u = DVector3.Cross(orbitNormal, reference).Normalized();
var v = DVector3.Cross(orbitNormal, u);
var phase = random.NextDouble() * System.Math.Tau;
var eccentricity = 0.03 + random.NextDouble() * 0.28;
var orbitRadius = orbitAu * UniverseScale.AstronomicalUnitMeters;
var distance = orbitRadius * (1 - eccentricity * eccentricity) / (1 + eccentricity * System.Math.Cos(phase));
var orbitalNoise = ProceduralNoise.Sample(noiseOffset + new DVector3(i * 3.7, phase, 8.1));
var position = barycentre + (u * System.Math.Cos(phase) + v * System.Math.Sin(phase)) * distance
+ orbitNormal * (distance * orbitalNoise * 0.045);
var radius = UniverseScale.EarthRadiusMeters * (0.55 + random.NextDouble() * 1.05);
var weather = new DeterministicRandom(seed ^ 0x57454154484552UL);
var atmosphereDensity = .07 + weather.NextDouble() * 1.7;
planets.Add(new(i, $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} {RomanNumeral(i + 1)}", position,
radius, radius * (0.0045 + random.NextDouble() * 0.0065), radius * (0.018 + random.NextDouble() * 0.012),
PlanetColors[random.NextInt(0, PlanetColors.Length)], AtmosphereColors[random.NextInt(0, i == 0 ? 3 : AtmosphereColors.Length)],
orbitRadius, phase, 0, seed)
{
Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:planet:{i}",
AtmosphereDensity = atmosphereDensity,
Clouds = CloudSettings.ForAtmosphere(seed, radius * .018, atmosphereDensity, PlanetSurfaceType.Rocky)
});
}
var primaryPlanets = planets.ToArray();
var moonCount = 0;
foreach (var parent in primaryPlanets)
{
if (moonCount >= 3 || (parent.Index != 0 && random.NextDouble() > 0.30)) continue;
var moonSeed = DeterministicRandom.Mix(parent.Seed ^ 0x4D4F4F4EUL);
var moonDirection = UnitVector(ref random);
var moonRadius = parent.RadiusMeters * (0.25 + random.NextDouble() * 0.16);
var moonOrbit = parent.RadiusMeters * (6.0 + random.NextDouble() * 3.0);
var atmosphereHeight = parent.Index == 0 || random.NextDouble() < 0.4 ? moonRadius * 0.018 : 0;
planets.Add(new(planets.Count, parent.Name + " a", parent.PositionMeters + moonDirection * moonOrbit,
moonRadius, moonRadius * 0.009, atmosphereHeight, new(0.39f, 0.21f, 0.13f), new(0.16f, 0.30f, 0.82f),
moonOrbit, random.NextDouble() * System.Math.Tau, parent.HostStarIndex, moonSeed)
{
ParentPlanetIndex = parent.Index, Id = parent.Id + ":moon", AtmosphereDensity = .18,
Clouds = CloudSettings.ForAtmosphere(moonSeed, atmosphereHeight, .18, PlanetSurfaceType.Rocky)
});
moonCount++;
}
return new(coordinate, systemSeed, stars, planets);
}
public IEnumerable<StarSystemData> GenerateNeighborhood(SectorCoordinate center, int radius = 1)
{
if (radius < 0 || radius > 8)
throw new ArgumentOutOfRangeException(nameof(radius));
for (var z = -radius; z <= radius; z++)
for (var y = -radius; y <= radius; y++)
for (var x = -radius; x <= radius; x++)
if (center.TryOffset(x, y, z, out var coordinate)) yield return Generate(coordinate);
}
public ulong SeedFor(SectorCoordinate coordinate)
{
var value = DeterministicRandom.Mix(UniverseSeed ^ 0xA0761D6478BD642FUL);
value = DeterministicRandom.Mix(value ^ unchecked((ulong)coordinate.X));
value = DeterministicRandom.Mix(value ^ unchecked((ulong)coordinate.Y) ^ 0xE7037ED1A0B428DBUL);
return DeterministicRandom.Mix(value ^ unchecked((ulong)coordinate.Z) ^ 0x8EBC6AF09C88C6E3UL);
}
private static DVector3 UnitVector(ref DeterministicRandom random)
{
var y = random.NextDouble() * 2 - 1;
var phi = random.NextDouble() * System.Math.Tau;
var r = System.Math.Sqrt(1 - y * y);
return new(r * System.Math.Cos(phi), y, r * System.Math.Sin(phi));
}
private static string RomanNumeral(int number) => number switch
{
1 => "I",
2 => "II",
3 => "III",
4 => "IV",
5 => "V",
6 => "VI",
7 => "VII",
8 => "VIII",
9 => "IX",
_ => number.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
}

View File

@@ -0,0 +1,32 @@
namespace Universe.Core.Math;
/// <summary>A small double precision vector for positions measured in metres.</summary>
public readonly record struct DVector3(double X, double Y, double Z)
{
public static DVector3 Zero => new(0, 0, 0);
public static DVector3 One => new(1, 1, 1);
public double LengthSquared => X * X + Y * Y + Z * Z;
public double Length => System.Math.Sqrt(LengthSquared);
public DVector3 Normalized()
{
var length = Length;
return length > 1e-30 && double.IsFinite(length) ? this / length : Zero;
}
public static double Dot(DVector3 a, DVector3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;
public static DVector3 Cross(DVector3 a, DVector3 b) => new(
a.Y * b.Z - a.Z * b.Y,
a.Z * b.X - a.X * b.Z,
a.X * b.Y - a.Y * b.X);
public static DVector3 Lerp(DVector3 a, DVector3 b, double amount) => a + (b - a) * amount;
public static DVector3 operator +(DVector3 a, DVector3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
public static DVector3 operator -(DVector3 a, DVector3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
public static DVector3 operator -(DVector3 value) => new(-value.X, -value.Y, -value.Z);
public static DVector3 operator *(DVector3 value, double scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar);
public static DVector3 operator *(double scalar, DVector3 value) => value * scalar;
public static DVector3 operator /(DVector3 value, double scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar);
public System.Numerics.Vector3 ToVector3() => new((float)X, (float)Y, (float)Z);
}

View File

@@ -0,0 +1,110 @@
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Core.Physics;
public readonly record struct CollisionResult(UniversePosition Position, bool Collided, int PlanetIndex);
/// <summary>Continuous swept-sphere collision against the same procedural surface used for rendering.</summary>
public static class PlanetCollisionSolver
{
public static CollisionResult Sweep(
UniversePosition start,
UniversePosition end,
UniversePosition systemCenter,
IReadOnlyList<PlanetData> planets,
double cellSizeMeters,
double cameraRadiusMeters = 1.5,
int samples = 128)
{
if (samples is < 1 or > 4096) throw new ArgumentOutOfRangeException(nameof(samples));
var from = start.RelativeTo(systemCenter, cellSizeMeters);
var to = end.RelativeTo(systemCenter, cellSizeMeters);
var travel = to - from;
var bestT = double.PositiveInfinity;
var bestPosition = end;
var bestPlanet = -1;
foreach (var planet in planets)
{
// Gas giants have an optical volume, not a solid terrain boundary.
if (planet.IsGasPlanet) continue;
var center = planet.PositionMeters;
var relativeStart = from - center;
var startSurface = TerrainHeight.Sample(planet, relativeStart) + cameraRadiusMeters;
var startDistance = relativeStart.Length;
var startClearance = startDistance - startSurface;
if (startClearance < -0.05 || (startClearance <= 0 && DVector3.Dot(relativeStart, travel) <= 0))
{
var normal = relativeStart.Normalized();
if (normal == DVector3.Zero)
normal = new(0, 1, 0);
bestT = 0;
bestPosition = systemCenter.Add(center + normal * startSurface, cellSizeMeters);
bestPlanet = planet.Index;
continue;
}
var a = travel.LengthSquared;
if (a < 1e-12)
continue;
var boundingRadius = planet.MaximumTerrainRadiusMeters + cameraRadiusMeters;
var c = relativeStart.LengthSquared - boundingRadius * boundingRadius;
var b = 2 * DVector3.Dot(relativeStart, travel);
var discriminant = b * b - 4 * a * c;
if (discriminant < 0)
continue;
var root = System.Math.Sqrt(discriminant);
var enter = System.Math.Max(0, (-b - root) / (2 * a));
var exit = System.Math.Min(1, (-b + root) / (2 * a));
if (enter > exit || enter >= bestT)
continue;
var previousT = enter;
var previousClearance = ClearanceAt(planet, relativeStart + travel * previousT, cameraRadiusMeters);
for (var sample = 1; sample <= samples; sample++)
{
var t = enter + (exit - enter) * sample / samples;
var relative = relativeStart + travel * t;
var clearance = ClearanceAt(planet, relative, cameraRadiusMeters);
if (clearance <= 0 && previousClearance > 0)
{
var low = previousT;
var high = t;
for (var iteration = 0; iteration < 28; iteration++)
{
var middle = (low + high) * 0.5;
var point = relativeStart + travel * middle;
if (ClearanceAt(planet, point, cameraRadiusMeters) > 0)
low = middle;
else
high = middle;
}
var contactT = low;
var contactVector = relativeStart + travel * contactT;
var normal = contactVector.Normalized();
var terrainRadius = TerrainHeight.Sample(planet, normal) + cameraRadiusMeters;
bestT = contactT;
bestPosition = systemCenter.Add(center + normal * terrainRadius, cellSizeMeters);
bestPlanet = planet.Index;
break;
}
previousT = t;
previousClearance = clearance;
}
}
return bestPlanet >= 0
? new(bestPosition, true, bestPlanet)
: new(end, false, -1);
}
private static double ClearanceAt(PlanetData planet, DVector3 relative, double cameraRadiusMeters)
{
return relative.Length - TerrainHeight.Sample(planet, relative) - cameraRadiusMeters;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>14.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StbImageSharp" Version="2.30.16" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
namespace Universe.Core.World;
public readonly record struct SectorCoordinate(long X, long Y, long Z)
{
public bool TryOffset(long x, long y, long z, out SectorCoordinate result)
{
try
{
result = new(checked(X + x), checked(Y + y), checked(Z + z));
return true;
}
catch (OverflowException)
{
result = default;
return false;
}
}
public override string ToString() => $"({X}, {Y}, {Z})";
}

View File

@@ -0,0 +1,73 @@
using Universe.Core.Math;
namespace Universe.Core.World;
/// <summary>
/// A large-world position. The local offset is always in [0, cellSize) on each axis.
/// Keeping the sector separate avoids losing metre precision far from the origin.
/// </summary>
public readonly record struct UniversePosition(SectorCoordinate Sector, DVector3 LocalOffset)
{
public static UniversePosition AtCellCenter(SectorCoordinate sector, double cellSizeMeters)
{
ValidateCellSize(cellSizeMeters);
var half = cellSizeMeters * 0.5;
return new(sector, new(half, half, half));
}
public UniversePosition Add(DVector3 deltaMeters, double cellSizeMeters)
{
ValidateCellSize(cellSizeMeters);
var (xSector, xOffset) = NormalizeAxis(Sector.X, LocalOffset.X + deltaMeters.X, cellSizeMeters);
var (ySector, yOffset) = NormalizeAxis(Sector.Y, LocalOffset.Y + deltaMeters.Y, cellSizeMeters);
var (zSector, zOffset) = NormalizeAxis(Sector.Z, LocalOffset.Z + deltaMeters.Z, cellSizeMeters);
return new(new(xSector, ySector, zSector), new(xOffset, yOffset, zOffset));
}
/// <summary>Returns this position relative to another position, using exact integer subtraction first.</summary>
public DVector3 RelativeTo(UniversePosition origin, double cellSizeMeters)
{
ValidateCellSize(cellSizeMeters);
var dx = (Int128)Sector.X - origin.Sector.X;
var dy = (Int128)Sector.Y - origin.Sector.Y;
var dz = (Int128)Sector.Z - origin.Sector.Z;
return new(
(double)dx * cellSizeMeters + LocalOffset.X - origin.LocalOffset.X,
(double)dy * cellSizeMeters + LocalOffset.Y - origin.LocalOffset.Y,
(double)dz * cellSizeMeters + LocalOffset.Z - origin.LocalOffset.Z);
}
private static (long Sector, double Offset) NormalizeAxis(long sector, double offset, double cellSizeMeters)
{
if (!double.IsFinite(offset))
throw new ArgumentOutOfRangeException(nameof(offset), "Position offsets must be finite.");
var shiftAsDouble = System.Math.Floor(offset / cellSizeMeters);
if (shiftAsDouble < long.MinValue || shiftAsDouble > long.MaxValue)
throw new OverflowException("Movement exceeded the supported sector range.");
var shift = checked((long)shiftAsDouble);
var newSector = checked(sector + shift);
var newOffset = offset - shiftAsDouble * cellSizeMeters;
// Floating point division may leave a value infinitesimally outside the canonical range.
if (newOffset < 0)
{
newSector = checked(newSector - 1);
newOffset += cellSizeMeters;
}
else if (newOffset >= cellSizeMeters)
{
newSector = checked(newSector + 1);
newOffset -= cellSizeMeters;
}
return (newSector, newOffset);
}
private static void ValidateCellSize(double cellSizeMeters)
{
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0)
throw new ArgumentOutOfRangeException(nameof(cellSizeMeters), "Cell size must be a positive finite number.");
}
}

View File

@@ -0,0 +1,10 @@
namespace Universe.Core.World;
public static class UniverseScale
{
public const double AstronomicalUnitMeters = 149_597_870_700d;
public const double LightSpeedMetersPerSecond = 299_792_458d;
public const double SolarRadiusMeters = 695_700_000d;
public const double EarthRadiusMeters = 6_371_000d;
public const double DefaultCellSizeMeters = 100d * AstronomicalUnitMeters;
}

View File

@@ -0,0 +1,46 @@
using Universe.Core.Generation;
namespace Universe.Core.World;
public sealed record UniverseNeighborhood(StarSystemData ActiveSystem, IReadOnlyList<StarSystemData> NearbySystems);
/// <summary>Loads the system under the camera in full and exposes adjacent systems as distant stars.</summary>
public sealed class UniverseStreamer(UniverseGenerator generator)
{
private readonly Dictionary<SectorCoordinate, StarSystemData> _cache = [];
public UniverseNeighborhood Load(SectorCoordinate activeCoordinate)
{
var active = GetOrCreate(activeCoordinate);
var nearby = new List<StarSystemData>(26);
for (var z = -1; z <= 1; z++)
for (var y = -1; y <= 1; y++)
for (var x = -1; x <= 1; x++)
{
if (x == 0 && y == 0 && z == 0)
continue;
if (activeCoordinate.TryOffset(x, y, z, out var coordinate))
nearby.Add(GetOrCreate(coordinate));
}
if (_cache.Count > 128)
{
var keep = new HashSet<SectorCoordinate>(nearby.Select(static system => system.Coordinate)) { activeCoordinate };
foreach (var key in _cache.Keys.ToArray())
if (!keep.Contains(key))
_cache.Remove(key);
}
return new(active, nearby);
}
private StarSystemData GetOrCreate(SectorCoordinate coordinate)
{
if (!_cache.TryGetValue(coordinate, out var system))
{
system = generator.Generate(coordinate);
_cache.Add(coordinate, system);
}
return system;
}
}