init
This commit is contained in:
191
src/Universe.App/Graphics/AdaptivePlanetMesh.cs
Normal file
191
src/Universe.App/Graphics/AdaptivePlanetMesh.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
156
src/Universe.App/Graphics/BenchmarkOverlay.cs
Normal file
156
src/Universe.App/Graphics/BenchmarkOverlay.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
28
src/Universe.App/Graphics/CloudDensityTexture.cs
Normal file
28
src/Universe.App/Graphics/CloudDensityTexture.cs
Normal 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);
|
||||
}
|
||||
33
src/Universe.App/Graphics/CloudNoiseTexture.cs
Normal file
33
src/Universe.App/Graphics/CloudNoiseTexture.cs
Normal 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); }
|
||||
}
|
||||
111
src/Universe.App/Graphics/GpuMesh.cs
Normal file
111
src/Universe.App/Graphics/GpuMesh.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
139
src/Universe.App/Graphics/GpuPassProfiler.cs
Normal file
139
src/Universe.App/Graphics/GpuPassProfiler.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
121
src/Universe.App/Graphics/GraphicsDiagnostics.cs
Normal file
121
src/Universe.App/Graphics/GraphicsDiagnostics.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
352
src/Universe.App/Graphics/GraphicsReport.cs
Normal file
352
src/Universe.App/Graphics/GraphicsReport.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/Universe.App/Graphics/InstancedAsteroidMesh.cs
Normal file
40
src/Universe.App/Graphics/InstancedAsteroidMesh.cs
Normal 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); }
|
||||
}
|
||||
24
src/Universe.App/Graphics/NoiseTexture.cs
Normal file
24
src/Universe.App/Graphics/NoiseTexture.cs
Normal 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);
|
||||
}
|
||||
89
src/Universe.App/Graphics/PauseMenu.cs
Normal file
89
src/Universe.App/Graphics/PauseMenu.cs
Normal 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();
|
||||
}
|
||||
113
src/Universe.App/Graphics/PerformanceOverlay.cs
Normal file
113
src/Universe.App/Graphics/PerformanceOverlay.cs
Normal 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();
|
||||
}
|
||||
58
src/Universe.App/Graphics/PlanetTextures.cs
Normal file
58
src/Universe.App/Graphics/PlanetTextures.cs
Normal 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); }
|
||||
}
|
||||
67
src/Universe.App/Graphics/PrimitiveMeshes.cs
Normal file
67
src/Universe.App/Graphics/PrimitiveMeshes.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
59
src/Universe.App/Graphics/RenderTarget.cs
Normal file
59
src/Universe.App/Graphics/RenderTarget.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
95
src/Universe.App/Graphics/RingParticles.cs
Normal file
95
src/Universe.App/Graphics/RingParticles.cs
Normal 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); }
|
||||
}
|
||||
43
src/Universe.App/Graphics/RingProfileTexture.cs
Normal file
43
src/Universe.App/Graphics/RingProfileTexture.cs
Normal 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);
|
||||
}
|
||||
96
src/Universe.App/Graphics/ShaderProgram.cs
Normal file
96
src/Universe.App/Graphics/ShaderProgram.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/Universe.App/Graphics/ShaderSources.cs
Normal file
28
src/Universe.App/Graphics/ShaderSources.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/Universe.App/Graphics/Shaders/asteroid-shape.glsl
Normal file
62
src/Universe.App/Graphics/Shaders/asteroid-shape.glsl
Normal 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;
|
||||
}
|
||||
84
src/Universe.App/Graphics/Shaders/atmosphere.frag.glsl
Normal file
84
src/Universe.App/Graphics/Shaders/atmosphere.frag.glsl
Normal 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);
|
||||
}
|
||||
13
src/Universe.App/Graphics/Shaders/blur.frag.glsl
Normal file
13
src/Universe.App/Graphics/Shaders/blur.frag.glsl
Normal 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);
|
||||
}
|
||||
17
src/Universe.App/Graphics/Shaders/bright.frag.glsl
Normal file
17
src/Universe.App/Graphics/Shaders/bright.frag.glsl
Normal 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);
|
||||
}
|
||||
9
src/Universe.App/Graphics/Shaders/camera-ray.glsl
Normal file
9
src/Universe.App/Graphics/Shaders/camera-ray.glsl
Normal 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);
|
||||
}
|
||||
114
src/Universe.App/Graphics/Shaders/cloud-common.glsl
Normal file
114
src/Universe.App/Graphics/Shaders/cloud-common.glsl
Normal 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;
|
||||
}
|
||||
31
src/Universe.App/Graphics/Shaders/cloud-composite.frag.glsl
Normal file
31
src/Universe.App/Graphics/Shaders/cloud-composite.frag.glsl
Normal 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);
|
||||
}
|
||||
124
src/Universe.App/Graphics/Shaders/cloud.frag.glsl
Normal file
124
src/Universe.App/Graphics/Shaders/cloud.frag.glsl
Normal 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);
|
||||
}
|
||||
27
src/Universe.App/Graphics/Shaders/final.frag.glsl
Normal file
27
src/Universe.App/Graphics/Shaders/final.frag.glsl
Normal 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);
|
||||
}
|
||||
7
src/Universe.App/Graphics/Shaders/fullscreen.vert.glsl
Normal file
7
src/Universe.App/Graphics/Shaders/fullscreen.vert.glsl
Normal 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);
|
||||
}
|
||||
143
src/Universe.App/Graphics/Shaders/gas.frag.glsl
Normal file
143
src/Universe.App/Graphics/Shaders/gas.frag.glsl
Normal 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));
|
||||
}
|
||||
}
|
||||
55
src/Universe.App/Graphics/Shaders/noise.glsl
Normal file
55
src/Universe.App/Graphics/Shaders/noise.glsl
Normal 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;
|
||||
}
|
||||
208
src/Universe.App/Graphics/Shaders/ocean.frag.glsl
Normal file
208
src/Universe.App/Graphics/Shaders/ocean.frag.glsl
Normal 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);
|
||||
}
|
||||
53
src/Universe.App/Graphics/Shaders/planet-height.glsl
Normal file
53
src/Universe.App/Graphics/Shaders/planet-height.glsl
Normal 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);
|
||||
}
|
||||
10
src/Universe.App/Graphics/Shaders/point.frag.glsl
Normal file
10
src/Universe.App/Graphics/Shaders/point.frag.glsl
Normal 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);
|
||||
}
|
||||
15
src/Universe.App/Graphics/Shaders/point.vert.glsl
Normal file
15
src/Universe.App/Graphics/Shaders/point.vert.glsl
Normal 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;
|
||||
}
|
||||
17
src/Universe.App/Graphics/Shaders/ring-optics.glsl
Normal file
17
src/Universe.App/Graphics/Shaders/ring-optics.glsl
Normal 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));
|
||||
}
|
||||
54
src/Universe.App/Graphics/Shaders/ring.frag.glsl
Normal file
54
src/Universe.App/Graphics/Shaders/ring.frag.glsl
Normal 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);
|
||||
}
|
||||
16
src/Universe.App/Graphics/Shaders/ring.vert.glsl
Normal file
16
src/Universe.App/Graphics/Shaders/ring.vert.glsl
Normal 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;
|
||||
}
|
||||
37
src/Universe.App/Graphics/Shaders/rock-common.glsl
Normal file
37
src/Universe.App/Graphics/Shaders/rock-common.glsl
Normal 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;
|
||||
}
|
||||
13
src/Universe.App/Graphics/Shaders/rock-point.frag.glsl
Normal file
13
src/Universe.App/Graphics/Shaders/rock-point.frag.glsl
Normal 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);
|
||||
}
|
||||
28
src/Universe.App/Graphics/Shaders/rock-point.vert.glsl
Normal file
28
src/Universe.App/Graphics/Shaders/rock-point.vert.glsl
Normal 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;
|
||||
}
|
||||
35
src/Universe.App/Graphics/Shaders/rock.frag.glsl
Normal file
35
src/Universe.App/Graphics/Shaders/rock.frag.glsl
Normal 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);
|
||||
}
|
||||
40
src/Universe.App/Graphics/Shaders/rock.vert.glsl
Normal file
40
src/Universe.App/Graphics/Shaders/rock.vert.glsl
Normal 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;
|
||||
}
|
||||
32
src/Universe.App/Graphics/Shaders/sky.frag.glsl
Normal file
32
src/Universe.App/Graphics/Shaders/sky.frag.glsl
Normal 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);
|
||||
}
|
||||
33
src/Universe.App/Graphics/Shaders/star.frag.glsl
Normal file
33
src/Universe.App/Graphics/Shaders/star.frag.glsl
Normal 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);
|
||||
}
|
||||
17
src/Universe.App/Graphics/Shaders/star.vert.glsl
Normal file
17
src/Universe.App/Graphics/Shaders/star.vert.glsl
Normal 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;
|
||||
}
|
||||
69
src/Universe.App/Graphics/Shaders/sun-glow.frag.glsl
Normal file
69
src/Universe.App/Graphics/Shaders/sun-glow.frag.glsl
Normal 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);
|
||||
}
|
||||
190
src/Universe.App/Graphics/Shaders/terrain.frag.glsl
Normal file
190
src/Universe.App/Graphics/Shaders/terrain.frag.glsl
Normal 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);
|
||||
}
|
||||
26
src/Universe.App/Graphics/Shaders/terrain.vert.glsl
Normal file
26
src/Universe.App/Graphics/Shaders/terrain.vert.glsl
Normal 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;
|
||||
}
|
||||
80
src/Universe.App/Graphics/SurfaceMaterials.cs
Normal file
80
src/Universe.App/Graphics/SurfaceMaterials.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
1051
src/Universe.App/Graphics/UniverseRenderer.cs
Normal file
1051
src/Universe.App/Graphics/UniverseRenderer.cs
Normal file
File diff suppressed because it is too large
Load Diff
4
src/Universe.App/Program.cs
Normal file
4
src/Universe.App/Program.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
using Universe.App;
|
||||
|
||||
using var application = new UniverseApplication();
|
||||
application.Run();
|
||||
224
src/Universe.App/Runtime/BenchmarkRunner.cs
Normal file
224
src/Universe.App/Runtime/BenchmarkRunner.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
379
src/Universe.App/Runtime/FlyCamera.cs
Normal file
379
src/Universe.App/Runtime/FlyCamera.cs
Normal 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);
|
||||
}
|
||||
136
src/Universe.App/Runtime/PlayerController.cs
Normal file
136
src/Universe.App/Runtime/PlayerController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/Universe.App/Universe.App.csproj
Normal file
28
src/Universe.App/Universe.App.csproj
Normal 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>
|
||||
406
src/Universe.App/UniverseApplication.cs
Normal file
406
src/Universe.App/UniverseApplication.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
64
src/Universe.Core/Configuration/EngineConfiguration.cs
Normal file
64
src/Universe.Core/Configuration/EngineConfiguration.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Globalization;
|
||||
using Universe.Core.World;
|
||||
using Universe.Core.Generation;
|
||||
|
||||
namespace Universe.Core.Configuration;
|
||||
|
||||
public sealed record StartupConfiguration
|
||||
{
|
||||
public string SystemId { get; init; } = "sol";
|
||||
public string PlanetId { get; init; } = "earth";
|
||||
/// <summary>Planet ids assigned to number keys 1 through 9, in key order.</summary>
|
||||
public List<string> PlanetHotkeys { get; init; } = [];
|
||||
public double DistanceInRadii { get; init; } = 3;
|
||||
public double LongitudeDegrees { get; init; } = 20;
|
||||
public double LatitudeDegrees { get; init; } = 20;
|
||||
public double SpeedMetersPerSecond { get; init; } = 200_000;
|
||||
public double FieldOfViewDegrees { get; init; } = 67.65;
|
||||
}
|
||||
|
||||
public sealed record EngineConfiguration
|
||||
{
|
||||
public string UniverseSeed { get; init; } = "0x554E495645525345";
|
||||
public double CellSizeAu { get; init; } = 100;
|
||||
public string KnownSolarSystemsFile { get; init; } = "KnownSolarSystem/solar-systems.json";
|
||||
public StartupConfiguration Startup { get; init; } = new();
|
||||
public double CellSizeMeters => CellSizeAu * UniverseScale.AstronomicalUnitMeters;
|
||||
public ulong Seed => UniverseSeed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? ulong.Parse(UniverseSeed.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture)
|
||||
: ulong.Parse(UniverseSeed, CultureInfo.InvariantCulture);
|
||||
|
||||
public (StarSystemData System, PlanetData Planet) ResolveStartup(KnownSolarSystemCatalog catalog)
|
||||
{
|
||||
var system = catalog.GetById(Startup.SystemId);
|
||||
var planet = system.Planets.FirstOrDefault(p => p.Id == Startup.PlanetId)
|
||||
?? throw new InvalidDataException($"Startup planet '{Startup.PlanetId}' does not exist in system '{system.Id}'.");
|
||||
foreach (var hotkeyPlanetId in Startup.PlanetHotkeys)
|
||||
if (!system.Planets.Any(p => string.Equals(p.Id, hotkeyPlanetId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidDataException($"Planet hotkey target '{hotkeyPlanetId}' does not exist in startup system '{system.Id}'.");
|
||||
return (system, planet);
|
||||
}
|
||||
|
||||
public static EngineConfiguration Load(string path)
|
||||
{
|
||||
var settings = JsonSettings.Read<EngineConfiguration>(path);
|
||||
if (string.IsNullOrWhiteSpace(settings.UniverseSeed)) throw new InvalidDataException("config.json: universeSeed cannot be empty.");
|
||||
try { _ = settings.Seed; }
|
||||
catch (Exception error) when (error is FormatException or OverflowException or ArgumentNullException)
|
||||
{ throw new InvalidDataException("config.json: universeSeed must be an unsigned integer string, decimal or 0x-prefixed hexadecimal.", error); }
|
||||
if (!double.IsFinite(settings.CellSizeAu) || settings.CellSizeAu <= 0 || !double.IsFinite(settings.CellSizeMeters))
|
||||
throw new InvalidDataException("config.json: cellSizeAu must be finite and positive.");
|
||||
var startup = settings.Startup ?? throw new InvalidDataException("config.json: startup cannot be null.");
|
||||
if (string.IsNullOrWhiteSpace(startup.SystemId) || string.IsNullOrWhiteSpace(startup.PlanetId)
|
||||
|| startup.PlanetHotkeys is null || startup.PlanetHotkeys.Count > 9
|
||||
|| startup.PlanetHotkeys.Any(string.IsNullOrWhiteSpace)
|
||||
|| !double.IsFinite(startup.DistanceInRadii) || startup.DistanceInRadii < 1.05
|
||||
|| !double.IsFinite(startup.LongitudeDegrees) || !double.IsFinite(startup.LatitudeDegrees) || System.Math.Abs(startup.LatitudeDegrees) > 90
|
||||
|| !double.IsFinite(startup.SpeedMetersPerSecond) || startup.SpeedMetersPerSecond < 0.1
|
||||
|| startup.SpeedMetersPerSecond > UniverseScale.LightSpeedMetersPerSecond * 1_000_000
|
||||
|| !double.IsFinite(startup.FieldOfViewDegrees) || startup.FieldOfViewDegrees is < 30 or > 120)
|
||||
throw new InvalidDataException("config.json: invalid startup target, planet hotkeys, view angles, distance or flight speed. Planet hotkeys must contain at most nine non-empty planet ids.");
|
||||
if (string.IsNullOrWhiteSpace(settings.KnownSolarSystemsFile)) throw new InvalidDataException("config.json: knownSolarSystemsFile is required.");
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
30
src/Universe.Core/Configuration/JsonSettings.cs
Normal file
30
src/Universe.Core/Configuration/JsonSettings.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Universe.Core.Configuration;
|
||||
|
||||
internal static class JsonSettings
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public static T Read<T>(string path) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<T>(stream, Options) ?? throw new InvalidDataException($"Empty JSON file: {path}");
|
||||
}
|
||||
catch (JsonException error)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid JSON in '{path}' at {error.Path}: {error.Message}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
278
src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs
Normal file
278
src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Core.Configuration;
|
||||
|
||||
/// <summary>Explicit, validated systems override procedural generation at their assigned sectors.</summary>
|
||||
public sealed class KnownSolarSystemCatalog
|
||||
{
|
||||
private readonly Dictionary<SectorCoordinate, StarSystemData> _bySector = [];
|
||||
private readonly Dictionary<string, StarSystemData> _byId = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, RasterImage> _colors = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, HeightMap> _heights = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly string _assetDirectory;
|
||||
public IReadOnlyCollection<StarSystemData> Systems => _byId.Values;
|
||||
|
||||
private KnownSolarSystemCatalog(string directory) => _assetDirectory = directory;
|
||||
|
||||
public static KnownSolarSystemCatalog Load(string path, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters)
|
||||
{
|
||||
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0) throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var document = JsonSettings.Read<SolarSystemDocument>(fullPath);
|
||||
if (document.SchemaVersion != 1 || document.Systems is not { Count: > 0 })
|
||||
throw new InvalidDataException("Known solar systems require schemaVersion 1 and at least one system.");
|
||||
var catalog = new KnownSolarSystemCatalog(Path.GetDirectoryName(fullPath)!);
|
||||
foreach (var definition in document.Systems)
|
||||
{
|
||||
var system = catalog.Build(definition, cellSizeMeters);
|
||||
if (!catalog._byId.TryAdd(system.Id, system)) throw new InvalidDataException($"Duplicate solar system id '{system.Id}'.");
|
||||
if (!catalog._bySector.TryAdd(system.Coordinate, system)) throw new InvalidDataException($"Two known systems occupy sector {system.Coordinate}.");
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
public bool TryGet(SectorCoordinate coordinate, out StarSystemData system) => _bySector.TryGetValue(coordinate, out system!);
|
||||
public StarSystemData GetById(string id) => _byId.TryGetValue(id, out var system) ? system
|
||||
: throw new InvalidDataException($"Startup system '{id}' does not exist in KnownSolarSystem.");
|
||||
|
||||
private StarSystemData Build(SolarSystemDefinition definition, double cellSize)
|
||||
{
|
||||
ValidateId(definition.Id);
|
||||
if (string.IsNullOrWhiteSpace(definition.Name) || definition.Sector is null || definition.Stars is not { Count: >= 1 and <= 2 }
|
||||
|| definition.Planets is not { Count: > 0 }) throw new InvalidDataException($"Incomplete system '{definition.Id}' (one or two stars required).");
|
||||
var coordinate = new SectorCoordinate(definition.Sector.X, definition.Sector.Y, definition.Sector.Z);
|
||||
var offset = Vector(definition.CenterOffsetMeters);
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
var stars = new List<StarData>();
|
||||
foreach (var star in definition.Stars)
|
||||
{
|
||||
AddId(ids, star.Id); Positive(star.RadiusMeters, "star radius"); Positive(star.LuminositySolar, "luminosity");
|
||||
if (string.IsNullOrWhiteSpace(star.Name)) throw new InvalidDataException($"Missing name for '{star.Id}'.");
|
||||
VisualSettingsValidator.StarGlow(star.Glow, star.Id);
|
||||
stars.Add(new(stars.Count, star.Name, offset + Vector(star.PositionMeters), star.RadiusMeters, star.LuminositySolar,
|
||||
Color(star.Color), star.Seed ?? StableSeed(definition.Id + "/" + star.Id))
|
||||
{ Id = star.Id, Glow = star.Glow });
|
||||
}
|
||||
var planets = new List<PlanetData>();
|
||||
foreach (var planet in definition.Planets) planets.Add(BuildPlanet(planet, definition.Id, stars, planets.Count, null, ids));
|
||||
for (var i = 0; i < definition.Planets.Count; i++)
|
||||
{
|
||||
var parent = planets[i];
|
||||
foreach (var moon in definition.Planets[i].Moons ?? throw new InvalidDataException("moons cannot be null."))
|
||||
{
|
||||
if (moon.Moons is { Count: > 0 }) throw new InvalidDataException("Nested satellites are not supported.");
|
||||
var body = BuildPlanet(moon, definition.Id, stars, planets.Count, parent, ids);
|
||||
if ((body.PositionMeters - parent.PositionMeters).Length <= body.MaximumTerrainRadiusMeters + parent.MaximumTerrainRadiusMeters)
|
||||
throw new InvalidDataException($"Moon '{body.Id}' overlaps its parent.");
|
||||
planets.Add(body);
|
||||
}
|
||||
}
|
||||
if (planets.Count > 16) throw new InvalidDataException($"'{definition.Id}' exceeds the current limit of 16 planets and moons.");
|
||||
foreach (var star in stars) CheckBounds(star.PositionMeters, star.RadiusMeters, cellSize, star.Id);
|
||||
foreach (var planet in planets)
|
||||
{
|
||||
CheckBounds(planet.PositionMeters, System.Math.Max(planet.MaximumTerrainRadiusMeters, planet.Rings?.OuterRadiusMeters ?? 0), cellSize, planet.Id);
|
||||
if (stars.Any(s => (s.PositionMeters - planet.PositionMeters).Length <= s.RadiusMeters + planet.MaximumTerrainRadiusMeters))
|
||||
throw new InvalidDataException($"Planet '{planet.Id}' overlaps a star.");
|
||||
}
|
||||
return new(coordinate, definition.Seed ?? StableSeed(definition.Id), stars, planets) { Id = definition.Id, Name = definition.Name };
|
||||
}
|
||||
|
||||
private PlanetData BuildPlanet(PlanetDefinition p, string systemId, List<StarData> stars, int index, PlanetData? parent, HashSet<string> ids)
|
||||
{
|
||||
AddId(ids, p.Id); Positive(p.RadiusMeters, "planet radius");
|
||||
var terrain = p.Terrain ?? throw new InvalidDataException($"Missing terrain for '{p.Id}'.");
|
||||
var air = p.Atmosphere ?? throw new InvalidDataException($"Missing atmosphere for '{p.Id}'.");
|
||||
if (string.IsNullOrWhiteSpace(p.Name) || p.Orbit is null || !Enum.IsDefined(p.SurfaceType) || !Enum.IsDefined(terrain.NoiseMask))
|
||||
throw new InvalidDataException($"Invalid planet '{p.Id}'.");
|
||||
var host = parent is null ? stars.FindIndex(s => s.Id == p.HostStarId) : parent.HostStarIndex;
|
||||
if (!Finite(p.CloudBandFrequency, p.StormStrength) || p.CloudBandFrequency <= 0 || p.StormStrength is < 0 or > 1)
|
||||
throw new InvalidDataException($"Invalid cloud bands or storm strength on '{p.Id}'.");
|
||||
if (host < 0) throw new InvalidDataException($"Unknown hostStarId '{p.HostStarId}' on '{p.Id}'.");
|
||||
Positive(terrain.ProceduralNoiseFrequency, "noise frequency"); Positive(terrain.HeightmapGamma, "heightmap gamma"); Positive(terrain.LandBlend, "land blend");
|
||||
if (!Finite(terrain.HeightmapMinMeters, terrain.HeightmapMaxMeters, terrain.LandThreshold, p.AxialTiltDegrees, p.RotationDegrees,
|
||||
terrain.ProceduralNoiseAmplitudeMeters, air.HeightMeters, air.Density)
|
||||
|| terrain.HeightmapMinMeters > terrain.HeightmapMaxMeters || terrain.ProceduralNoiseAmplitudeMeters < 0
|
||||
|| terrain.ProceduralNoiseFrequency > 512 || air.HeightMeters < 0 || air.Density < 0 || p.AxialTiltDegrees is < 0 or > 180)
|
||||
throw new InvalidDataException($"Invalid terrain, atmosphere or orientation on '{p.Id}'.");
|
||||
var heightmap = terrain.Heightmap is null ? null : LoadHeight(terrain.Heightmap);
|
||||
var region = terrain.RegionalHeight is null ? null : LoadRegionalHeight(terrain.RegionalHeight);
|
||||
var materials = p.Materials ?? throw new InvalidDataException($"'{p.Id}': materials cannot be null.");
|
||||
if (!Finite(materials.ScaleMeters, materials.FadeDistanceMeters, materials.Strength) || materials.ScaleMeters <= 0
|
||||
|| materials.FadeDistanceMeters <= 0 || materials.Strength is < 0 or > 1)
|
||||
throw new InvalidDataException($"'{p.Id}': invalid material settings.");
|
||||
VisualSettingsValidator.Gas(p.Gas, p.Id);
|
||||
if (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant && (heightmap is not null || region is not null || terrain.ProceduralNoiseAmplitudeMeters != 0))
|
||||
throw new InvalidDataException($"'{p.Id}': gas planets use gas volume settings, not a terrain heightmap or terrain amplitude.");
|
||||
if (terrain.NoiseMask == TerrainNoiseMask.HeightmapLand && heightmap is null)
|
||||
throw new InvalidDataException($"'{p.Id}': heightmapLand noise mask requires a heightmap.");
|
||||
var position = (parent?.PositionMeters ?? stars[host].PositionMeters) + OrbitPosition(p.Orbit);
|
||||
var surfaceTexture = p.SurfaceTexture is null ? null : LoadColor(p.SurfaceTexture);
|
||||
var planetSeed = p.Seed ?? StableSeed(systemId + "/" + p.Id);
|
||||
var clouds = p.Clouds ?? CloudSettings.ForAtmosphere(planetSeed, air.HeightMeters, air.Density, p.SurfaceType);
|
||||
if (clouds is not null) VisualSettingsValidator.Clouds(clouds, p.Id, air.HeightMeters, air.Density, p.SurfaceType);
|
||||
var cloudDensityMap = clouds?.DensityTexturePath is { Length: > 0 } cloudPath ? LoadContentDensityMap(cloudPath) : null;
|
||||
if (p.Ocean is { } ocean)
|
||||
{
|
||||
VisualSettingsValidator.Ocean(ocean, p.Id);
|
||||
if (p.RadiusMeters + ocean.SeaLevelMeters - ocean.DepthMeters <= 0)
|
||||
throw new InvalidDataException($"'{p.Id}': ocean floor must remain outside the planet centre.");
|
||||
if (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant)
|
||||
throw new InvalidDataException($"'{p.Id}': an ocean requires a solid planet.");
|
||||
if (ocean.Enabled && ocean.MaskFromSurfaceTexture && surfaceTexture is null)
|
||||
throw new InvalidDataException($"'{p.Id}': ocean.maskFromSurfaceTexture requires surfaceTexture.");
|
||||
}
|
||||
var result = new PlanetData(index, p.Name, position, p.RadiusMeters, terrain.ProceduralNoiseAmplitudeMeters,
|
||||
air.HeightMeters, Color(p.SurfaceColor), Color(air.Color), p.Orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters,
|
||||
p.Orbit.PhaseDegrees * System.Math.PI / 180, host, p.Seed ?? StableSeed(systemId + "/" + p.Id))
|
||||
{
|
||||
Id = p.Id,
|
||||
Description = p.Description,
|
||||
ParentPlanetIndex = parent?.Index,
|
||||
SurfaceType = p.SurfaceType,
|
||||
Gas = p.Gas,
|
||||
CloudBandFrequency = p.CloudBandFrequency,
|
||||
StormStrength = p.StormStrength,
|
||||
SurfaceTexture = surfaceTexture,
|
||||
Materials = materials,
|
||||
Ocean = p.Ocean,
|
||||
Clouds = clouds,
|
||||
CloudDensityMap = cloudDensityMap,
|
||||
OceanMask = p.Ocean is { Enabled: true, MaskFromSurfaceTexture: true } ? OceanSurface.CreateMask(surfaceTexture!) : null,
|
||||
AtmosphereDensity = air.Density,
|
||||
AtmosphereComposition = air.Composition,
|
||||
Orientation = new(p.AxialTiltDegrees, p.RotationDegrees),
|
||||
Terrain = new()
|
||||
{
|
||||
HeightMap = heightmap,
|
||||
RegionalHeight = region,
|
||||
HeightmapMinMeters = terrain.HeightmapMinMeters,
|
||||
HeightmapMaxMeters = terrain.HeightmapMaxMeters,
|
||||
HeightmapGamma = terrain.HeightmapGamma,
|
||||
NoiseFrequency = terrain.ProceduralNoiseFrequency,
|
||||
NoiseMask = terrain.NoiseMask,
|
||||
LandThreshold = terrain.LandThreshold,
|
||||
LandBlend = terrain.LandBlend
|
||||
}
|
||||
};
|
||||
if (p.Rings is { } rings)
|
||||
{
|
||||
Positive(rings.InnerRadiusMeters, "ring inner radius"); Positive(rings.OuterRadiusMeters, "ring outer radius");
|
||||
if (rings.InnerRadiusMeters <= result.MaximumTerrainRadiusMeters || rings.OuterRadiusMeters <= rings.InnerRadiusMeters
|
||||
|| !Finite(rings.Opacity, rings.GapInnerRadiusMeters, rings.GapOuterRadiusMeters) || rings.Opacity is < 0 or > 1
|
||||
|| rings.GapInnerRadiusMeters < 0 || rings.GapOuterRadiusMeters < rings.GapInnerRadiusMeters)
|
||||
throw new InvalidDataException($"Invalid rings on '{p.Id}'.");
|
||||
result = result with
|
||||
{
|
||||
Rings = new(rings.InnerRadiusMeters, rings.OuterRadiusMeters, Color(rings.Color), rings.Opacity,
|
||||
rings.GapInnerRadiusMeters, rings.GapOuterRadiusMeters)
|
||||
{
|
||||
Seed = rings.Seed ?? StableSeed(systemId + "/" + p.Id + "/rings"),
|
||||
ThicknessMeters = rings.ThicknessMeters,
|
||||
Appearance = rings.Appearance,
|
||||
Bands = rings.Bands,
|
||||
Gaps = rings.Gaps,
|
||||
Rocks = rings.Rocks,
|
||||
Preview = rings.Preview
|
||||
}
|
||||
};
|
||||
VisualSettingsValidator.Rings(result.Rings, p.Id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DVector3 OrbitPosition(OrbitDefinition orbit)
|
||||
{
|
||||
Positive(orbit.SemiMajorAxisAu, "semi-major axis");
|
||||
if (!Finite(orbit.Eccentricity, orbit.PhaseDegrees, orbit.InclinationDegrees, orbit.AscendingNodeDegrees, orbit.PeriapsisDegrees)
|
||||
|| orbit.Eccentricity is < 0 or >= 1) throw new InvalidDataException("Only finite elliptical orbits with eccentricity in [0,1) are supported.");
|
||||
var phase = orbit.PhaseDegrees * System.Math.PI / 180;
|
||||
var distance = orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters * (1 - orbit.Eccentricity * orbit.Eccentricity)
|
||||
/ (1 + orbit.Eccentricity * System.Math.Cos(phase));
|
||||
var angle = phase + orbit.PeriapsisDegrees * System.Math.PI / 180;
|
||||
var inclination = orbit.InclinationDegrees * System.Math.PI / 180;
|
||||
var node = orbit.AscendingNodeDegrees * System.Math.PI / 180;
|
||||
var x = System.Math.Cos(angle) * distance;
|
||||
var z = System.Math.Sin(angle) * distance;
|
||||
var y = z * System.Math.Sin(inclination);
|
||||
z *= System.Math.Cos(inclination);
|
||||
return new(x * System.Math.Cos(node) - z * System.Math.Sin(node), y, x * System.Math.Sin(node) + z * System.Math.Cos(node));
|
||||
}
|
||||
|
||||
private string AssetPath(string relative)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new InvalidDataException("Asset paths must be relative to KnownSolarSystem.");
|
||||
var path = Path.GetFullPath(Path.Combine(_assetDirectory, relative));
|
||||
if (!path.StartsWith(_assetDirectory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Asset paths must stay inside KnownSolarSystem.");
|
||||
if (!File.Exists(path)) throw new FileNotFoundException($"KnownSolarSystem asset not found: {relative}", path);
|
||||
return path;
|
||||
}
|
||||
private CloudDensityMap LoadContentDensityMap(string relative)
|
||||
{
|
||||
var content = Path.GetFullPath(Path.Combine(_assetDirectory, "..", "Content"));
|
||||
if (Path.IsPathRooted(relative)) throw new InvalidDataException("Cloud density texture paths must be relative to Content.");
|
||||
var path = Path.GetFullPath(Path.Combine(content, relative));
|
||||
if (!path.StartsWith(content.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Cloud density texture paths must remain inside Content.");
|
||||
if (!File.Exists(path)) throw new FileNotFoundException($"Cloud density texture not found: {relative}", path);
|
||||
return CloudDensityMap.Load(path);
|
||||
}
|
||||
private RasterImage LoadColor(string path)
|
||||
{
|
||||
var full = AssetPath(path);
|
||||
if (!_colors.TryGetValue(full, out var image)) _colors.Add(full, image = RasterImage.Load(full));
|
||||
return image;
|
||||
}
|
||||
private RegionalHeightMap LoadRegionalHeight(RegionalHeightDefinition definition)
|
||||
{
|
||||
if (definition.Width is < 2 or > 16384 || definition.Height is < 2 or > 16384
|
||||
|| !Finite(definition.WestDegrees, definition.EastDegrees, definition.NorthDegrees, definition.SouthDegrees, definition.FadeDegrees)
|
||||
|| definition.WestDegrees < -180 || definition.EastDegrees > 180 || definition.SouthDegrees < -90 || definition.NorthDegrees > 90
|
||||
|| definition.EastDegrees <= definition.WestDegrees || definition.NorthDegrees <= definition.SouthDegrees || definition.FadeDegrees <= 0)
|
||||
throw new InvalidDataException("Invalid regional heightmap bounds or dimensions.");
|
||||
var content = Path.GetFullPath(Path.Combine(_assetDirectory, "..", "Content"));
|
||||
if (string.IsNullOrWhiteSpace(definition.File) || Path.IsPathRooted(definition.File)) throw new InvalidDataException("Regional heightmap path must be relative to Content.");
|
||||
var path = Path.GetFullPath(Path.Combine(content, definition.File));
|
||||
if (!path.StartsWith(content.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Regional heightmap path must remain inside Content.");
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
if (bytes.Length != checked(definition.Width * definition.Height * 4) || !BitConverter.IsLittleEndian)
|
||||
throw new InvalidDataException("Regional heightmap must be little-endian float32 with the declared dimensions.");
|
||||
var values = new float[definition.Width * definition.Height];
|
||||
Buffer.BlockCopy(bytes, 0, values, 0, bytes.Length);
|
||||
if (values.Any(v => !float.IsFinite(v) || v < -12000 || v > 12000)) throw new InvalidDataException("Invalid regional elevation values.");
|
||||
return new() { Width = definition.Width, Height = definition.Height, Meters = values, MaximumMeters = values.Max(), MinimumMeters = values.Min(),
|
||||
WestDegrees = definition.WestDegrees, SouthDegrees = definition.SouthDegrees, EastDegrees = definition.EastDegrees,
|
||||
NorthDegrees = definition.NorthDegrees, FadeDegrees = definition.FadeDegrees };
|
||||
}
|
||||
private HeightMap LoadHeight(string path)
|
||||
{
|
||||
var full = AssetPath(path);
|
||||
if (!_heights.TryGetValue(full, out var image)) _heights.Add(full, image = HeightMap.Load(full));
|
||||
return image;
|
||||
}
|
||||
private static ulong StableSeed(string value)
|
||||
{
|
||||
var hash = 14695981039346656037UL;
|
||||
foreach (var b in Encoding.UTF8.GetBytes(value)) hash = unchecked((hash ^ b) * 1099511628211UL);
|
||||
return DeterministicRandom.Mix(hash);
|
||||
}
|
||||
private static DVector3 Vector(double[] values) => values is { Length: 3 } && Finite(values)
|
||||
? new(values[0], values[1], values[2]) : throw new InvalidDataException("Position vectors require three finite numbers.");
|
||||
private static Vector3 Color(float[] values) => values is { Length: 3 } && values.All(v => float.IsFinite(v) && v is >= 0 and <= 1)
|
||||
? new(values[0], values[1], values[2]) : throw new InvalidDataException("Colors require three numbers in [0,1].");
|
||||
private static bool Finite(params double[] values) => values.All(double.IsFinite);
|
||||
private static void Positive(double value, string label)
|
||||
{ if (!double.IsFinite(value) || value <= 0) throw new InvalidDataException($"{label} must be finite and positive."); }
|
||||
private static void ValidateId(string id)
|
||||
{ if (string.IsNullOrWhiteSpace(id) || !id.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) throw new InvalidDataException($"Invalid id '{id}'."); }
|
||||
private static void AddId(HashSet<string> ids, string id)
|
||||
{ ValidateId(id); if (!ids.Add(id)) throw new InvalidDataException($"Duplicate body id '{id}'."); }
|
||||
private static void CheckBounds(DVector3 p, double extent, double cell, string id)
|
||||
{ if (System.Math.Max(System.Math.Abs(p.X), System.Math.Max(System.Math.Abs(p.Y), System.Math.Abs(p.Z))) + extent >= cell / 2) throw new InvalidDataException($"'{id}' does not fit inside its configured sector; increase cellSizeAu."); }
|
||||
}
|
||||
123
src/Universe.Core/Configuration/SolarSystemDefinitions.cs
Normal file
123
src/Universe.Core/Configuration/SolarSystemDefinitions.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Universe.Core.Generation;
|
||||
|
||||
namespace Universe.Core.Configuration;
|
||||
|
||||
public sealed record SolarSystemDocument
|
||||
{
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string Notes { get; init; } = string.Empty;
|
||||
public required List<SolarSystemDefinition> Systems { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SectorDefinition(long X, long Y, long Z);
|
||||
|
||||
public sealed record SolarSystemDefinition
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required SectorDefinition Sector { get; init; }
|
||||
public ulong? Seed { get; init; }
|
||||
public double[] CenterOffsetMeters { get; init; } = [0, 0, 0];
|
||||
public required List<StarDefinition> Stars { get; init; }
|
||||
public required List<PlanetDefinition> Planets { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StarDefinition
|
||||
{
|
||||
public StarGlowSettings Glow { get; init; } = new();
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public double[] PositionMeters { get; init; } = [0, 0, 0];
|
||||
public required double RadiusMeters { get; init; }
|
||||
public double LuminositySolar { get; init; } = 1;
|
||||
public float[] Color { get; init; } = [1, 0.94f, 0.85f];
|
||||
public ulong? Seed { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OrbitDefinition
|
||||
{
|
||||
public required double SemiMajorAxisAu { get; init; }
|
||||
public double Eccentricity { get; init; }
|
||||
public double PhaseDegrees { get; init; }
|
||||
public double InclinationDegrees { get; init; }
|
||||
public double AscendingNodeDegrees { get; init; }
|
||||
public double PeriapsisDegrees { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AtmosphereDefinition
|
||||
{
|
||||
public double HeightMeters { get; init; }
|
||||
public double Density { get; init; } = 1;
|
||||
public float[] Color { get; init; } = [0.13f, 0.32f, 1];
|
||||
public string Composition { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record TerrainDefinition
|
||||
{
|
||||
public string? Heightmap { get; init; }
|
||||
public RegionalHeightDefinition? RegionalHeight { get; init; }
|
||||
public double HeightmapMinMeters { get; init; }
|
||||
public double HeightmapMaxMeters { get; init; } = 8848;
|
||||
public double HeightmapGamma { get; init; } = 1;
|
||||
public double ProceduralNoiseAmplitudeMeters { get; init; }
|
||||
public double ProceduralNoiseFrequency { get; init; } = 1;
|
||||
public TerrainNoiseMask NoiseMask { get; init; }
|
||||
public double LandThreshold { get; init; } = 0.02;
|
||||
public double LandBlend { get; init; } = 0.04;
|
||||
}
|
||||
|
||||
public sealed record RegionalHeightDefinition
|
||||
{
|
||||
public required string File { get; init; }
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public double WestDegrees { get; init; }
|
||||
public double SouthDegrees { get; init; }
|
||||
public double EastDegrees { get; init; }
|
||||
public double NorthDegrees { get; init; }
|
||||
public double FadeDegrees { get; init; } = .04;
|
||||
}
|
||||
|
||||
public sealed record RingsDefinition
|
||||
{
|
||||
public ulong? Seed { get; init; }
|
||||
public double ThicknessMeters { get; init; } = 40;
|
||||
public RingAppearanceSettings Appearance { get; init; } = new();
|
||||
public RingBandSettings[] Bands { get; init; } = [];
|
||||
public RingGapSettings[] Gaps { get; init; } = [];
|
||||
public RingRockSettings Rocks { get; init; } = new();
|
||||
public RingPreviewSettings Preview { get; init; } = new();
|
||||
public required double InnerRadiusMeters { get; init; }
|
||||
public required double OuterRadiusMeters { get; init; }
|
||||
public float[] Color { get; init; } = [0.65f, 0.58f, 0.43f];
|
||||
public double Opacity { get; init; } = 0.75;
|
||||
public double GapInnerRadiusMeters { get; init; }
|
||||
public double GapOuterRadiusMeters { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PlanetDefinition
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public string HostStarId { get; init; } = "sun";
|
||||
public required double RadiusMeters { get; init; }
|
||||
public required OrbitDefinition Orbit { get; init; }
|
||||
public ulong? Seed { get; init; }
|
||||
public double AxialTiltDegrees { get; init; }
|
||||
public double RotationDegrees { get; init; }
|
||||
public PlanetSurfaceType SurfaceType { get; init; }
|
||||
public GasVolumeSettings Gas { get; init; } = new();
|
||||
public double CloudBandFrequency { get; init; } = 28;
|
||||
public double StormStrength { get; init; }
|
||||
public float[] SurfaceColor { get; init; } = [0.38f, 0.30f, 0.22f];
|
||||
public string? SurfaceTexture { get; init; }
|
||||
public TerrainMaterialSettings Materials { get; init; } = new();
|
||||
public OceanSettings? Ocean { get; init; }
|
||||
public CloudSettings? Clouds { get; init; }
|
||||
public TerrainDefinition Terrain { get; init; } = new();
|
||||
public AtmosphereDefinition Atmosphere { get; init; } = new();
|
||||
public RingsDefinition? Rings { get; init; }
|
||||
public List<PlanetDefinition> Moons { get; init; } = [];
|
||||
}
|
||||
135
src/Universe.Core/Configuration/VisualSettingsValidator.cs
Normal file
135
src/Universe.Core/Configuration/VisualSettingsValidator.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Universe.Core.Generation;
|
||||
|
||||
namespace Universe.Core.Configuration;
|
||||
|
||||
internal static class VisualSettingsValidator
|
||||
{
|
||||
public static void Clouds(CloudSettings c, string id, double atmosphereHeight, double atmosphereDensity, PlanetSurfaceType type)
|
||||
{
|
||||
Range(c.BaseAltitudeMeters, 0, 1000000, id, "clouds.baseAltitudeMeters");
|
||||
Range(c.ThicknessMeters, 100, 1000000, id, "clouds.thicknessMeters");
|
||||
Range(c.Coverage, 0, 1, id, "clouds.coverage"); Range(c.Density, 0, 10, id, "clouds.density");
|
||||
Range(c.ShapeScaleMeters, 1000, 1000000, id, "clouds.shapeScaleMeters");
|
||||
Range(c.DetailScaleMeters, 100, c.ShapeScaleMeters, id, "clouds.detailScaleMeters");
|
||||
Range(c.Erosion, 0, .9, id, "clouds.erosion"); Range(c.CloudType, 0, 1, id, "clouds.cloudType");
|
||||
Range(c.WindSpeedMetersPerSecond, 0, 1000, id, "clouds.windSpeedMetersPerSecond");
|
||||
Range(c.WindDirectionDegrees, -360, 360, id, "clouds.windDirectionDegrees");
|
||||
Range(c.ExtinctionPerMeter, .000001, .1, id, "clouds.extinctionPerMeter");
|
||||
Range(c.ScatteringAlbedo, 0, 1, id, "clouds.scatteringAlbedo"); Range(c.Anisotropy, 0, .9, id, "clouds.anisotropy");
|
||||
Range(c.SilverLiningStrength, 0, 3, id, "clouds.silverLiningStrength");
|
||||
Range(c.AmbientStrength, 0, 3, id, "clouds.ambientStrength"); Range(c.SunlightStrength, 0, 10, id, "clouds.sunlightStrength");
|
||||
Range(c.SunsetStrength, 0, 5, id, "clouds.sunsetStrength"); Range(c.ShadowStrength, 0, 1, id, "clouds.shadowStrength");
|
||||
Range(c.ViewSteps, 16, 192, id, "clouds.viewSteps"); Range(c.LightSteps, 2, 16, id, "clouds.lightSteps");
|
||||
Range(c.ResolutionScale, .25, 1, id, "clouds.resolutionScale");
|
||||
Range(c.MaxViewDistanceMeters, 10000, 2000000, id, "clouds.maxViewDistanceMeters");
|
||||
Range(c.DensityTextureStrength, 0, 1, id, "clouds.densityTextureStrength");
|
||||
if (c.DensityTexturePath is not null && string.IsNullOrWhiteSpace(c.DensityTexturePath))
|
||||
throw new InvalidDataException($"'{id}': clouds.densityTexturePath cannot be blank; omit it to use procedural coverage.");
|
||||
if (c.Enabled && (atmosphereDensity <= 0 || c.BaseAltitudeMeters + c.ThicknessMeters > atmosphereHeight
|
||||
|| type is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant))
|
||||
throw new InvalidDataException($"'{id}': enabled clouds require a solid planet with an atmosphere containing the entire cloud layer.");
|
||||
}
|
||||
public static void Ocean(OceanSettings o, string id)
|
||||
{
|
||||
Range(o.SeaLevelMeters, -100000, 100000, id, "ocean.seaLevelMeters");
|
||||
Range(o.DepthMeters, 1, 100000, id, "ocean.depthMeters");
|
||||
Range(o.WaveAmplitudeMeters, 0, 30, id, "ocean.waveAmplitudeMeters");
|
||||
Range(o.WaveLengthMeters, 10, 2000, id, "ocean.waveLengthMeters");
|
||||
Range(o.WindDirectionDegrees, -360, 360, id, "ocean.windDirectionDegrees");
|
||||
Range(o.WaveSpeed, 0, 10, id, "ocean.waveSpeed"); Range(o.Choppiness, 0, .9, id, "ocean.choppiness");
|
||||
Range(o.Roughness, .04, 1, id, "ocean.roughness"); Range(o.ReflectionStrength, 0, 3, id, "ocean.reflectionStrength");
|
||||
Range(o.RefractionStrength, 0, .1, id, "ocean.refractionStrength"); Range(o.FoamStrength, 0, 3, id, "ocean.foamStrength");
|
||||
Color(o.DeepColor, id); Color(o.ShallowColor, id); Color(o.Absorption, id);
|
||||
}
|
||||
public static void StarGlow(StarGlowSettings g, string id)
|
||||
{
|
||||
if (g is null) throw new InvalidDataException($"'{id}': glow cannot be null.");
|
||||
Range(g.Intensity, 0, 50, id, "glow.intensity"); Range(g.ReferenceDistanceAu, .001, 1000, id, "glow.referenceDistanceAu");
|
||||
Range(g.DistanceFalloff, .1, 4, id, "glow.distanceFalloff"); Range(g.ApparentRadiusMultiplier, 1, 10, id, "glow.apparentRadiusMultiplier");
|
||||
Range(g.MinimumCorePixels, .1, 10, id, "glow.minimumCorePixels");
|
||||
Range(g.HaloRadiusDegrees, .01, 30, id, "glow.haloRadiusDegrees"); Range(g.HaloStrength, 0, 10, id, "glow.haloStrength");
|
||||
Range(g.RayCount, 2, 32, id, "glow.rayCount"); Range(g.RayLengthDegrees, .01, 45, id, "glow.rayLengthDegrees");
|
||||
Range(g.RaySharpness, 1, 500, id, "glow.raySharpness"); Range(g.RayStrength, 0, 20, id, "glow.rayStrength");
|
||||
Range(g.GhostStrength, 0, 5, id, "glow.ghostStrength"); Range(g.GhostSpacing, .05, 2, id, "glow.ghostSpacing");
|
||||
Range(g.ChromaticAberration, 0, 1, id, "glow.chromaticAberration");
|
||||
}
|
||||
public static void Gas(GasVolumeSettings g, string id)
|
||||
{
|
||||
if (g is null) throw new InvalidDataException($"'{id}': gas settings cannot be null.");
|
||||
Range(g.OuterRadiusScale, 1.0001, 1.2, id, "gas.outerRadiusScale"); Range(g.PolarRadiusScale, 0.5, 1.2, id, "gas.polarRadiusScale");
|
||||
Range(g.Density, 0.1, 10000, id, "gas.density"); Range(g.ScaleHeight, 0.0001, 0.2, id, "gas.scaleHeight");
|
||||
Range(g.ViewSteps, 16, 192, id, "gas.viewSteps"); Range(g.LightSteps, 2, 16, id, "gas.lightSteps");
|
||||
Range(g.SampleDistribution, 1, 5, id, "gas.sampleDistribution"); Range(g.StopTransmittance, 0.0001, 0.1, id, "gas.stopTransmittance");
|
||||
Range(g.ScatteringAlbedo, 0, 1, id, "gas.scatteringAlbedo"); Range(g.Anisotropy, -0.9, 0.9, id, "gas.anisotropy");
|
||||
Range(g.LightExtinction, 0.01, 2, id, "gas.lightExtinction");
|
||||
Range(g.SunlightStrength, 0, 50, id, "gas.sunlightStrength"); Range(g.AmbientStrength, 0, 2, id, "gas.ambientStrength");
|
||||
Range(g.BandContrast, 0, 1, id, "gas.bandContrast"); Range(g.FineBandFrequency, 1, 2000, id, "gas.fineBandFrequency");
|
||||
Range(g.FineBandContrast, 0, 1, id, "gas.fineBandContrast"); Range(g.CloudTurbulence, 0, 2, id, "gas.cloudTurbulence");
|
||||
Range(g.DetailStrength, 0, 1, id, "gas.detailStrength"); Range(g.NoiseScale, 1, 1000, id, "gas.noiseScale"); Range(g.WindSpeed, -10, 10, id, "gas.windSpeed");
|
||||
if (g.BandColor is not null) Color(g.BandColor, id); if (g.PolarColor is not null) Color(g.PolarColor, id); if (g.HazeColor is not null) Color(g.HazeColor, id);
|
||||
}
|
||||
|
||||
public static void Rings(PlanetRings r, string id)
|
||||
{
|
||||
if (r.Appearance is null || r.Rocks is null || r.Preview is null || r.Bands is null || r.Gaps is null)
|
||||
throw new InvalidDataException($"'{id}': ring appearance, rocks, preview, bands and gaps cannot be null.");
|
||||
Range(r.ThicknessMeters, 0.1, 100000, id, "rings.thicknessMeters");
|
||||
var a = r.Appearance;
|
||||
Range(a.ProfileResolution, 256, 65536, id, "rings.appearance.profileResolution"); Range(a.OpticalDepth, 0, 20, id, "rings.appearance.opticalDepth");
|
||||
Range(a.FineBandCount, 1, 65536, id, "rings.appearance.fineBandCount"); Range(a.MicroBandCount, 1, 65536, id, "rings.appearance.microBandCount");
|
||||
Range(a.BandContrast, 0, 1, id, "rings.appearance.bandContrast"); Range(a.SunlightStrength, 0, 50, id, "rings.appearance.sunlightStrength");
|
||||
Range(a.AmbientStrength, 0, 2, id, "rings.appearance.ambientStrength"); Range(a.ForwardScattering, 0, 0.95, id, "rings.appearance.forwardScattering");
|
||||
Range(a.BacklitStrength, 0, 10, id, "rings.appearance.backlitStrength"); Range(a.ShadowSoftnessMeters, 1, 10000000, id, "rings.appearance.shadowSoftnessMeters");
|
||||
Range(a.DustOpacity, 0, 1, id, "rings.appearance.dustOpacity"); Color(a.OuterColor, id);
|
||||
foreach (var band in r.Bands)
|
||||
{
|
||||
if (band is null) throw new InvalidDataException($"'{id}': null ring band.");
|
||||
Interval(band.InnerRadiusMeters, band.OuterRadiusMeters, r, id); Range(band.Density, 0, 20, id, "ring band density"); Color(band.Color, id);
|
||||
}
|
||||
foreach (var gap in r.Gaps)
|
||||
{
|
||||
if (gap is null) throw new InvalidDataException($"'{id}': null ring gap.");
|
||||
Interval(gap.InnerRadiusMeters, gap.OuterRadiusMeters, r, id); Range(gap.DensityMultiplier, 0, 1, id, "ring gap densityMultiplier");
|
||||
}
|
||||
var p = r.Rocks;
|
||||
var d = p.Asteroid ?? throw new InvalidDataException($"'{id}': rocks.asteroid cannot be null.");
|
||||
Range(d.BaseSubdivision, 1, 3, id, "asteroid.baseSubdivision"); Range(d.MaxSubdivision, d.BaseSubdivision, 6, id, "asteroid.maxSubdivision");
|
||||
Range(d.LodStartPixels, 4, 100, id, "asteroid.lodStartPixels"); Range(d.LodPixelMultiplier, 1.2, 5, id, "asteroid.lodPixelMultiplier");
|
||||
Range(d.HighDetailDistanceMeters, 1, p.MeshDistanceMeters, id, "asteroid.highDetailDistanceMeters"); Range(d.MaxHighDetailInstances, 1, 64, id, "asteroid.maxHighDetailInstances");
|
||||
Range(d.Elongation, 0, .7, id, "asteroid.elongation"); Range(d.NoiseFrequency, .5, 16, id, "asteroid.noiseFrequency");
|
||||
Range(d.NoiseOctaves, 1, 7, id, "asteroid.noiseOctaves"); Range(d.NoisePersistence, .1, .8, id, "asteroid.noisePersistence");
|
||||
Range(d.RidgeStrength, 0, .3, id, "asteroid.ridgeStrength"); Range(d.CraterFrequency, 2, 40, id, "asteroid.craterFrequency");
|
||||
Range(d.CraterStrength, 0, 2, id, "asteroid.craterStrength"); Range(d.CraterDensity, 0, 1, id, "asteroid.craterDensity");
|
||||
Range(d.CraterRimHeight, 0, .5, id, "asteroid.craterRimHeight"); Range(d.SurfaceDetailStrength, 0, .03, id, "asteroid.surfaceDetailStrength");
|
||||
Range(d.SurfaceDetailFrequency, 10, 1000, id, "asteroid.surfaceDetailFrequency"); Range(d.NormalStrength, 0, 1, id, "asteroid.normalStrength");
|
||||
Range(p.CellSizeMeters, 1, 10000, id, "rings.rocks.cellSizeMeters"); Range(p.ParticlesPerCell, 1, 4096, id, "rings.rocks.particlesPerCell");
|
||||
Range(p.StreamRadiusMeters, p.CellSizeMeters, p.CellSizeMeters * 32, id, "rings.rocks.streamRadiusMeters");
|
||||
Range(p.MaximumInstances, 1, 500000, id, "rings.rocks.maximumInstances");
|
||||
Range(p.MinRadiusMeters, 0.001, 1000, id, "rings.rocks.minRadiusMeters"); Range(p.MaxRadiusMeters, p.MinRadiusMeters, 1000, id, "rings.rocks.maxRadiusMeters");
|
||||
Range(p.SizeExponent, 0.1, 10, id, "rings.rocks.sizeExponent"); Range(p.DensityScale, 0, 20, id, "rings.rocks.densityScale");
|
||||
Range(p.MeshDistanceMeters, 1, p.StreamRadiusMeters, id, "rings.rocks.meshDistanceMeters"); Range(p.MeshLodPixels, 1, 20, id, "rings.rocks.meshLodPixels");
|
||||
Range(p.LodTransitionMeters, 1, p.StreamRadiusMeters, id, "rings.rocks.lodTransitionMeters");
|
||||
Range(p.FadeStartMeters, 0, p.StreamRadiusMeters - 0.1, id, "rings.rocks.fadeStartMeters");
|
||||
Range(p.ShapeIrregularity, 0, 0.8, id, "rings.rocks.shapeIrregularity"); Range(p.Roughness, 0, 1, id, "rings.rocks.roughness");
|
||||
Range(p.IceStrength, 0, 5, id, "rings.rocks.iceStrength"); Range(p.SunlightStrength, 0, 50, id, "rings.rocks.sunlightStrength");
|
||||
Range(p.AmbientStrength, 0, 2, id, "rings.rocks.ambientStrength"); Color(p.MinColor, id); Color(p.MaxColor, id);
|
||||
var v = r.Preview;
|
||||
Range(v.OverviewDistanceInOuterRadii, 1.2, 10, id, "rings.preview.overviewDistanceInOuterRadii");
|
||||
Range(v.OverviewElevationDegrees, -85, 85, id, "rings.preview.overviewElevationDegrees");
|
||||
Range(v.OverviewSunAngleDegrees, -180, 180, id, "rings.preview.overviewSunAngleDegrees");
|
||||
Range(v.RollDegrees, -180, 180, id, "rings.preview.rollDegrees");
|
||||
Range(v.RadiusFraction, 0, 1, id, "rings.preview.radiusFraction"); Range(v.HeightMeters, 0.1, p.StreamRadiusMeters, id, "rings.preview.heightMeters");
|
||||
Range(v.AzimuthDegrees, -360, 360, id, "rings.preview.azimuthDegrees"); Range(v.YawOffsetDegrees, -180, 180, id, "rings.preview.yawOffsetDegrees");
|
||||
Range(v.PitchDegrees, -85, 85, id, "rings.preview.pitchDegrees"); Range(v.FieldOfViewDegrees, 30, 120, id, "rings.preview.fieldOfViewDegrees");
|
||||
Range(v.FlightSeconds, 1, 60, id, "rings.preview.flightSeconds"); Range(v.SpeedMetersPerSecond, 0.1, 1000000, id, "rings.preview.speedMetersPerSecond");
|
||||
}
|
||||
private static void Interval(double inner, double outer, PlanetRings rings, string id)
|
||||
{
|
||||
Range(inner, rings.InnerRadiusMeters, rings.OuterRadiusMeters, id, "ring interval innerRadiusMeters");
|
||||
Range(outer, inner + 0.001, rings.OuterRadiusMeters, id, "ring interval outerRadiusMeters");
|
||||
}
|
||||
private static void Color(float[] color, string id)
|
||||
{ if (color is not { Length: 3 } || color.Any(c => !float.IsFinite(c) || c < 0 || c > 1)) throw new InvalidDataException($"'{id}': colors require three finite values in [0,1]."); }
|
||||
private static void Range(double value, double min, double max, string id, string label)
|
||||
{ if (!double.IsFinite(value) || value < min || value > max) throw new InvalidDataException($"'{id}': {label} must be in [{min},{max}]."); }
|
||||
}
|
||||
35
src/Universe.Core/Generation/AsteroidDetailSettings.cs
Normal file
35
src/Universe.Core/Generation/AsteroidDetailSettings.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed record AsteroidDetailSettings
|
||||
{
|
||||
public int BaseSubdivision { get; init; } = 1;
|
||||
public int MaxSubdivision { get; init; } = 6;
|
||||
public double LodStartPixels { get; init; } = 14;
|
||||
public double LodPixelMultiplier { get; init; } = 2.3;
|
||||
public double HighDetailDistanceMeters { get; init; } = 80;
|
||||
public int MaxHighDetailInstances { get; init; } = 12;
|
||||
public double Elongation { get; init; } = .55;
|
||||
public double NoiseFrequency { get; init; } = 1.1;
|
||||
public int NoiseOctaves { get; init; } = 3;
|
||||
public double NoisePersistence { get; init; } = .40;
|
||||
public double RidgeStrength { get; init; } = .012;
|
||||
public double CraterFrequency { get; init; } = 10;
|
||||
public double CraterStrength { get; init; } = .28;
|
||||
public double CraterDensity { get; init; } = .72;
|
||||
public double CraterRimHeight { get; init; } = .06;
|
||||
public double SurfaceDetailStrength { get; init; } = .0002;
|
||||
public double SurfaceDetailFrequency { get; init; } = 150;
|
||||
public double NormalStrength { get; init; } = .8;
|
||||
}
|
||||
|
||||
public static class AsteroidLod
|
||||
{
|
||||
public static int Select(double projectedDiameterPixels, double distanceMeters, AsteroidDetailSettings settings)
|
||||
{
|
||||
var level = settings.BaseSubdivision;
|
||||
var threshold = settings.LodStartPixels;
|
||||
while (level < settings.MaxSubdivision && projectedDiameterPixels > threshold)
|
||||
{ level++; threshold *= settings.LodPixelMultiplier; }
|
||||
return distanceMeters > settings.HighDetailDistanceMeters ? System.Math.Min(level, 3) : level;
|
||||
}
|
||||
}
|
||||
66
src/Universe.Core/Generation/CelestialBodies.cs
Normal file
66
src/Universe.Core/Generation/CelestialBodies.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed record StarData(
|
||||
int Index,
|
||||
string Name,
|
||||
DVector3 PositionMeters,
|
||||
double RadiusMeters,
|
||||
double LuminositySolar,
|
||||
System.Numerics.Vector3 Color,
|
||||
ulong Seed)
|
||||
{
|
||||
public string Id { get; init; } = $"star-{Index}";
|
||||
public StarGlowSettings Glow { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record PlanetData(
|
||||
int Index,
|
||||
string Name,
|
||||
DVector3 PositionMeters,
|
||||
double RadiusMeters,
|
||||
double TerrainAmplitudeMeters,
|
||||
double AtmosphereHeightMeters,
|
||||
System.Numerics.Vector3 SurfaceColor,
|
||||
System.Numerics.Vector3 AtmosphereColor,
|
||||
double OrbitRadiusMeters,
|
||||
double OrbitPhaseRadians,
|
||||
int HostStarIndex,
|
||||
ulong Seed)
|
||||
{
|
||||
public string Id { get; init; } = $"planet-{Index}";
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public PlanetSurfaceType SurfaceType { get; init; }
|
||||
public bool IsGasPlanet => SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant;
|
||||
public GasVolumeSettings Gas { get; init; } = new();
|
||||
public double CloudBandFrequency { get; init; } = 28;
|
||||
public double StormStrength { get; init; }
|
||||
public RasterImage? SurfaceTexture { get; init; }
|
||||
public TerrainMaterialSettings Materials { get; init; } = new();
|
||||
public OceanSettings? Ocean { get; init; }
|
||||
public CloudSettings? Clouds { get; init; }
|
||||
public CloudDensityMap? CloudDensityMap { get; init; }
|
||||
public HeightMap? OceanMask { get; init; }
|
||||
public PlanetTerrain Terrain { get; init; } = PlanetTerrain.Default;
|
||||
public PlanetOrientation Orientation { get; init; } = PlanetOrientation.Identity;
|
||||
public PlanetRings? Rings { get; init; }
|
||||
public double AtmosphereDensity { get; init; } = 1;
|
||||
public string AtmosphereComposition { get; init; } = string.Empty;
|
||||
public double MaximumTerrainRadiusMeters => RadiusMeters + System.Math.Max(TerrainAmplitudeMeters * 1.2
|
||||
+ System.Math.Max(Terrain.RegionalHeight?.MaximumMeters ?? 0,
|
||||
Terrain.HeightMap is null ? 0 : System.Math.Max(0, System.Math.Max(Terrain.HeightmapMinMeters, Terrain.HeightmapMaxMeters))),
|
||||
Ocean is { Enabled: true } water ? water.SeaLevelMeters + water.WaveAmplitudeMeters * 3 : 0);
|
||||
public int? ParentPlanetIndex { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StarSystemData(
|
||||
SectorCoordinate Coordinate,
|
||||
ulong Seed,
|
||||
IReadOnlyList<StarData> Stars,
|
||||
IReadOnlyList<PlanetData> Planets)
|
||||
{
|
||||
public string Id { get; init; } = $"procedural:{Coordinate.X},{Coordinate.Y},{Coordinate.Z}";
|
||||
public string Name { get; init; } = "Proceduriellt solsystem";
|
||||
}
|
||||
17
src/Universe.Core/Generation/CloudDensityMap.cs
Normal file
17
src/Universe.Core/Generation/CloudDensityMap.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using StbImageSharp;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Single-channel equirectangular cloud coverage map; white means cloudy, black means clear.</summary>
|
||||
public sealed record CloudDensityMap(int Width, int Height, byte[] Samples, string? SourcePath = null)
|
||||
{
|
||||
public static CloudDensityMap Load(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var image = ImageResult.FromStream(stream, ColorComponents.Grey);
|
||||
if (image.Width < 2 || image.Height < 2 || image.Width > 16384 || image.Height > 16384
|
||||
|| image.Data.Length != checked(image.Width * image.Height))
|
||||
throw new InvalidDataException($"Invalid cloud density map dimensions in '{path}': {image.Width}x{image.Height}.");
|
||||
return new(image.Width, image.Height, image.Data, Path.GetFullPath(path));
|
||||
}
|
||||
}
|
||||
70
src/Universe.Core/Generation/CloudNoise.cs
Normal file
70
src/Universe.Core/Generation/CloudNoise.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Periodic fractal value/Worley volumes, generated once and sampled cheaply by the GPU.</summary>
|
||||
public static class CloudNoise
|
||||
{
|
||||
public static byte[] Build(int size, bool detail)
|
||||
{
|
||||
if (size is < 4 or > 128) throw new ArgumentOutOfRangeException(nameof(size));
|
||||
var data = new byte[size * size * size * 4];
|
||||
Parallel.For(0, size, z =>
|
||||
{
|
||||
for (var y = 0; y < size; y++)
|
||||
for (var x = 0; x < size; x++)
|
||||
{
|
||||
var px = (x + .5) / size; var py = (y + .5) / size; var pz = (z + .5) / size;
|
||||
var a = Cellular(px, py, pz, 4); var b = Cellular(px, py, pz, 8); var c = Cellular(px, py, pz, 16);
|
||||
var perlin = Value(px, py, pz, 4) * .55 + Value(px, py, pz, 8) * .25
|
||||
+ Value(px, py, pz, 16) * .13 + Value(px, py, pz, 32) * .07;
|
||||
var offset = ((z * size + y) * size + x) * 4;
|
||||
static byte Pack(double v) => (byte)System.Math.Clamp(System.Math.Round(v * 255), 0, 255);
|
||||
data[offset] = Pack(detail ? a : (perlin - (1 - (a * .65 + b * .25 + c * .1)) * .28) / .8);
|
||||
data[offset + 1] = Pack(b); data[offset + 2] = Pack(c);
|
||||
data[offset + 3] = detail ? (byte)255 : Pack(Value(px, py, pz, 16) * .65 + Value(px, py, pz, 32) * .35);
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
public static double Cellular(double x, double y, double z, int period)
|
||||
{
|
||||
x *= period; y *= period; z *= period;
|
||||
var ix = (int)System.Math.Floor(x); var iy = (int)System.Math.Floor(y); var iz = (int)System.Math.Floor(z);
|
||||
var nearest = 4d;
|
||||
for (var dz = -1; dz <= 1; dz++)
|
||||
for (var dy = -1; dy <= 1; dy++)
|
||||
for (var dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
var cx = ix + dx; var cy = iy + dy; var cz = iz + dz;
|
||||
var h = Hash(cx, cy, cz, period);
|
||||
var px = cx + .15 + .7 * Unit(h);
|
||||
var py = cy + .15 + .7 * Unit(Mix(h + 1));
|
||||
var pz = cz + .15 + .7 * Unit(Mix(h + 2));
|
||||
var distance = (px - x) * (px - x) + (py - y) * (py - y) + (pz - z) * (pz - z);
|
||||
nearest = System.Math.Min(nearest, distance);
|
||||
}
|
||||
return 1 - System.Math.Min(1, System.Math.Sqrt(nearest));
|
||||
}
|
||||
|
||||
public static double Value(double x, double y, double z, int period)
|
||||
{
|
||||
x *= period; y *= period; z *= period;
|
||||
var ix = (int)System.Math.Floor(x); var iy = (int)System.Math.Floor(y); var iz = (int)System.Math.Floor(z);
|
||||
static double Fade(double t) => t * t * t * (t * (t * 6 - 15) + 10);
|
||||
var tx = Fade(x - ix); var ty = Fade(y - iy); var tz = Fade(z - iz);
|
||||
var result = 0d;
|
||||
for (var dz = 0; dz < 2; dz++)
|
||||
for (var dy = 0; dy < 2; dy++)
|
||||
for (var dx = 0; dx < 2; dx++)
|
||||
result += Unit(Hash(ix + dx, iy + dy, iz + dz, period)) * (dx == 0 ? 1 - tx : tx)
|
||||
* (dy == 0 ? 1 - ty : ty) * (dz == 0 ? 1 - tz : tz);
|
||||
return result;
|
||||
}
|
||||
private static double Unit(uint h) => (h & 0xFFFFFF) / 16777215d;
|
||||
private static uint Hash(int x, int y, int z, int period)
|
||||
{
|
||||
static uint Wrap(int v, int p) => (uint)((v % p + p) % p);
|
||||
return Mix(Wrap(x, period) * 73856093u ^ Wrap(y, period) * 19349663u ^ Wrap(z, period) * 83492791u ^ 0xC10D5u);
|
||||
}
|
||||
private static uint Mix(uint h) { h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15; h *= 0x846ca68bu; return h ^ (h >> 16); }
|
||||
}
|
||||
77
src/Universe.Core/Generation/CloudSettings.cs
Normal file
77
src/Universe.Core/Generation/CloudSettings.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Numerics;
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed record CloudSettings
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public ulong? Seed { get; init; }
|
||||
/// <summary>Optional equirectangular grayscale weather mask, relative to Content/.</summary>
|
||||
public string? DensityTexturePath { get; init; }
|
||||
/// <summary>Blend strength for the optional grayscale weather mask; black is clear and white is cloudy.</summary>
|
||||
public double DensityTextureStrength { get; init; } = 1;
|
||||
public double BaseAltitudeMeters { get; init; } = 1800;
|
||||
public double ThicknessMeters { get; init; } = 5500;
|
||||
public double Coverage { get; init; } = .56;
|
||||
public double Density { get; init; } = 1;
|
||||
public double ShapeScaleMeters { get; init; } = 42000;
|
||||
public double DetailScaleMeters { get; init; } = 6500;
|
||||
public double Erosion { get; init; } = .28;
|
||||
public double CloudType { get; init; } = .8;
|
||||
public double WindSpeedMetersPerSecond { get; init; } = 18;
|
||||
public double WindDirectionDegrees { get; init; } = 25;
|
||||
public double ExtinctionPerMeter { get; init; } = .0007;
|
||||
public double ScatteringAlbedo { get; init; } = .96;
|
||||
public double Anisotropy { get; init; } = .62;
|
||||
public double SilverLiningStrength { get; init; } = .7;
|
||||
public double AmbientStrength { get; init; } = .6;
|
||||
public double SunlightStrength { get; init; } = 1;
|
||||
public double SunsetStrength { get; init; } = 1.4;
|
||||
public double ShadowStrength { get; init; } = .7;
|
||||
public int ViewSteps { get; init; } = 48;
|
||||
public int LightSteps { get; init; } = 4;
|
||||
public double ResolutionScale { get; init; } = .5;
|
||||
public double MaxViewDistanceMeters { get; init; } = 220000;
|
||||
|
||||
/// <summary>Uses a separate seed stream so adding weather never moves stars or planets.</summary>
|
||||
public static CloudSettings? ForAtmosphere(ulong seed, double height, double density, PlanetSurfaceType type)
|
||||
{
|
||||
if (height < 500 || density < .09 || type is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant) return null;
|
||||
var random = new DeterministicRandom(seed ^ 0x434C4F554453UL);
|
||||
var strength = System.Math.Clamp(density / 1.3, 0, 1);
|
||||
var baseHeight = System.Math.Clamp(height * (.018 + random.NextDouble() * .018), 100, System.Math.Min(10000, height * .4));
|
||||
return new()
|
||||
{
|
||||
BaseAltitudeMeters = baseHeight,
|
||||
ThicknessMeters = System.Math.Min(height - baseHeight, 1500 + strength * 6000 + random.NextDouble() * 2000),
|
||||
Coverage = System.Math.Clamp(.08 + strength * (.4 + random.NextDouble() * .4), .04, .94),
|
||||
Density = .2 + strength * (1 + random.NextDouble() * .5),
|
||||
CloudType = random.NextDouble(),
|
||||
WindSpeedMetersPerSecond = 5 + random.NextDouble() * (15 + strength * 35),
|
||||
WindDirectionDegrees = random.NextDouble() * 360,
|
||||
ShapeScaleMeters = 26000 + random.NextDouble() * 35000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class CloudLighting
|
||||
{
|
||||
public static Vector3 SunTransmission(double sunCosine, double altitude, double atmosphereHeight,
|
||||
double radius, double atmosphereDensity, Vector3 atmosphereColor, double strength)
|
||||
{
|
||||
var scaleHeight = System.Math.Max(100, atmosphereHeight * .055);
|
||||
var airMass = System.Math.Exp(-System.Math.Max(0, altitude) / scaleHeight)
|
||||
/ System.Math.Max(.025, sunCosine + System.Math.Sqrt(sunCosine * sunCosine + 2 * scaleHeight / radius));
|
||||
var optical = (float)(airMass * atmosphereDensity * strength);
|
||||
return new(System.MathF.Exp(-(.022f * (.7f + atmosphereColor.X * .3f)) * optical),
|
||||
System.MathF.Exp(-(.055f * (.7f + atmosphereColor.Y * .3f)) * optical),
|
||||
System.MathF.Exp(-(.14f * (.7f + atmosphereColor.Z * .3f)) * optical));
|
||||
}
|
||||
|
||||
public static DVector3 Wind(CloudSettings settings)
|
||||
{
|
||||
var angle = settings.WindDirectionDegrees * System.Math.PI / 180;
|
||||
return new DVector3(System.Math.Cos(angle), .05, System.Math.Sin(angle)).Normalized() * settings.WindSpeedMetersPerSecond;
|
||||
}
|
||||
}
|
||||
26
src/Universe.Core/Generation/DeterministicRandom.cs
Normal file
26
src/Universe.Core/Generation/DeterministicRandom.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>SplitMix64-based generator with stable results across .NET versions.</summary>
|
||||
public struct DeterministicRandom(ulong seed)
|
||||
{
|
||||
private ulong _state = seed;
|
||||
|
||||
public ulong NextUInt64()
|
||||
{
|
||||
var z = (_state += 0x9E3779B97F4A7C15UL);
|
||||
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
|
||||
return z ^ (z >> 31);
|
||||
}
|
||||
|
||||
public double NextDouble() => (NextUInt64() >> 11) * (1d / (1UL << 53));
|
||||
public int NextInt(int inclusiveMinimum, int exclusiveMaximum) =>
|
||||
inclusiveMinimum + (int)(NextUInt64() % (uint)(exclusiveMaximum - inclusiveMinimum));
|
||||
|
||||
public static ulong Mix(ulong value)
|
||||
{
|
||||
value = (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
value = (value ^ (value >> 27)) * 0x94D049BB133111EBUL;
|
||||
return value ^ (value >> 31);
|
||||
}
|
||||
}
|
||||
100
src/Universe.Core/Generation/GasAndRingSettings.cs
Normal file
100
src/Universe.Core/Generation/GasAndRingSettings.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed record GasVolumeSettings
|
||||
{
|
||||
public double OuterRadiusScale { get; init; } = 1.015;
|
||||
public double PolarRadiusScale { get; init; } = 0.95;
|
||||
public double Density { get; init; } = 420;
|
||||
public double ScaleHeight { get; init; } = 0.008;
|
||||
public int ViewSteps { get; init; } = 96;
|
||||
public int LightSteps { get; init; } = 7;
|
||||
public double SampleDistribution { get; init; } = 3;
|
||||
public double StopTransmittance { get; init; } = 0.003;
|
||||
public double ScatteringAlbedo { get; init; } = 0.94;
|
||||
public double LightExtinction { get; init; } = 0.35;
|
||||
public double SunlightStrength { get; init; } = 7;
|
||||
public double AmbientStrength { get; init; } = 0.025;
|
||||
public double Anisotropy { get; init; } = 0.18;
|
||||
public double BandContrast { get; init; } = 0.18;
|
||||
public double FineBandFrequency { get; init; } = 260;
|
||||
public double FineBandContrast { get; init; } = 0.06;
|
||||
public double CloudTurbulence { get; init; } = 0.12;
|
||||
public double DetailStrength { get; init; } = 0.025;
|
||||
public double NoiseScale { get; init; } = 65;
|
||||
public double WindSpeed { get; init; } = 0.012;
|
||||
public float[]? BandColor { get; init; }
|
||||
public float[]? PolarColor { get; init; }
|
||||
public float[]? HazeColor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RingBandSettings
|
||||
{
|
||||
public double InnerRadiusMeters { get; init; }
|
||||
public double OuterRadiusMeters { get; init; }
|
||||
public double Density { get; init; } = 1;
|
||||
public float[] Color { get; init; } = [0.68f, 0.65f, 0.57f];
|
||||
}
|
||||
|
||||
public sealed record RingGapSettings
|
||||
{
|
||||
public double InnerRadiusMeters { get; init; }
|
||||
public double OuterRadiusMeters { get; init; }
|
||||
public double DensityMultiplier { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RingAppearanceSettings
|
||||
{
|
||||
public int ProfileResolution { get; init; } = 32768;
|
||||
public double OpticalDepth { get; init; } = 0.9;
|
||||
public double FineBandCount { get; init; } = 1200;
|
||||
public double MicroBandCount { get; init; } = 12000;
|
||||
public double BandContrast { get; init; } = 0.65;
|
||||
public double SunlightStrength { get; init; } = 3.4;
|
||||
public double AmbientStrength { get; init; } = 0.035;
|
||||
public double ForwardScattering { get; init; } = 0.65;
|
||||
public double BacklitStrength { get; init; } = 0.8;
|
||||
public double ShadowSoftnessMeters { get; init; } = 150000;
|
||||
public double DustOpacity { get; init; } = 0.08;
|
||||
public float[] OuterColor { get; init; } = [0.70f, 0.68f, 0.60f];
|
||||
}
|
||||
|
||||
public sealed record RingRockSettings
|
||||
{
|
||||
public AsteroidDetailSettings Asteroid { get; init; } = new();
|
||||
public bool Enabled { get; init; } = true;
|
||||
public double CellSizeMeters { get; init; } = 256;
|
||||
public int ParticlesPerCell { get; init; } = 256;
|
||||
public double StreamRadiusMeters { get; init; } = 3500;
|
||||
public int MaximumInstances { get; init; } = 100000;
|
||||
public double MinRadiusMeters { get; init; } = 0.06;
|
||||
public double MaxRadiusMeters { get; init; } = 4;
|
||||
public double SizeExponent { get; init; } = 2.8;
|
||||
public double DensityScale { get; init; } = 1;
|
||||
public double MeshDistanceMeters { get; init; } = 600;
|
||||
public double MeshLodPixels { get; init; } = 3.5;
|
||||
public double LodTransitionMeters { get; init; } = 180;
|
||||
public double FadeStartMeters { get; init; } = 2600;
|
||||
public double ShapeIrregularity { get; init; } = 0.27;
|
||||
public double Roughness { get; init; } = 0.7;
|
||||
public double IceStrength { get; init; } = 0.5;
|
||||
public double SunlightStrength { get; init; } = 4;
|
||||
public double AmbientStrength { get; init; } = 0.04;
|
||||
public float[] MinColor { get; init; } = [0.28f, 0.28f, 0.26f];
|
||||
public float[] MaxColor { get; init; } = [0.85f, 0.84f, 0.77f];
|
||||
}
|
||||
|
||||
public sealed record RingPreviewSettings
|
||||
{
|
||||
public double OverviewDistanceInOuterRadii { get; init; } = 1.9;
|
||||
public double OverviewElevationDegrees { get; init; } = 24;
|
||||
public double OverviewSunAngleDegrees { get; init; } = 55;
|
||||
public double RollDegrees { get; init; } = -12;
|
||||
public double RadiusFraction { get; init; } = 0.88;
|
||||
public double AzimuthDegrees { get; init; } = -80;
|
||||
public double HeightMeters { get; init; } = 55;
|
||||
public double YawOffsetDegrees { get; init; } = 35;
|
||||
public double PitchDegrees { get; init; } = -12;
|
||||
public double FieldOfViewDegrees { get; init; } = 100;
|
||||
public double FlightSeconds { get; init; } = 8;
|
||||
public double SpeedMetersPerSecond { get; init; } = 40;
|
||||
}
|
||||
41
src/Universe.Core/Generation/IcosphereMesh.cs
Normal file
41
src/Universe.Core/Generation/IcosphereMesh.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Shared vertices on a closed, seam-free geodesic sphere, subdivided uniformly.</summary>
|
||||
public sealed record IcosphereMesh(float[] Vertices, uint[] Indices)
|
||||
{
|
||||
public static IcosphereMesh Build(int subdivisions)
|
||||
{
|
||||
if (subdivisions is < 0 or > 6) throw new ArgumentOutOfRangeException(nameof(subdivisions));
|
||||
var t = (1 + MathF.Sqrt(5)) / 2;
|
||||
var vertices = new List<Vector3>{new(-1,t,0),new(1,t,0),new(-1,-t,0),new(1,-t,0),new(0,-1,t),new(0,1,t),
|
||||
new(0,-1,-t),new(0,1,-t),new(t,0,-1),new(t,0,1),new(-t,0,-1),new(-t,0,1)};
|
||||
for (var i = 0; i < vertices.Count; i++) vertices[i] = Vector3.Normalize(vertices[i]);
|
||||
var indices = new List<uint>{0,11,5,0,5,1,0,1,7,0,7,10,0,10,11,1,5,9,5,11,4,11,10,2,10,7,6,7,1,8,
|
||||
3,9,4,3,4,2,3,2,6,3,6,8,3,8,9,4,9,5,2,4,11,6,2,10,8,6,7,9,8,1};
|
||||
for (var level = 0; level < subdivisions; level++)
|
||||
{
|
||||
var midpoints = new Dictionary<ulong, uint>();
|
||||
uint Mid(uint a, uint b)
|
||||
{
|
||||
var key = ((ulong)System.Math.Min(a, b) << 32) | System.Math.Max(a, b);
|
||||
if (midpoints.TryGetValue(key, out var index)) return index;
|
||||
index = (uint)vertices.Count; vertices.Add(Vector3.Normalize(vertices[(int)a] + vertices[(int)b]));
|
||||
midpoints.Add(key, index); return index;
|
||||
}
|
||||
var next = new List<uint>(indices.Count * 4);
|
||||
for (var i = 0; i < indices.Count; i += 3)
|
||||
{
|
||||
var a = indices[i]; var b = indices[i + 1]; var c = indices[i + 2];
|
||||
var ab = Mid(a, b); var bc = Mid(b, c); var ca = Mid(c, a);
|
||||
next.AddRange([a, ab, ca, b, bc, ab, c, ca, bc, ab, bc, ca]);
|
||||
}
|
||||
indices = next;
|
||||
}
|
||||
var packed = new float[vertices.Count * 6];
|
||||
for (var i = 0; i < vertices.Count; i++)
|
||||
for (var c = 0; c < 3; c++) packed[i * 6 + c] = packed[i * 6 + 3 + c] = vertices[i][c];
|
||||
return new(packed, indices.ToArray());
|
||||
}
|
||||
}
|
||||
81
src/Universe.Core/Generation/OceanSettings.cs
Normal file
81
src/Universe.Core/Generation/OceanSettings.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Metres, seconds and linear RGB. A null ocean on a planet disables water.</summary>
|
||||
public sealed record OceanSettings
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public bool MaskFromSurfaceTexture { get; init; } = true;
|
||||
public double SeaLevelMeters { get; init; }
|
||||
public double DepthMeters { get; init; } = 4000;
|
||||
public double WaveAmplitudeMeters { get; init; } = 1.6;
|
||||
public double WaveLengthMeters { get; init; } = 72;
|
||||
public double WindDirectionDegrees { get; init; } = 35;
|
||||
public double WaveSpeed { get; init; } = 1;
|
||||
public double Choppiness { get; init; } = .65;
|
||||
public double Roughness { get; init; } = .14;
|
||||
public double ReflectionStrength { get; init; } = 1;
|
||||
public double RefractionStrength { get; init; } = .018;
|
||||
public double FoamStrength { get; init; } = .65;
|
||||
public float[] DeepColor { get; init; } = [.006f, .045f, .075f];
|
||||
public float[] ShallowColor { get; init; } = [.025f, .28f, .24f];
|
||||
public float[] Absorption { get; init; } = [.12f, .045f, .022f];
|
||||
}
|
||||
|
||||
public readonly record struct OceanWave(DVector3 Direction, double WaveNumber, double Amplitude, double AngularFrequency);
|
||||
|
||||
public static class OceanSurface
|
||||
{
|
||||
public const int WaveCount = 12;
|
||||
|
||||
public static OceanWave[] Spectrum(OceanSettings settings, ulong seed)
|
||||
{
|
||||
var random = new DeterministicRandom(seed ^ 0x4F4345414EUL);
|
||||
var waves = new OceanWave[WaveCount];
|
||||
for (var i = 0; i < waves.Length; i++)
|
||||
{
|
||||
var angle = (settings.WindDirectionDegrees + (random.NextDouble() - .5) * 115 + i * 23) * System.Math.PI / 180;
|
||||
var direction = new DVector3(System.Math.Cos(angle), .23 * System.Math.Sin(angle * 1.7 + i), System.Math.Sin(angle)).Normalized();
|
||||
var wavelength = settings.WaveLengthMeters * System.Math.Pow(.63, i);
|
||||
var k = System.Math.Tau / wavelength;
|
||||
var amplitude = settings.WaveAmplitudeMeters * System.Math.Pow(.62, i);
|
||||
waves[i] = new(direction, k, amplitude, System.Math.Sqrt(9.81 * k) * settings.WaveSpeed);
|
||||
}
|
||||
return waves;
|
||||
}
|
||||
|
||||
/// <summary>Blue ocean pixels become a shared CPU/GPU mask; land and white ice stay dry.</summary>
|
||||
public static HeightMap CreateMask(RasterImage image)
|
||||
{
|
||||
var width = System.Math.Min(image.Width, 2048);
|
||||
var height = System.Math.Max(2, (int)System.Math.Round((double)image.Height * width / image.Width));
|
||||
var values = new float[width * height];
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
float water = 0;
|
||||
for (var sy = 0; sy < 2; sy++)
|
||||
for (var sx = 0; sx < 2; sx++)
|
||||
{
|
||||
var px = System.Math.Min(image.Width - 1, (int)((x + (sx + .5) / 2) * image.Width / width));
|
||||
var py = System.Math.Min(image.Height - 1, (int)((y + (sy + .5) / 2) * image.Height / height));
|
||||
var offset = (py * image.Width + px) * 4;
|
||||
var r = image.Rgba[offset]; var g = image.Rgba[offset + 1]; var b = image.Rgba[offset + 2];
|
||||
water += b > g * 1.15 && b > r * 1.35 ? .25f : 0;
|
||||
}
|
||||
values[y * width + x] = water;
|
||||
}
|
||||
// Smooth the coast before sampling; GPU UVs have float precision while collision uses doubles.
|
||||
var horizontal = new float[values.Length]; var filtered = new float[values.Length];
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
horizontal[y * width + x] = (values[y * width + (x + width - 1) % width] + 2 * values[y * width + x]
|
||||
+ values[y * width + (x + 1) % width]) * .25f;
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
filtered[y * width + x] = (horizontal[System.Math.Max(0, y - 1) * width + x] + 2 * horizontal[y * width + x]
|
||||
+ horizontal[System.Math.Min(height - 1, y + 1) * width + x]) * .25f;
|
||||
return new(width, height, filtered);
|
||||
}
|
||||
}
|
||||
70
src/Universe.Core/Generation/PlanetAppearance.cs
Normal file
70
src/Universe.Core/Generation/PlanetAppearance.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Numerics;
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public enum PlanetSurfaceType { Rocky, Terrestrial, CloudCovered, GasGiant, IceGiant }
|
||||
public enum TerrainNoiseMask { Everywhere, HeightmapLand }
|
||||
|
||||
public sealed record PlanetTerrain
|
||||
{
|
||||
public static readonly PlanetTerrain Default = new();
|
||||
public HeightMap? HeightMap { get; init; }
|
||||
public RegionalHeightMap? RegionalHeight { get; init; }
|
||||
public double HeightmapMinMeters { get; init; }
|
||||
public double HeightmapMaxMeters { get; init; }
|
||||
public double HeightmapGamma { get; init; } = 1;
|
||||
public double NoiseFrequency { get; init; } = 1;
|
||||
public TerrainNoiseMask NoiseMask { get; init; }
|
||||
public double LandThreshold { get; init; } = 0.02;
|
||||
public double LandBlend { get; init; } = 0.04;
|
||||
}
|
||||
|
||||
public sealed record PlanetRings(double InnerRadiusMeters, double OuterRadiusMeters, Vector3 Color, double Opacity,
|
||||
double GapInnerRadiusMeters = 0, double GapOuterRadiusMeters = 0)
|
||||
{
|
||||
public ulong Seed { get; init; } = 1;
|
||||
public double ThicknessMeters { get; init; } = 40;
|
||||
public RingAppearanceSettings Appearance { get; init; } = new();
|
||||
public RingBandSettings[] Bands { get; init; } = [];
|
||||
public RingGapSettings[] Gaps { get; init; } = [];
|
||||
public RingRockSettings Rocks { get; init; } = new();
|
||||
public RingPreviewSettings Preview { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Fixed body orientation. Terrain, texture UVs, ring planes and collision use this same transform.</summary>
|
||||
public sealed class PlanetOrientation
|
||||
{
|
||||
public static readonly PlanetOrientation Identity = new(0, 0);
|
||||
private readonly double _sinTilt, _cosTilt, _sinSpin, _cosSpin;
|
||||
public double AxialTiltDegrees { get; }
|
||||
public double RotationDegrees { get; }
|
||||
public Matrix4x4 BodyToWorld { get; }
|
||||
public Matrix4x4 WorldToBody { get; }
|
||||
|
||||
public PlanetOrientation(double axialTiltDegrees, double rotationDegrees)
|
||||
{
|
||||
AxialTiltDegrees = axialTiltDegrees;
|
||||
RotationDegrees = rotationDegrees;
|
||||
var tilt = axialTiltDegrees * System.Math.PI / 180;
|
||||
var spin = rotationDegrees * System.Math.PI / 180;
|
||||
(_sinTilt, _cosTilt) = System.Math.SinCos(tilt);
|
||||
(_sinSpin, _cosSpin) = System.Math.SinCos(spin);
|
||||
BodyToWorld = Matrix4x4.CreateRotationY((float)spin) * Matrix4x4.CreateRotationZ((float)tilt);
|
||||
WorldToBody = Matrix4x4.Transpose(BodyToWorld);
|
||||
}
|
||||
|
||||
public DVector3 ToBody(DVector3 d)
|
||||
{
|
||||
var x = _cosTilt * d.X + _sinTilt * d.Y;
|
||||
var y = -_sinTilt * d.X + _cosTilt * d.Y;
|
||||
return new(_cosSpin * x - _sinSpin * d.Z, y, _sinSpin * x + _cosSpin * d.Z);
|
||||
}
|
||||
|
||||
public DVector3 ToWorld(DVector3 d)
|
||||
{
|
||||
var x = _cosSpin * d.X + _sinSpin * d.Z;
|
||||
var z = -_sinSpin * d.X + _cosSpin * d.Z;
|
||||
return new(_cosTilt * x - _sinTilt * d.Y, _sinTilt * x + _cosTilt * d.Y, z);
|
||||
}
|
||||
}
|
||||
78
src/Universe.Core/Generation/ProceduralNoise.cs
Normal file
78
src/Universe.Core/Generation/ProceduralNoise.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>A periodic value field. The identical lattice is uploaded as a 3D GPU texture.</summary>
|
||||
public static class ProceduralNoise
|
||||
{
|
||||
public const int Size = 64;
|
||||
private const int Mask = Size - 1;
|
||||
public static readonly float[] Values = CreateValues();
|
||||
public static readonly float[] PackedGradients = CreateGradients();
|
||||
|
||||
public static DVector3 SeedOffset(ulong seed) => new(
|
||||
(seed & 1023) / 32d, ((seed >> 20) & 1023) / 32d, ((seed >> 40) & 1023) / 32d);
|
||||
|
||||
public static double Sample(DVector3 p)
|
||||
{
|
||||
var x = (int)System.Math.Floor(p.X);
|
||||
var y = (int)System.Math.Floor(p.Y);
|
||||
var z = (int)System.Math.Floor(p.Z);
|
||||
var fx = Fade(p.X - x);
|
||||
var fy = Fade(p.Y - y);
|
||||
var fz = Fade(p.Z - z);
|
||||
var a = Lerp(At(x, y, z), At(x + 1, y, z), fx);
|
||||
var b = Lerp(At(x, y + 1, z), At(x + 1, y + 1, z), fx);
|
||||
var c = Lerp(At(x, y, z + 1), At(x + 1, y, z + 1), fx);
|
||||
var d = Lerp(At(x, y + 1, z + 1), At(x + 1, y + 1, z + 1), fx);
|
||||
return Lerp(Lerp(a, b, fy), Lerp(c, d, fy), fz);
|
||||
}
|
||||
|
||||
public static DVector3 RotateOctave(DVector3 p) => new(
|
||||
0.80 * p.Y + 0.60 * p.Z,
|
||||
-0.80 * p.X + 0.36 * p.Y - 0.48 * p.Z,
|
||||
-0.60 * p.X - 0.48 * p.Y + 0.64 * p.Z);
|
||||
|
||||
public static double GradientSample(DVector3 p)
|
||||
{
|
||||
var x = (int)System.Math.Floor(p.X); var y = (int)System.Math.Floor(p.Y); var z = (int)System.Math.Floor(p.Z);
|
||||
var f = new DVector3(p.X - x, p.Y - y, p.Z - z);
|
||||
var t = new DVector3(Fade(f.X), Fade(f.Y), Fade(f.Z));
|
||||
var a = Lerp(GradientDot(x, y, z, f), GradientDot(x + 1, y, z, f - new DVector3(1, 0, 0)), t.X);
|
||||
var b = Lerp(GradientDot(x, y + 1, z, f - new DVector3(0, 1, 0)), GradientDot(x + 1, y + 1, z, f - new DVector3(1, 1, 0)), t.X);
|
||||
var c = Lerp(GradientDot(x, y, z + 1, f - new DVector3(0, 0, 1)), GradientDot(x + 1, y, z + 1, f - new DVector3(1, 0, 1)), t.X);
|
||||
var d = Lerp(GradientDot(x, y + 1, z + 1, f - new DVector3(0, 1, 1)), GradientDot(x + 1, y + 1, z + 1, f - new DVector3(1, 1, 1)), t.X);
|
||||
return System.Math.Clamp(Lerp(Lerp(a, b, t.Y), Lerp(c, d, t.Y), t.Z) * 1.7, -1, 1);
|
||||
}
|
||||
|
||||
private static double GradientDot(int x, int y, int z, DVector3 d)
|
||||
{
|
||||
var i = (((z & Mask) * Size + (y & Mask)) * Size + (x & Mask)) * 4;
|
||||
return PackedGradients[i] * d.X + PackedGradients[i + 1] * d.Y + PackedGradients[i + 2] * d.Z;
|
||||
}
|
||||
|
||||
private static float At(int x, int y, int z) => Values[((z & Mask) * Size + (y & Mask)) * Size + (x & Mask)];
|
||||
private static double Fade(double t) => t * t * t * (t * (t * 6 - 15) + 10);
|
||||
private static double Lerp(double a, double b, double t) => a + (b - a) * t;
|
||||
|
||||
private static float[] CreateValues()
|
||||
{
|
||||
var values = new float[Size * Size * Size];
|
||||
var random = new DeterministicRandom(0x4E4F4953453344UL);
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
values[i] = (float)(random.NextDouble() * 2 - 1);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static float[] CreateGradients()
|
||||
{
|
||||
var data = new float[Values.Length * 4];
|
||||
var random = new DeterministicRandom(0x4752414449454E54UL);
|
||||
for (var i = 0; i < Values.Length; i++)
|
||||
{
|
||||
var g = new DVector3(random.NextDouble() * 2 - 1, random.NextDouble() * 2 - 1, random.NextDouble() * 2 - 1).Normalized();
|
||||
data[i * 4] = (float)g.X; data[i * 4 + 1] = (float)g.Y; data[i * 4 + 2] = (float)g.Z; data[i * 4 + 3] = Values[i];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
68
src/Universe.Core/Generation/RasterImage.cs
Normal file
68
src/Universe.Core/Generation/RasterImage.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using StbImageSharp;
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Unmodified image rows, north at the top. Color and height maps share equirectangular UVs.</summary>
|
||||
public sealed record RasterImage(int Width, int Height, byte[] Rgba, string? SourcePath = null)
|
||||
{
|
||||
public static RasterImage Load(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
|
||||
if (image.Width < 2 || image.Height < 2 || image.Width > 16384 || image.Height > 16384)
|
||||
throw new InvalidDataException($"Invalid map dimensions in '{path}': {image.Width}x{image.Height}.");
|
||||
return new(image.Width, image.Height, image.Data, Path.GetFullPath(path));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HeightMap
|
||||
{
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
public float[] Samples { get; }
|
||||
public string? SourcePath { get; }
|
||||
|
||||
public HeightMap(int width, int height, float[] samples, string? sourcePath = null)
|
||||
{
|
||||
if (width < 2 || height < 2 || samples.Length != checked(width * height)
|
||||
|| samples.Any(value => !float.IsFinite(value) || value < 0 || value > 1))
|
||||
throw new ArgumentException("A heightmap must contain width*height finite values in [0,1].");
|
||||
Width = width; Height = height; Samples = samples; SourcePath = sourcePath;
|
||||
}
|
||||
|
||||
public static HeightMap Load(string path)
|
||||
{
|
||||
var image = RasterImage.Load(path);
|
||||
var data = new float[image.Width * image.Height];
|
||||
for (var i = 0; i < data.Length; i++) data[i] = image.Rgba[i * 4] / 255f;
|
||||
return new(image.Width, image.Height, data, image.SourcePath);
|
||||
}
|
||||
|
||||
public static (double U, double V) Coordinates(DVector3 direction)
|
||||
{
|
||||
var d = direction.Normalized();
|
||||
return (0.5 - System.Math.Atan2(d.Z, d.X) / System.Math.Tau,
|
||||
System.Math.Atan2(System.Math.Sqrt(d.X * d.X + d.Z * d.Z), d.Y) / System.Math.PI);
|
||||
}
|
||||
|
||||
public double Sample(DVector3 bodyDirection)
|
||||
{
|
||||
var (u, v) = Coordinates(bodyDirection);
|
||||
return SampleUv(u, v);
|
||||
}
|
||||
|
||||
public double SampleUv(double u, double v)
|
||||
{
|
||||
var x = (u - System.Math.Floor(u)) * Width - 0.5;
|
||||
var y = System.Math.Clamp(v * Height - 0.5, 0, Height - 1);
|
||||
var x0 = (int)System.Math.Floor(x);
|
||||
var y0 = (int)System.Math.Floor(y);
|
||||
var tx = x - x0; var ty = y - y0;
|
||||
var a = At(x0, y0) * (1 - tx) + At(x0 + 1, y0) * tx;
|
||||
var b = At(x0, y0 + 1) * (1 - tx) + At(x0 + 1, y0 + 1) * tx;
|
||||
return a * (1 - ty) + b * ty;
|
||||
}
|
||||
|
||||
private float At(int x, int y) => Samples[System.Math.Clamp(y, 0, Height - 1) * Width + ((x % Width + Width) % Width)];
|
||||
}
|
||||
46
src/Universe.Core/Generation/RegionalHeightMap.cs
Normal file
46
src/Universe.Core/Generation/RegionalHeightMap.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>A north-up, geographic tile of metre heights. The fade joins it to the global map.</summary>
|
||||
public sealed class RegionalHeightMap
|
||||
{
|
||||
public required int Width { get; init; }
|
||||
public required int Height { get; init; }
|
||||
public required float[] Meters { get; init; }
|
||||
public required double WestDegrees { get; init; }
|
||||
public required double SouthDegrees { get; init; }
|
||||
public required double EastDegrees { get; init; }
|
||||
public required double NorthDegrees { get; init; }
|
||||
public required double FadeDegrees { get; init; }
|
||||
public required double MaximumMeters { get; init; }
|
||||
public required double MinimumMeters { get; init; }
|
||||
|
||||
public (double Height, double Weight) Sample(DVector3 direction)
|
||||
{
|
||||
var (u, v) = HeightMap.Coordinates(direction);
|
||||
var lon = (u - .5) * 360;
|
||||
var lat = 90 - v * 180;
|
||||
var edge = System.Math.Min(System.Math.Min(lon - WestDegrees, EastDegrees - lon),
|
||||
System.Math.Min(lat - SouthDegrees, NorthDegrees - lat));
|
||||
if (edge <= 0) return (0, 0);
|
||||
var weight = System.Math.Clamp(edge / FadeDegrees, 0, 1);
|
||||
weight = weight * weight * (3 - 2 * weight);
|
||||
var x = System.Math.Clamp((lon - WestDegrees) / (EastDegrees - WestDegrees) * Width - .5, 0, Width - 1);
|
||||
var y = System.Math.Clamp((NorthDegrees - lat) / (NorthDegrees - SouthDegrees) * Height - .5, 0, Height - 1);
|
||||
var x0 = (int)x; var y0 = (int)y; var x1 = System.Math.Min(x0 + 1, Width - 1); var y1 = System.Math.Min(y0 + 1, Height - 1);
|
||||
var a = Meters[y0 * Width + x0] * (1 - (x - x0)) + Meters[y0 * Width + x1] * (x - x0);
|
||||
var b = Meters[y1 * Width + x0] * (1 - (x - x0)) + Meters[y1 * Width + x1] * (x - x0);
|
||||
return (a * (1 - (y - y0)) + b * (y - y0), weight);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TerrainMaterialSettings
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public string? FlatMaterial { get; init; }
|
||||
public string? SlopeMaterial { get; init; }
|
||||
public double ScaleMeters { get; init; } = 8;
|
||||
public double FadeDistanceMeters { get; init; } = 2000;
|
||||
public double Strength { get; init; } = .8;
|
||||
}
|
||||
64
src/Universe.Core/Generation/RingProfile.cs
Normal file
64
src/Universe.Core/Generation/RingProfile.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public readonly record struct RingMaterial(Vector3 Color, double OpticalDepth);
|
||||
|
||||
/// <summary>The same seeded radial optical-depth profile drives distant rings, shadows and rock density.</summary>
|
||||
public sealed class RingProfile
|
||||
{
|
||||
public PlanetRings Rings { get; }
|
||||
public float[] Rgba { get; }
|
||||
public int Resolution => Rgba.Length / 4;
|
||||
|
||||
public RingProfile(PlanetRings rings)
|
||||
{
|
||||
Rings = rings;
|
||||
Rgba = new float[rings.Appearance.ProfileResolution * 4];
|
||||
var offset = (rings.Seed & 65535) / 131d;
|
||||
var outer = Vector(rings.Appearance.OuterColor);
|
||||
for (var i = 0; i < Resolution; i++)
|
||||
{
|
||||
var u = (i + 0.5) / Resolution;
|
||||
var radius = rings.InnerRadiusMeters + u * (rings.OuterRadiusMeters - rings.InnerRadiusMeters);
|
||||
var color = Vector3.Lerp(rings.Color, outer, (float)u);
|
||||
var density = rings.Bands.Length == 0 ? 1d : 0d;
|
||||
foreach (var band in rings.Bands)
|
||||
if (radius >= band.InnerRadiusMeters && radius <= band.OuterRadiusMeters)
|
||||
{ density = band.Density; color = Vector(band.Color); }
|
||||
var broad = Noise(u * 37 + offset);
|
||||
var fine = Noise(u * rings.Appearance.FineBandCount + offset * 3.1);
|
||||
var micro = Noise(u * rings.Appearance.MicroBandCount + offset * 7.3);
|
||||
var structure = System.Math.Clamp(1 + rings.Appearance.BandContrast * (broad * 0.30 + fine * 0.60 + micro * 0.35), 0.04, 2);
|
||||
density *= structure * rings.Opacity * rings.Appearance.OpticalDepth;
|
||||
color *= (float)(0.82 + 0.18 * structure);
|
||||
if (radius > rings.GapInnerRadiusMeters && radius < rings.GapOuterRadiusMeters) density = 0;
|
||||
foreach (var gap in rings.Gaps)
|
||||
if (radius > gap.InnerRadiusMeters && radius < gap.OuterRadiusMeters) density *= gap.DensityMultiplier;
|
||||
Rgba[i * 4] = color.X; Rgba[i * 4 + 1] = color.Y; Rgba[i * 4 + 2] = color.Z; Rgba[i * 4 + 3] = (float)density;
|
||||
}
|
||||
}
|
||||
|
||||
public RingMaterial Sample(double radius)
|
||||
{
|
||||
if (radius < Rings.InnerRadiusMeters || radius > Rings.OuterRadiusMeters) return new(Vector3.Zero, 0);
|
||||
// Preserve truly empty gaps even when the radial texture is minified.
|
||||
if (radius > Rings.GapInnerRadiusMeters && radius < Rings.GapOuterRadiusMeters) return new(Vector3.Zero, 0);
|
||||
foreach (var gap in Rings.Gaps)
|
||||
if (gap.DensityMultiplier == 0 && radius > gap.InnerRadiusMeters && radius < gap.OuterRadiusMeters) return new(Vector3.Zero, 0);
|
||||
var p = (radius - Rings.InnerRadiusMeters) / (Rings.OuterRadiusMeters - Rings.InnerRadiusMeters) * Resolution - 0.5;
|
||||
var i = (int)System.Math.Floor(p); var f = (float)(p - i);
|
||||
var a = System.Math.Clamp(i, 0, Resolution - 1) * 4;
|
||||
var b = System.Math.Clamp(i + 1, 0, Resolution - 1) * 4;
|
||||
return new(Vector3.Lerp(new(Rgba[a], Rgba[a + 1], Rgba[a + 2]), new(Rgba[b], Rgba[b + 1], Rgba[b + 2]), f), Rgba[a + 3] * (1 - f) + Rgba[b + 3] * f);
|
||||
}
|
||||
|
||||
private static double Noise(double x)
|
||||
{
|
||||
var i = (long)System.Math.Floor(x); var f = x - i;
|
||||
f = f * f * (3 - 2 * f);
|
||||
static double H(long n) => (DeterministicRandom.Mix(unchecked((ulong)n)) >> 11) * (2d / (1UL << 53)) - 1;
|
||||
return H(i) * (1 - f) + H(i + 1) * f;
|
||||
}
|
||||
private static Vector3 Vector(float[] color) => new(color[0], color[1], color[2]);
|
||||
}
|
||||
90
src/Universe.Core/Generation/RingRockStreamer.cs
Normal file
90
src/Universe.Core/Generation/RingRockStreamer.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Numerics;
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public readonly record struct RingCell(long X, long Z);
|
||||
public readonly record struct RingRock(DVector3 Position, double Radius, Vector3 Color, Quaternion Rotation, float ShapeSeed);
|
||||
public sealed record RingRockFrame(DVector3 Anchor, IReadOnlyList<RingRock> Rocks, int Revision);
|
||||
|
||||
/// <summary>Deterministic local debris cells in the ring's own frame; distant cells are evicted.</summary>
|
||||
public sealed class RingRockStreamer(RingProfile profile)
|
||||
{
|
||||
private readonly Dictionary<RingCell, RingRock[]> _cells = [];
|
||||
private RingCell _lastCell = new(long.MinValue, long.MinValue);
|
||||
private long _lastHeight = long.MinValue;
|
||||
private RingRockFrame _frame = new(DVector3.Zero, [], 0);
|
||||
public int CachedCells => _cells.Count;
|
||||
public double DistanceToRing(DVector3 camera)
|
||||
{
|
||||
var r = System.Math.Sqrt(camera.X * camera.X + camera.Z * camera.Z);
|
||||
var dr = r < profile.Rings.InnerRadiusMeters ? profile.Rings.InnerRadiusMeters - r : System.Math.Max(0, r - profile.Rings.OuterRadiusMeters);
|
||||
var dy = System.Math.Max(0, System.Math.Abs(camera.Y) - profile.Rings.ThicknessMeters * 0.5);
|
||||
return System.Math.Sqrt(dr * dr + dy * dy);
|
||||
}
|
||||
|
||||
public RingRockFrame Update(DVector3 camera)
|
||||
{
|
||||
var options = profile.Rings.Rocks;
|
||||
if (!options.Enabled || DistanceToRing(camera) > options.StreamRadiusMeters)
|
||||
{
|
||||
if (_frame.Rocks.Count > 0 || _cells.Count > 0)
|
||||
{ _cells.Clear(); _lastCell = new(long.MinValue, long.MinValue); _lastHeight = long.MinValue; _frame = new(DVector3.Zero, [], _frame.Revision + 1); }
|
||||
return _frame;
|
||||
}
|
||||
var cell = new RingCell((long)System.Math.Floor(camera.X / options.CellSizeMeters), (long)System.Math.Floor(camera.Z / options.CellSizeMeters));
|
||||
var height = (long)System.Math.Floor(camera.Y / options.CellSizeMeters);
|
||||
if (cell == _lastCell && height == _lastHeight) return _frame;
|
||||
_lastCell = cell; _lastHeight = height;
|
||||
var radius = (int)System.Math.Ceiling(options.StreamRadiusMeters / options.CellSizeMeters) + 1;
|
||||
var candidates = new List<RingCell>();
|
||||
for (var z = -radius; z <= radius; z++) for (var x = -radius; x <= radius; x++) candidates.Add(new(cell.X + x, cell.Z + z));
|
||||
double Distance(RingCell c)
|
||||
{
|
||||
var x = (c.X + 0.5) * options.CellSizeMeters - camera.X; var z = (c.Z + 0.5) * options.CellSizeMeters - camera.Z;
|
||||
return x * x + z * z;
|
||||
}
|
||||
candidates.Sort((a, b) => { var d = Distance(a).CompareTo(Distance(b)); return d != 0 ? d : a.X != b.X ? a.X.CompareTo(b.X) : a.Z.CompareTo(b.Z); });
|
||||
var result = new List<RingRock>(System.Math.Min(options.MaximumInstances, 50000));
|
||||
var range = (options.StreamRadiusMeters + options.CellSizeMeters) * (options.StreamRadiusMeters + options.CellSizeMeters);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (Distance(candidate) > range) continue;
|
||||
if (!_cells.TryGetValue(candidate, out var rocks)) _cells.Add(candidate, rocks = GenerateCell(candidate));
|
||||
foreach (var rock in rocks)
|
||||
{
|
||||
if ((rock.Position - camera).LengthSquared > range) continue;
|
||||
result.Add(rock);
|
||||
if (result.Count >= options.MaximumInstances) break;
|
||||
}
|
||||
if (result.Count >= options.MaximumInstances) break;
|
||||
}
|
||||
foreach (var key in _cells.Keys.Where(c => System.Math.Abs(c.X - cell.X) > radius + 1 || System.Math.Abs(c.Z - cell.Z) > radius + 1).ToArray()) _cells.Remove(key);
|
||||
_frame = new(new(cell.X * options.CellSizeMeters, 0, cell.Z * options.CellSizeMeters), result, _frame.Revision + 1);
|
||||
return _frame;
|
||||
}
|
||||
|
||||
public RingRock[] GenerateCell(RingCell cell)
|
||||
{
|
||||
var rings = profile.Rings; var options = rings.Rocks;
|
||||
var seed = DeterministicRandom.Mix(rings.Seed ^ unchecked((ulong)cell.X) * 0xD6E8FEB86659FD93UL ^ unchecked((ulong)cell.Z) * 0xA5A3564E27F8862FUL);
|
||||
var random = new DeterministicRandom(seed);
|
||||
var result = new List<RingRock>();
|
||||
var min = new Vector3(options.MinColor[0], options.MinColor[1], options.MinColor[2]);
|
||||
var max = new Vector3(options.MaxColor[0], options.MaxColor[1], options.MaxColor[2]);
|
||||
for (var i = 0; i < options.ParticlesPerCell; i++)
|
||||
{
|
||||
var x = (cell.X + random.NextDouble()) * options.CellSizeMeters;
|
||||
var z = (cell.Z + random.NextDouble()) * options.CellSizeMeters;
|
||||
var radius = System.Math.Sqrt(x * x + z * z);
|
||||
var material = profile.Sample(radius);
|
||||
if (material.OpticalDepth <= 0 || random.NextDouble() >= 1 - System.Math.Exp(-material.OpticalDepth * options.DensityScale)) continue;
|
||||
var y = (random.NextDouble() - 0.5) * rings.ThicknessMeters;
|
||||
var size = options.MinRadiusMeters + (options.MaxRadiusMeters - options.MinRadiusMeters) * System.Math.Pow(random.NextDouble(), options.SizeExponent);
|
||||
var color = Vector3.Lerp(min, max, (float)random.NextDouble());
|
||||
var rotation = Quaternion.CreateFromYawPitchRoll((float)(random.NextDouble() * System.Math.Tau), (float)(random.NextDouble() * System.Math.Tau), (float)(random.NextDouble() * System.Math.Tau));
|
||||
result.Add(new(new(x, y, z), size, color, rotation, (float)random.NextDouble()));
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
}
|
||||
28
src/Universe.Core/Generation/StarGlowSettings.cs
Normal file
28
src/Universe.Core/Generation/StarGlowSettings.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed record StarGlowSettings
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public double Intensity { get; init; } = 1.8;
|
||||
public double ReferenceDistanceAu { get; init; } = 1;
|
||||
public double DistanceFalloff { get; init; } = .65;
|
||||
public double ApparentRadiusMultiplier { get; init; } = 1;
|
||||
public double MinimumCorePixels { get; init; } = 1.2;
|
||||
public double HaloRadiusDegrees { get; init; } = 1.4;
|
||||
public double HaloStrength { get; init; } = .30;
|
||||
public int RayCount { get; init; } = 10;
|
||||
public double RayLengthDegrees { get; init; } = 4.5;
|
||||
public double RaySharpness { get; init; } = 70;
|
||||
public double RayStrength { get; init; } = 1.2;
|
||||
public double GhostStrength { get; init; } = .14;
|
||||
public double GhostSpacing { get; init; } = .42;
|
||||
public double ChromaticAberration { get; init; } = .20;
|
||||
|
||||
public double Attenuation(double distanceMeters, double luminositySolar)
|
||||
{
|
||||
if (!Enabled) return 0;
|
||||
var distanceAu = System.Math.Max(0, distanceMeters / World.UniverseScale.AstronomicalUnitMeters);
|
||||
return Intensity * System.Math.Sqrt(System.Math.Max(0, luminositySolar)) /
|
||||
(1 + System.Math.Pow(distanceAu / ReferenceDistanceAu, DistanceFalloff));
|
||||
}
|
||||
}
|
||||
69
src/Universe.Core/Generation/TerrainChunkBuilder.cs
Normal file
69
src/Universe.Core/Generation/TerrainChunkBuilder.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed record TerrainChunkData(float[] Vertices, uint[] Indices)
|
||||
{
|
||||
public int VertexCount => Vertices.Length / 6;
|
||||
}
|
||||
|
||||
/// <summary>Builds one cube-sphere face at a chosen LOD. Six faces form a planet surface.</summary>
|
||||
public static class TerrainChunkBuilder
|
||||
{
|
||||
public static TerrainChunkData BuildFace(PlanetData planet, int face, int segments)
|
||||
{
|
||||
if ((uint)face >= 6)
|
||||
throw new ArgumentOutOfRangeException(nameof(face));
|
||||
if (segments < 2 || segments > 256)
|
||||
throw new ArgumentOutOfRangeException(nameof(segments));
|
||||
|
||||
var side = segments + 1;
|
||||
var vertices = new float[side * side * 6];
|
||||
var indices = new uint[segments * segments * 6];
|
||||
var write = 0;
|
||||
|
||||
for (var y = 0; y <= segments; y++)
|
||||
for (var x = 0; x <= segments; x++)
|
||||
{
|
||||
var u = x / (double)segments * 2 - 1;
|
||||
var v = y / (double)segments * 2 - 1;
|
||||
var direction = FaceDirection(face, u, v).Normalized();
|
||||
var position = direction * TerrainHeight.Sample(planet, direction);
|
||||
var normal = direction;
|
||||
vertices[write++] = (float)position.X;
|
||||
vertices[write++] = (float)position.Y;
|
||||
vertices[write++] = (float)position.Z;
|
||||
vertices[write++] = (float)normal.X;
|
||||
vertices[write++] = (float)normal.Y;
|
||||
vertices[write++] = (float)normal.Z;
|
||||
}
|
||||
|
||||
write = 0;
|
||||
for (var y = 0; y < segments; y++)
|
||||
for (var x = 0; x < segments; x++)
|
||||
{
|
||||
var topLeft = (uint)(y * side + x);
|
||||
var topRight = topLeft + 1;
|
||||
var bottomLeft = topLeft + (uint)side;
|
||||
var bottomRight = bottomLeft + 1;
|
||||
indices[write++] = topLeft;
|
||||
indices[write++] = bottomLeft;
|
||||
indices[write++] = topRight;
|
||||
indices[write++] = topRight;
|
||||
indices[write++] = bottomLeft;
|
||||
indices[write++] = bottomRight;
|
||||
}
|
||||
|
||||
return new(vertices, indices);
|
||||
}
|
||||
|
||||
public static DVector3 FaceDirection(int face, double u, double v) => face switch
|
||||
{
|
||||
0 => new(1, v, -u),
|
||||
1 => new(-1, v, u),
|
||||
2 => new(u, 1, -v),
|
||||
3 => new(u, -1, v),
|
||||
4 => new(u, v, 1),
|
||||
_ => new(-u, v, -1)
|
||||
};
|
||||
}
|
||||
76
src/Universe.Core/Generation/TerrainHeight.cs
Normal file
76
src/Universe.Core/Generation/TerrainHeight.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Domain-warped ridged multifractal terrain, shared by collision, meshes and GLSL.</summary>
|
||||
public static class TerrainHeight
|
||||
{
|
||||
public static double Sample(PlanetData planet, DVector3 direction)
|
||||
{
|
||||
var unitDirection = direction.Normalized();
|
||||
if (unitDirection == DVector3.Zero) unitDirection = new(0, 1, 0);
|
||||
var bodyDirection = planet.Orientation.ToBody(unitDirection);
|
||||
var terrain = planet.Terrain;
|
||||
var map = terrain.HeightMap?.Sample(bodyDirection) ?? 0;
|
||||
var mappedHeight = terrain.HeightMap is null ? 0 : terrain.HeightmapMinMeters
|
||||
+ (terrain.HeightmapMaxMeters - terrain.HeightmapMinMeters) * System.Math.Pow(map, terrain.HeightmapGamma);
|
||||
if (terrain.RegionalHeight is { } region)
|
||||
{
|
||||
var (regionalHeight, weight) = region.Sample(bodyDirection);
|
||||
mappedHeight += (regionalHeight - mappedHeight) * weight;
|
||||
}
|
||||
var mask = 1d;
|
||||
if (terrain.NoiseMask == TerrainNoiseMask.HeightmapLand && terrain.HeightMap is not null)
|
||||
{
|
||||
var t = System.Math.Clamp((map - terrain.LandThreshold) / terrain.LandBlend, 0, 1);
|
||||
mask = t * t * (3 - 2 * t);
|
||||
}
|
||||
var noise = planet.TerrainAmplitudeMeters == 0 ? 0 : Elevation(bodyDirection * terrain.NoiseFrequency,
|
||||
ProceduralNoise.SeedOffset(planet.Seed)) * planet.TerrainAmplitudeMeters * mask;
|
||||
var elevation = mappedHeight + noise;
|
||||
if (planet.Ocean is { Enabled: true } ocean && planet.OceanMask is { } water)
|
||||
{
|
||||
var wet = water.Sample(bodyDirection);
|
||||
elevation += (ocean.SeaLevelMeters - ocean.DepthMeters - elevation) * wet;
|
||||
}
|
||||
return planet.RadiusMeters + elevation;
|
||||
}
|
||||
|
||||
public static double Elevation(DVector3 direction, DVector3 offset)
|
||||
{
|
||||
var p = direction * 3.2 + offset;
|
||||
var warp = new DVector3(
|
||||
ProceduralNoise.Sample(p * 0.82 + new DVector3(5.2, 1.3, 9.7)),
|
||||
ProceduralNoise.Sample(p * 0.82 + new DVector3(19.1, 7.7, 2.4)),
|
||||
ProceduralNoise.Sample(p * 0.82 + new DVector3(3.4, 21.8, 11.6)));
|
||||
var continental = ProceduralNoise.Sample(p * 0.47);
|
||||
p = direction * 52 + offset + warp * 2.6;
|
||||
var amplitude = 0.58;
|
||||
var weight = 1d;
|
||||
var sum = 0d;
|
||||
for (var i = 0; i < 7; i++)
|
||||
{
|
||||
var ridge = 1 - System.Math.Abs(ProceduralNoise.GradientSample(p));
|
||||
ridge = ridge * ridge * ridge * weight;
|
||||
sum += ridge * amplitude;
|
||||
weight = 0.55 + 0.45 * ridge;
|
||||
p = ProceduralNoise.RotateOctave(p) * 2.03 + new DVector3(17.1, 9.2, 13.5);
|
||||
amplitude *= 0.50;
|
||||
}
|
||||
return (sum - 0.42) * 0.95 + continental * 0.16;
|
||||
}
|
||||
|
||||
public static DVector3 Normal(PlanetData planet, DVector3 direction, double epsilon = 0.000025)
|
||||
{
|
||||
var d = direction.Normalized();
|
||||
var axis = System.Math.Abs(d.Y) < 0.9 ? new DVector3(0, 1, 0) : new DVector3(1, 0, 0);
|
||||
var tangent = DVector3.Cross(axis, d).Normalized();
|
||||
var bitangent = DVector3.Cross(d, tangent);
|
||||
var a = (d + tangent * epsilon).Normalized();
|
||||
var b = (d - tangent * epsilon).Normalized();
|
||||
var c = (d + bitangent * epsilon).Normalized();
|
||||
var e = (d - bitangent * epsilon).Normalized();
|
||||
var normal = DVector3.Cross(a * Sample(planet, a) - b * Sample(planet, b), c * Sample(planet, c) - e * Sample(planet, e)).Normalized();
|
||||
return DVector3.Dot(normal, d) < 0 ? -normal : normal;
|
||||
}
|
||||
}
|
||||
29
src/Universe.Core/Generation/TerrainLodSelector.cs
Normal file
29
src/Universe.Core/Generation/TerrainLodSelector.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public static class TerrainLodSelector
|
||||
{
|
||||
private static readonly int[] SegmentCounts = [4, 8, 16, 32, 64, 128];
|
||||
|
||||
public static int SelectLevel(double cameraDistanceMeters, double planetRadiusMeters)
|
||||
{
|
||||
if (planetRadiusMeters <= 0 || !double.IsFinite(planetRadiusMeters))
|
||||
throw new ArgumentOutOfRangeException(nameof(planetRadiusMeters));
|
||||
var distanceInRadii = System.Math.Max(0, cameraDistanceMeters) / planetRadiusMeters;
|
||||
return distanceInRadii switch
|
||||
{
|
||||
< 1.35 => 5,
|
||||
< 2.8 => 4,
|
||||
< 6 => 3,
|
||||
< 18 => 2,
|
||||
< 50 => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public static int SegmentsForLevel(int level)
|
||||
{
|
||||
if ((uint)level >= SegmentCounts.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(level));
|
||||
return SegmentCounts[level];
|
||||
}
|
||||
}
|
||||
93
src/Universe.Core/Generation/TerrainPatchBuilder.cs
Normal file
93
src/Universe.Core/Generation/TerrainPatchBuilder.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public readonly record struct TerrainPatchKey(int Face, int Level, int X, int Y)
|
||||
{
|
||||
public double Width => 2d / (1 << Level);
|
||||
public DVector3 Direction => TerrainChunkBuilder.FaceDirection(Face, -1 + (X + 0.5) * Width, -1 + (Y + 0.5) * Width).Normalized();
|
||||
public TerrainPatchKey Child(int index) => new(Face, Level + 1, X * 2 + (index & 1), Y * 2 + (index >> 1));
|
||||
}
|
||||
|
||||
public sealed record TerrainPatchData(DVector3 Origin, float[] Vertices, uint[] Indices)
|
||||
{
|
||||
public DVector3 BoundsCenter { get; init; }
|
||||
public double BoundsRadius { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Camera-relative quad-tree tiles with shared edge normals and recessed seam coverage.</summary>
|
||||
public static class TerrainPatchBuilder
|
||||
{
|
||||
public const int Segments = 24;
|
||||
|
||||
public static TerrainPatchData Build(PlanetData planet, TerrainPatchKey key)
|
||||
{
|
||||
var side = Segments + 1;
|
||||
var expanded = side + 2;
|
||||
var positions = new DVector3[expanded * expanded];
|
||||
var origin = key.Direction * planet.RadiusMeters;
|
||||
var startU = -1 + key.X * key.Width;
|
||||
var startV = -1 + key.Y * key.Width;
|
||||
for (var y = -1; y <= side; y++)
|
||||
for (var x = -1; x <= side; x++)
|
||||
{
|
||||
var d = TerrainChunkBuilder.FaceDirection(key.Face, startU + key.Width * x / Segments, startV + key.Width * y / Segments).Normalized();
|
||||
positions[(y + 1) * expanded + x + 1] = d * TerrainHeight.Sample(planet, d);
|
||||
}
|
||||
var vertices = new List<float>((side * side + side * 4) * 9);
|
||||
for (var y = 0; y < side; y++)
|
||||
for (var x = 0; x < side; x++)
|
||||
{
|
||||
var index = (y + 1) * expanded + x + 1;
|
||||
var p = positions[index];
|
||||
var n = DVector3.Cross(positions[index + 1] - positions[index - 1], positions[index + expanded] - positions[index - expanded]).Normalized();
|
||||
if (DVector3.Dot(n, p) < 0) n = -n;
|
||||
AddVertex(vertices, p - origin, n, p.Normalized());
|
||||
}
|
||||
var indices = new List<uint>(Segments * Segments * 6 + Segments * 24);
|
||||
for (var y = 0; y < Segments; y++)
|
||||
for (var x = 0; x < Segments; x++)
|
||||
{
|
||||
var a = (uint)(y * side + x);
|
||||
indices.AddRange([a, a + (uint)side, a + 1, a + 1, a + (uint)side, a + (uint)side + 1]);
|
||||
}
|
||||
var spacing = planet.RadiusMeters * key.Width / Segments;
|
||||
// A fixed fraction of mountain amplitude creates hundred-metre walls around
|
||||
// metre-sized tiles. Keep enough overlap for seams while shrinking it with LOD.
|
||||
var skirtDepth = spacing * 2 + System.Math.Min(planet.TerrainAmplitudeMeters * .012, spacing * 2);
|
||||
for (var edge = 0; edge < 4; edge++)
|
||||
{
|
||||
var skirtStart = (uint)(vertices.Count / 9);
|
||||
for (var t = 0; t < side; t++)
|
||||
{
|
||||
var source = edge switch { 0 => t, 1 => t * side + Segments, 2 => Segments * side + t, _ => t * side };
|
||||
var p = new DVector3(vertices[source * 9], vertices[source * 9 + 1], vertices[source * 9 + 2]) + origin;
|
||||
var n = new DVector3(vertices[source * 9 + 3], vertices[source * 9 + 4], vertices[source * 9 + 5]);
|
||||
var d = p.Normalized();
|
||||
AddVertex(vertices, p - d * skirtDepth - origin, n, d);
|
||||
if (t == Segments) continue;
|
||||
var next = source + (edge is 0 or 2 ? 1 : side);
|
||||
var b = skirtStart + (uint)t;
|
||||
indices.AddRange([(uint)source, b, (uint)next, (uint)next, b, b + 1]);
|
||||
}
|
||||
}
|
||||
var vertexData = vertices.ToArray();
|
||||
var min = new DVector3(double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
|
||||
var max = new DVector3(double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity);
|
||||
for (var i = 0; i < vertexData.Length; i += 9)
|
||||
{
|
||||
min = new(System.Math.Min(min.X, vertexData[i]), System.Math.Min(min.Y, vertexData[i + 1]), System.Math.Min(min.Z, vertexData[i + 2]));
|
||||
max = new(System.Math.Max(max.X, vertexData[i]), System.Math.Max(max.Y, vertexData[i + 1]), System.Math.Max(max.Z, vertexData[i + 2]));
|
||||
}
|
||||
var center = (min + max) * .5;
|
||||
var radius = 0d;
|
||||
for (var i = 0; i < vertexData.Length; i += 9)
|
||||
radius = System.Math.Max(radius, (new DVector3(vertexData[i], vertexData[i + 1], vertexData[i + 2]) - center).Length);
|
||||
return new(origin, vertexData, indices.ToArray()) { BoundsCenter = origin + center, BoundsRadius = radius + .01 };
|
||||
}
|
||||
|
||||
private static void AddVertex(List<float> target, DVector3 p, DVector3 n, DVector3 d)
|
||||
{
|
||||
target.AddRange([(float)p.X, (float)p.Y, (float)p.Z, (float)n.X, (float)n.Y, (float)n.Z, (float)d.X, (float)d.Y, (float)d.Z]);
|
||||
}
|
||||
}
|
||||
80
src/Universe.Core/Generation/TerrainVisibility.cs
Normal file
80
src/Universe.Core/Generation/TerrainVisibility.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
/// <summary>Conservative horizon and distance estimates for cube-sphere patches.</summary>
|
||||
public static class TerrainVisibility
|
||||
{
|
||||
public static double InnerRadius(PlanetData planet)
|
||||
{
|
||||
var mapFloor = planet.Terrain.HeightMap is null ? 0 : System.Math.Min(0, planet.Terrain.HeightmapMinMeters);
|
||||
mapFloor = System.Math.Min(mapFloor, planet.Terrain.RegionalHeight?.MinimumMeters ?? 0);
|
||||
// The procedural terrain is bounded by less than 1.2 times its amplitude.
|
||||
var floor = planet.RadiusMeters + mapFloor - planet.TerrainAmplitudeMeters * 1.2;
|
||||
if (planet.Ocean is { Enabled: true } ocean && planet.OceanMask is not null)
|
||||
floor = System.Math.Min(floor, planet.RadiusMeters + ocean.SeaLevelMeters - ocean.DepthMeters);
|
||||
return System.Math.Max(0, floor);
|
||||
}
|
||||
|
||||
public static double HorizonAngle(DVector3 camera, double innerRadius, double outerRadius)
|
||||
{
|
||||
var distance = camera.Length;
|
||||
if (innerRadius <= 0 || outerRadius < innerRadius || distance <= innerRadius) return System.Math.PI;
|
||||
return System.Math.Acos(System.Math.Clamp(innerRadius / distance, 0, 1))
|
||||
+ System.Math.Acos(System.Math.Clamp(innerRadius / outerRadius, 0, 1));
|
||||
}
|
||||
|
||||
public static bool BehindHorizon(TerrainPatchKey patch, DVector3 cameraDirection, double horizonAngle)
|
||||
{
|
||||
// Normalizing a cube-face point is at most 1-Lipschitz in face coordinates.
|
||||
// The centre-to-corner distance is width/sqrt(2), so this cap contains the whole patch.
|
||||
var angularRadius = patch.Width * .708;
|
||||
var limit = horizonAngle + angularRadius + .001;
|
||||
if (limit >= System.Math.PI) return false;
|
||||
return DVector3.Dot(cameraDirection, patch.Direction) < System.Math.Cos(limit);
|
||||
}
|
||||
|
||||
public static bool OutsideFrustum(TerrainPatchKey key, DVector3 camera, DVector3 forward, DVector3 right, DVector3 up,
|
||||
double verticalTan, double aspect, double lowerRadius, double upperRadius)
|
||||
{
|
||||
var direction = key.Direction;
|
||||
var midpoint = (upperRadius + lowerRadius) * .5;
|
||||
var radialExtent = (upperRadius - lowerRadius) * .5;
|
||||
var tangentExtent = upperRadius * key.Width * .708;
|
||||
var toPatch = direction * midpoint - camera;
|
||||
bool Outside(DVector3 plane)
|
||||
{
|
||||
var bound = tangentExtent * plane.Length + radialExtent * System.Math.Abs(DVector3.Dot(direction, plane));
|
||||
return DVector3.Dot(toPatch, plane) < -bound;
|
||||
}
|
||||
var horizontalTan = verticalTan * aspect;
|
||||
return Outside(forward) || Outside(forward * verticalTan + up) || Outside(forward * verticalTan - up)
|
||||
|| Outside(forward * horizontalTan + right) || Outside(forward * horizontalTan - right);
|
||||
}
|
||||
|
||||
public static double DistanceToPatch(TerrainPatchKey key, DVector3 camera, double radius)
|
||||
{
|
||||
// Project the camera onto the cube face, then clamp to the patch's actual rectangle.
|
||||
// Unlike a bounding sphere, this distinguishes the tile underfoot from its neighbours.
|
||||
var (denominator, u, v) = key.Face switch
|
||||
{
|
||||
0 => (camera.X, -camera.Z, camera.Y), 1 => (-camera.X, camera.Z, camera.Y),
|
||||
2 => (camera.Y, camera.X, -camera.Z), 3 => (-camera.Y, camera.X, camera.Z),
|
||||
4 => (camera.Z, camera.X, camera.Y), _ => (-camera.Z, -camera.X, camera.Y)
|
||||
};
|
||||
if (denominator <= 0) return (camera - key.Direction * radius).Length;
|
||||
var startU = -1 + key.X * key.Width;
|
||||
var startV = -1 + key.Y * key.Width;
|
||||
var nearestU = System.Math.Clamp(u / denominator, startU, startU + key.Width);
|
||||
var nearestV = System.Math.Clamp(v / denominator, startV, startV + key.Width);
|
||||
var direction = TerrainChunkBuilder.FaceDirection(key.Face, nearestU, nearestV).Normalized();
|
||||
return (camera - direction * radius).Length;
|
||||
}
|
||||
|
||||
public static double PixelTarget(double distanceMeters)
|
||||
{
|
||||
var t = System.Math.Clamp((distanceMeters - 500) / 15_000, 0, 1);
|
||||
t = t * t * (3 - 2 * t);
|
||||
return 2.5 + t * 9.5;
|
||||
}
|
||||
}
|
||||
152
src/Universe.Core/Generation/UniverseGenerator.cs
Normal file
152
src/Universe.Core/Generation/UniverseGenerator.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Numerics;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
using Universe.Core.Configuration;
|
||||
|
||||
namespace Universe.Core.Generation;
|
||||
|
||||
public sealed class UniverseGenerator
|
||||
{
|
||||
private readonly KnownSolarSystemCatalog? _knownSystems;
|
||||
private static readonly Vector3[] StarColors =
|
||||
[new(1f, 0.83f, 0.62f), new(1f, 0.94f, 0.83f), new(0.72f, 0.84f, 1f), new(1f, 0.56f, 0.32f)];
|
||||
private static readonly Vector3[] PlanetColors =
|
||||
[new(0.38f, 0.29f, 0.21f), new(0.30f, 0.32f, 0.29f), new(0.43f, 0.26f, 0.16f), new(0.26f, 0.30f, 0.35f), new(0.47f, 0.41f, 0.30f)];
|
||||
private static readonly Vector3[] AtmosphereColors =
|
||||
[new(0.13f, 0.32f, 1f), new(0.16f, 0.42f, 1f), new(0.09f, 0.22f, 0.88f), new(0.80f, 0.48f, 0.25f)];
|
||||
|
||||
public UniverseGenerator(ulong universeSeed, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters, KnownSolarSystemCatalog? knownSystems = null)
|
||||
{
|
||||
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
|
||||
UniverseSeed = universeSeed;
|
||||
CellSizeMeters = cellSizeMeters;
|
||||
_knownSystems = knownSystems;
|
||||
}
|
||||
|
||||
public ulong UniverseSeed { get; }
|
||||
public double CellSizeMeters { get; }
|
||||
|
||||
public StarSystemData Generate(SectorCoordinate coordinate)
|
||||
{
|
||||
if (_knownSystems is not null && _knownSystems.TryGet(coordinate, out var known)) return known;
|
||||
var systemSeed = SeedFor(coordinate);
|
||||
var random = new DeterministicRandom(systemSeed);
|
||||
var noiseOffset = ProceduralNoise.SeedOffset(systemSeed);
|
||||
// Jitter the barycentre inside the cell in all three axes, without moving bodies out of it.
|
||||
var barycentre = new DVector3(
|
||||
ProceduralNoise.Sample(noiseOffset + new DVector3(7.1, 1.4, 9.2)),
|
||||
ProceduralNoise.Sample(noiseOffset + new DVector3(17.7, 6.3, 3.1)),
|
||||
ProceduralNoise.Sample(noiseOffset + new DVector3(2.6, 22.3, 11.8))) * (CellSizeMeters * 0.18);
|
||||
var starCount = random.NextDouble() < 0.46 ? 2 : 1;
|
||||
var binaryAxis = UnitVector(ref random);
|
||||
var separation = (0.025 + random.NextDouble() * 0.17) * UniverseScale.AstronomicalUnitMeters;
|
||||
var massRatio = 0.25 + random.NextDouble() * 0.6;
|
||||
var stars = new List<StarData>(starCount);
|
||||
for (var i = 0; i < starCount; i++)
|
||||
{
|
||||
var scale = 0.65 + random.NextDouble() * 0.85;
|
||||
var position = starCount == 1 ? barycentre : barycentre + binaryAxis * separation * (i == 0 ? -massRatio : 1 - massRatio);
|
||||
stars.Add(new(i, $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} Star {i + 1}", position,
|
||||
UniverseScale.SolarRadiusMeters * scale, System.Math.Pow(scale, 3.5),
|
||||
StarColors[random.NextInt(0, StarColors.Length)], DeterministicRandom.Mix(systemSeed + (ulong)i + 1))
|
||||
{ Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:star:{i}" });
|
||||
}
|
||||
|
||||
var planetCount = random.NextInt(5, 10);
|
||||
var planets = new List<PlanetData>(planetCount);
|
||||
var orbitAu = 0.38 + random.NextDouble() * 0.48;
|
||||
var planeNormal = UnitVector(ref random);
|
||||
var barycentreExtent = System.Math.Max(System.Math.Abs(barycentre.X), System.Math.Max(System.Math.Abs(barycentre.Y), System.Math.Abs(barycentre.Z)));
|
||||
var maximumOrbit = (CellSizeMeters * 0.47 - barycentreExtent) / UniverseScale.AstronomicalUnitMeters / 1.32;
|
||||
for (var i = 0; i < planetCount; i++)
|
||||
{
|
||||
if (i > 0) orbitAu *= 1.38 + random.NextDouble() * 0.57;
|
||||
if (orbitAu > maximumOrbit) break;
|
||||
var seed = DeterministicRandom.Mix(systemSeed ^ (ulong)(i + 0x100));
|
||||
var orbitNormal = (planeNormal + UnitVector(ref random) * (0.12 + random.NextDouble() * 0.48)).Normalized();
|
||||
var reference = System.Math.Abs(orbitNormal.Y) < 0.9 ? new DVector3(0, 1, 0) : new DVector3(1, 0, 0);
|
||||
var u = DVector3.Cross(orbitNormal, reference).Normalized();
|
||||
var v = DVector3.Cross(orbitNormal, u);
|
||||
var phase = random.NextDouble() * System.Math.Tau;
|
||||
var eccentricity = 0.03 + random.NextDouble() * 0.28;
|
||||
var orbitRadius = orbitAu * UniverseScale.AstronomicalUnitMeters;
|
||||
var distance = orbitRadius * (1 - eccentricity * eccentricity) / (1 + eccentricity * System.Math.Cos(phase));
|
||||
var orbitalNoise = ProceduralNoise.Sample(noiseOffset + new DVector3(i * 3.7, phase, 8.1));
|
||||
var position = barycentre + (u * System.Math.Cos(phase) + v * System.Math.Sin(phase)) * distance
|
||||
+ orbitNormal * (distance * orbitalNoise * 0.045);
|
||||
var radius = UniverseScale.EarthRadiusMeters * (0.55 + random.NextDouble() * 1.05);
|
||||
var weather = new DeterministicRandom(seed ^ 0x57454154484552UL);
|
||||
var atmosphereDensity = .07 + weather.NextDouble() * 1.7;
|
||||
planets.Add(new(i, $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} {RomanNumeral(i + 1)}", position,
|
||||
radius, radius * (0.0045 + random.NextDouble() * 0.0065), radius * (0.018 + random.NextDouble() * 0.012),
|
||||
PlanetColors[random.NextInt(0, PlanetColors.Length)], AtmosphereColors[random.NextInt(0, i == 0 ? 3 : AtmosphereColors.Length)],
|
||||
orbitRadius, phase, 0, seed)
|
||||
{
|
||||
Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:planet:{i}",
|
||||
AtmosphereDensity = atmosphereDensity,
|
||||
Clouds = CloudSettings.ForAtmosphere(seed, radius * .018, atmosphereDensity, PlanetSurfaceType.Rocky)
|
||||
});
|
||||
}
|
||||
var primaryPlanets = planets.ToArray();
|
||||
var moonCount = 0;
|
||||
foreach (var parent in primaryPlanets)
|
||||
{
|
||||
if (moonCount >= 3 || (parent.Index != 0 && random.NextDouble() > 0.30)) continue;
|
||||
var moonSeed = DeterministicRandom.Mix(parent.Seed ^ 0x4D4F4F4EUL);
|
||||
var moonDirection = UnitVector(ref random);
|
||||
var moonRadius = parent.RadiusMeters * (0.25 + random.NextDouble() * 0.16);
|
||||
var moonOrbit = parent.RadiusMeters * (6.0 + random.NextDouble() * 3.0);
|
||||
var atmosphereHeight = parent.Index == 0 || random.NextDouble() < 0.4 ? moonRadius * 0.018 : 0;
|
||||
planets.Add(new(planets.Count, parent.Name + " a", parent.PositionMeters + moonDirection * moonOrbit,
|
||||
moonRadius, moonRadius * 0.009, atmosphereHeight, new(0.39f, 0.21f, 0.13f), new(0.16f, 0.30f, 0.82f),
|
||||
moonOrbit, random.NextDouble() * System.Math.Tau, parent.HostStarIndex, moonSeed)
|
||||
{
|
||||
ParentPlanetIndex = parent.Index, Id = parent.Id + ":moon", AtmosphereDensity = .18,
|
||||
Clouds = CloudSettings.ForAtmosphere(moonSeed, atmosphereHeight, .18, PlanetSurfaceType.Rocky)
|
||||
});
|
||||
moonCount++;
|
||||
}
|
||||
return new(coordinate, systemSeed, stars, planets);
|
||||
}
|
||||
|
||||
public IEnumerable<StarSystemData> GenerateNeighborhood(SectorCoordinate center, int radius = 1)
|
||||
{
|
||||
if (radius < 0 || radius > 8)
|
||||
throw new ArgumentOutOfRangeException(nameof(radius));
|
||||
for (var z = -radius; z <= radius; z++)
|
||||
for (var y = -radius; y <= radius; y++)
|
||||
for (var x = -radius; x <= radius; x++)
|
||||
if (center.TryOffset(x, y, z, out var coordinate)) yield return Generate(coordinate);
|
||||
}
|
||||
|
||||
public ulong SeedFor(SectorCoordinate coordinate)
|
||||
{
|
||||
var value = DeterministicRandom.Mix(UniverseSeed ^ 0xA0761D6478BD642FUL);
|
||||
value = DeterministicRandom.Mix(value ^ unchecked((ulong)coordinate.X));
|
||||
value = DeterministicRandom.Mix(value ^ unchecked((ulong)coordinate.Y) ^ 0xE7037ED1A0B428DBUL);
|
||||
return DeterministicRandom.Mix(value ^ unchecked((ulong)coordinate.Z) ^ 0x8EBC6AF09C88C6E3UL);
|
||||
}
|
||||
|
||||
private static DVector3 UnitVector(ref DeterministicRandom random)
|
||||
{
|
||||
var y = random.NextDouble() * 2 - 1;
|
||||
var phi = random.NextDouble() * System.Math.Tau;
|
||||
var r = System.Math.Sqrt(1 - y * y);
|
||||
return new(r * System.Math.Cos(phi), y, r * System.Math.Sin(phi));
|
||||
}
|
||||
|
||||
private static string RomanNumeral(int number) => number switch
|
||||
{
|
||||
1 => "I",
|
||||
2 => "II",
|
||||
3 => "III",
|
||||
4 => "IV",
|
||||
5 => "V",
|
||||
6 => "VI",
|
||||
7 => "VII",
|
||||
8 => "VIII",
|
||||
9 => "IX",
|
||||
_ => number.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
32
src/Universe.Core/Math/DVector3.cs
Normal file
32
src/Universe.Core/Math/DVector3.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Universe.Core.Math;
|
||||
|
||||
/// <summary>A small double precision vector for positions measured in metres.</summary>
|
||||
public readonly record struct DVector3(double X, double Y, double Z)
|
||||
{
|
||||
public static DVector3 Zero => new(0, 0, 0);
|
||||
public static DVector3 One => new(1, 1, 1);
|
||||
public double LengthSquared => X * X + Y * Y + Z * Z;
|
||||
public double Length => System.Math.Sqrt(LengthSquared);
|
||||
|
||||
public DVector3 Normalized()
|
||||
{
|
||||
var length = Length;
|
||||
return length > 1e-30 && double.IsFinite(length) ? this / length : Zero;
|
||||
}
|
||||
|
||||
public static double Dot(DVector3 a, DVector3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;
|
||||
public static DVector3 Cross(DVector3 a, DVector3 b) => new(
|
||||
a.Y * b.Z - a.Z * b.Y,
|
||||
a.Z * b.X - a.X * b.Z,
|
||||
a.X * b.Y - a.Y * b.X);
|
||||
|
||||
public static DVector3 Lerp(DVector3 a, DVector3 b, double amount) => a + (b - a) * amount;
|
||||
public static DVector3 operator +(DVector3 a, DVector3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
|
||||
public static DVector3 operator -(DVector3 a, DVector3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
|
||||
public static DVector3 operator -(DVector3 value) => new(-value.X, -value.Y, -value.Z);
|
||||
public static DVector3 operator *(DVector3 value, double scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar);
|
||||
public static DVector3 operator *(double scalar, DVector3 value) => value * scalar;
|
||||
public static DVector3 operator /(DVector3 value, double scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar);
|
||||
|
||||
public System.Numerics.Vector3 ToVector3() => new((float)X, (float)Y, (float)Z);
|
||||
}
|
||||
110
src/Universe.Core/Physics/PlanetCollisionSolver.cs
Normal file
110
src/Universe.Core/Physics/PlanetCollisionSolver.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Core.Physics;
|
||||
|
||||
public readonly record struct CollisionResult(UniversePosition Position, bool Collided, int PlanetIndex);
|
||||
|
||||
/// <summary>Continuous swept-sphere collision against the same procedural surface used for rendering.</summary>
|
||||
public static class PlanetCollisionSolver
|
||||
{
|
||||
public static CollisionResult Sweep(
|
||||
UniversePosition start,
|
||||
UniversePosition end,
|
||||
UniversePosition systemCenter,
|
||||
IReadOnlyList<PlanetData> planets,
|
||||
double cellSizeMeters,
|
||||
double cameraRadiusMeters = 1.5,
|
||||
int samples = 128)
|
||||
{
|
||||
if (samples is < 1 or > 4096) throw new ArgumentOutOfRangeException(nameof(samples));
|
||||
var from = start.RelativeTo(systemCenter, cellSizeMeters);
|
||||
var to = end.RelativeTo(systemCenter, cellSizeMeters);
|
||||
var travel = to - from;
|
||||
var bestT = double.PositiveInfinity;
|
||||
var bestPosition = end;
|
||||
var bestPlanet = -1;
|
||||
|
||||
foreach (var planet in planets)
|
||||
{
|
||||
// Gas giants have an optical volume, not a solid terrain boundary.
|
||||
if (planet.IsGasPlanet) continue;
|
||||
var center = planet.PositionMeters;
|
||||
var relativeStart = from - center;
|
||||
var startSurface = TerrainHeight.Sample(planet, relativeStart) + cameraRadiusMeters;
|
||||
var startDistance = relativeStart.Length;
|
||||
var startClearance = startDistance - startSurface;
|
||||
if (startClearance < -0.05 || (startClearance <= 0 && DVector3.Dot(relativeStart, travel) <= 0))
|
||||
{
|
||||
var normal = relativeStart.Normalized();
|
||||
if (normal == DVector3.Zero)
|
||||
normal = new(0, 1, 0);
|
||||
bestT = 0;
|
||||
bestPosition = systemCenter.Add(center + normal * startSurface, cellSizeMeters);
|
||||
bestPlanet = planet.Index;
|
||||
continue;
|
||||
}
|
||||
|
||||
var a = travel.LengthSquared;
|
||||
if (a < 1e-12)
|
||||
continue;
|
||||
|
||||
var boundingRadius = planet.MaximumTerrainRadiusMeters + cameraRadiusMeters;
|
||||
var c = relativeStart.LengthSquared - boundingRadius * boundingRadius;
|
||||
var b = 2 * DVector3.Dot(relativeStart, travel);
|
||||
var discriminant = b * b - 4 * a * c;
|
||||
if (discriminant < 0)
|
||||
continue;
|
||||
|
||||
var root = System.Math.Sqrt(discriminant);
|
||||
var enter = System.Math.Max(0, (-b - root) / (2 * a));
|
||||
var exit = System.Math.Min(1, (-b + root) / (2 * a));
|
||||
if (enter > exit || enter >= bestT)
|
||||
continue;
|
||||
|
||||
var previousT = enter;
|
||||
var previousClearance = ClearanceAt(planet, relativeStart + travel * previousT, cameraRadiusMeters);
|
||||
for (var sample = 1; sample <= samples; sample++)
|
||||
{
|
||||
var t = enter + (exit - enter) * sample / samples;
|
||||
var relative = relativeStart + travel * t;
|
||||
var clearance = ClearanceAt(planet, relative, cameraRadiusMeters);
|
||||
if (clearance <= 0 && previousClearance > 0)
|
||||
{
|
||||
var low = previousT;
|
||||
var high = t;
|
||||
for (var iteration = 0; iteration < 28; iteration++)
|
||||
{
|
||||
var middle = (low + high) * 0.5;
|
||||
var point = relativeStart + travel * middle;
|
||||
if (ClearanceAt(planet, point, cameraRadiusMeters) > 0)
|
||||
low = middle;
|
||||
else
|
||||
high = middle;
|
||||
}
|
||||
|
||||
var contactT = low;
|
||||
var contactVector = relativeStart + travel * contactT;
|
||||
var normal = contactVector.Normalized();
|
||||
var terrainRadius = TerrainHeight.Sample(planet, normal) + cameraRadiusMeters;
|
||||
bestT = contactT;
|
||||
bestPosition = systemCenter.Add(center + normal * terrainRadius, cellSizeMeters);
|
||||
bestPlanet = planet.Index;
|
||||
break;
|
||||
}
|
||||
previousT = t;
|
||||
previousClearance = clearance;
|
||||
}
|
||||
}
|
||||
|
||||
return bestPlanet >= 0
|
||||
? new(bestPosition, true, bestPlanet)
|
||||
: new(end, false, -1);
|
||||
}
|
||||
|
||||
private static double ClearanceAt(PlanetData planet, DVector3 relative, double cameraRadiusMeters)
|
||||
{
|
||||
return relative.Length - TerrainHeight.Sample(planet, relative) - cameraRadiusMeters;
|
||||
}
|
||||
}
|
||||
14
src/Universe.Core/Universe.Core.csproj
Normal file
14
src/Universe.Core/Universe.Core.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StbImageSharp" Version="2.30.16" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
20
src/Universe.Core/World/SectorCoordinate.cs
Normal file
20
src/Universe.Core/World/SectorCoordinate.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Universe.Core.World;
|
||||
|
||||
public readonly record struct SectorCoordinate(long X, long Y, long Z)
|
||||
{
|
||||
public bool TryOffset(long x, long y, long z, out SectorCoordinate result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = new(checked(X + x), checked(Y + y), checked(Z + z));
|
||||
return true;
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"({X}, {Y}, {Z})";
|
||||
}
|
||||
73
src/Universe.Core/World/UniversePosition.cs
Normal file
73
src/Universe.Core/World/UniversePosition.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// A large-world position. The local offset is always in [0, cellSize) on each axis.
|
||||
/// Keeping the sector separate avoids losing metre precision far from the origin.
|
||||
/// </summary>
|
||||
public readonly record struct UniversePosition(SectorCoordinate Sector, DVector3 LocalOffset)
|
||||
{
|
||||
public static UniversePosition AtCellCenter(SectorCoordinate sector, double cellSizeMeters)
|
||||
{
|
||||
ValidateCellSize(cellSizeMeters);
|
||||
var half = cellSizeMeters * 0.5;
|
||||
return new(sector, new(half, half, half));
|
||||
}
|
||||
|
||||
public UniversePosition Add(DVector3 deltaMeters, double cellSizeMeters)
|
||||
{
|
||||
ValidateCellSize(cellSizeMeters);
|
||||
var (xSector, xOffset) = NormalizeAxis(Sector.X, LocalOffset.X + deltaMeters.X, cellSizeMeters);
|
||||
var (ySector, yOffset) = NormalizeAxis(Sector.Y, LocalOffset.Y + deltaMeters.Y, cellSizeMeters);
|
||||
var (zSector, zOffset) = NormalizeAxis(Sector.Z, LocalOffset.Z + deltaMeters.Z, cellSizeMeters);
|
||||
return new(new(xSector, ySector, zSector), new(xOffset, yOffset, zOffset));
|
||||
}
|
||||
|
||||
/// <summary>Returns this position relative to another position, using exact integer subtraction first.</summary>
|
||||
public DVector3 RelativeTo(UniversePosition origin, double cellSizeMeters)
|
||||
{
|
||||
ValidateCellSize(cellSizeMeters);
|
||||
var dx = (Int128)Sector.X - origin.Sector.X;
|
||||
var dy = (Int128)Sector.Y - origin.Sector.Y;
|
||||
var dz = (Int128)Sector.Z - origin.Sector.Z;
|
||||
return new(
|
||||
(double)dx * cellSizeMeters + LocalOffset.X - origin.LocalOffset.X,
|
||||
(double)dy * cellSizeMeters + LocalOffset.Y - origin.LocalOffset.Y,
|
||||
(double)dz * cellSizeMeters + LocalOffset.Z - origin.LocalOffset.Z);
|
||||
}
|
||||
|
||||
private static (long Sector, double Offset) NormalizeAxis(long sector, double offset, double cellSizeMeters)
|
||||
{
|
||||
if (!double.IsFinite(offset))
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), "Position offsets must be finite.");
|
||||
|
||||
var shiftAsDouble = System.Math.Floor(offset / cellSizeMeters);
|
||||
if (shiftAsDouble < long.MinValue || shiftAsDouble > long.MaxValue)
|
||||
throw new OverflowException("Movement exceeded the supported sector range.");
|
||||
|
||||
var shift = checked((long)shiftAsDouble);
|
||||
var newSector = checked(sector + shift);
|
||||
var newOffset = offset - shiftAsDouble * cellSizeMeters;
|
||||
|
||||
// Floating point division may leave a value infinitesimally outside the canonical range.
|
||||
if (newOffset < 0)
|
||||
{
|
||||
newSector = checked(newSector - 1);
|
||||
newOffset += cellSizeMeters;
|
||||
}
|
||||
else if (newOffset >= cellSizeMeters)
|
||||
{
|
||||
newSector = checked(newSector + 1);
|
||||
newOffset -= cellSizeMeters;
|
||||
}
|
||||
|
||||
return (newSector, newOffset);
|
||||
}
|
||||
|
||||
private static void ValidateCellSize(double cellSizeMeters)
|
||||
{
|
||||
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(cellSizeMeters), "Cell size must be a positive finite number.");
|
||||
}
|
||||
}
|
||||
10
src/Universe.Core/World/UniverseScale.cs
Normal file
10
src/Universe.Core/World/UniverseScale.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Universe.Core.World;
|
||||
|
||||
public static class UniverseScale
|
||||
{
|
||||
public const double AstronomicalUnitMeters = 149_597_870_700d;
|
||||
public const double LightSpeedMetersPerSecond = 299_792_458d;
|
||||
public const double SolarRadiusMeters = 695_700_000d;
|
||||
public const double EarthRadiusMeters = 6_371_000d;
|
||||
public const double DefaultCellSizeMeters = 100d * AstronomicalUnitMeters;
|
||||
}
|
||||
46
src/Universe.Core/World/UniverseStreamer.cs
Normal file
46
src/Universe.Core/World/UniverseStreamer.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Universe.Core.Generation;
|
||||
|
||||
namespace Universe.Core.World;
|
||||
|
||||
public sealed record UniverseNeighborhood(StarSystemData ActiveSystem, IReadOnlyList<StarSystemData> NearbySystems);
|
||||
|
||||
/// <summary>Loads the system under the camera in full and exposes adjacent systems as distant stars.</summary>
|
||||
public sealed class UniverseStreamer(UniverseGenerator generator)
|
||||
{
|
||||
private readonly Dictionary<SectorCoordinate, StarSystemData> _cache = [];
|
||||
|
||||
public UniverseNeighborhood Load(SectorCoordinate activeCoordinate)
|
||||
{
|
||||
var active = GetOrCreate(activeCoordinate);
|
||||
var nearby = new List<StarSystemData>(26);
|
||||
for (var z = -1; z <= 1; z++)
|
||||
for (var y = -1; y <= 1; y++)
|
||||
for (var x = -1; x <= 1; x++)
|
||||
{
|
||||
if (x == 0 && y == 0 && z == 0)
|
||||
continue;
|
||||
if (activeCoordinate.TryOffset(x, y, z, out var coordinate))
|
||||
nearby.Add(GetOrCreate(coordinate));
|
||||
}
|
||||
|
||||
if (_cache.Count > 128)
|
||||
{
|
||||
var keep = new HashSet<SectorCoordinate>(nearby.Select(static system => system.Coordinate)) { activeCoordinate };
|
||||
foreach (var key in _cache.Keys.ToArray())
|
||||
if (!keep.Contains(key))
|
||||
_cache.Remove(key);
|
||||
}
|
||||
|
||||
return new(active, nearby);
|
||||
}
|
||||
|
||||
private StarSystemData GetOrCreate(SectorCoordinate coordinate)
|
||||
{
|
||||
if (!_cache.TryGetValue(coordinate, out var system))
|
||||
{
|
||||
system = generator.Generate(coordinate);
|
||||
_cache.Add(coordinate, system);
|
||||
}
|
||||
return system;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user