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