screen space reflections vatten och dämpad motion blur på skärnor

This commit is contained in:
2026-11-01 21:46:25 +01:00
parent a7207086b2
commit cb5070dc1c
19 changed files with 480 additions and 55 deletions

View File

@@ -127,7 +127,31 @@ Jordens `ocean`-block aktiverar ett eget vattenpass på planetens havsnivå. Try
| `deepColor`, `shallowColor`, `absorption` | Linjära RGB-färger för djup/grunt vatten och absorption per meter för rött, grönt och blått ljus | | `deepColor`, `shallowColor`, `absorption` | Linjära RGB-färger för djup/grunt vatten och absorption per meter för rött, grönt och blått ljus |
| `foamStrength` | Skum på vågkammar och vid grunda stränder | | `foamStrength` | Skum på vågkammar och vid grunda stränder |
Vågspektrumet består av Gerstner-komponenter som filtreras efter pixelstorleken. Ytan söks med strålar i ett lokalt koordinatsystem förankrat i dubbelprecision, så samma hav fungerar från rymden till våghöjd. Reflektionerna kombinerar en analytisk atmosfärshimmel med skärmreflektioner av synlig terräng; objekt utanför bilden använder himlen som reserv. Refraktionen använder scenens färg och djup från ett separat framebuffer. Havet simulerar ingen fluiddynamik, fartygskölvatten eller bestående skumtransport. Vågspektrumet består av Gerstner-komponenter som filtreras efter pixelstorleken. Ytan söks med strålar i ett lokalt koordinatsystem förankrat i dubbelprecision, så samma hav fungerar från rymden till våghöjd. Refraktionen använder scenens färg och djup från ett separat framebuffer. Havet simulerar ingen fluiddynamik, fartygskölvatten eller bestående skumtransport.
### Skärmrymdsreflektioner på vatten
`ocean.screenSpaceReflections` styr en separat reflektionspassering efter atmosfär och moln. Den söker synlig geometri med perspektivkorrekt interpolation mot det logaritmiska djupet och förfinar träffen i sju binära steg. Där en stråle fortsätter ut mot himlen används den färdiga scenens himmel, inklusive molnkartan och stjärnorna. Havets vågnormaler och Fresnelvikt styr reflektionen; skum minskar den. Färg-mipmaps filtrerar reflektionen efter vattnets `roughness`.
| Fält | Standard | Betydelse |
| --- | --- | --- |
| `enabled` | `true` | Slår på den extra skärmreflektionen. Grundläggande himmel/molnreflektion finns även avstängd. |
| `reflectSky` | `true` | Använd bildens färdiga himmel och avlägsna objekt som reflektionskälla. |
| `strength` | `1` | Inblandning av skärmreflektionen, 0–1. `reflectionStrength` på havet styr hela reflektionsmaterialet. |
| `resolutionScale` | `0.5` | Upplösning 0,25–1. Halv upplösning med djup- och normalstyrd uppskalning är standard. |
| `maxSteps` | `48` | Högst 8–128 djupsöksteg per reflektionspixel. |
| `stridePixels` | `2` | Minsta skärmsteg i fullupplösta bildpunkter; större steg kan missa små objekt. |
| `maxDistanceMeters` | `3000` | Geometrisökningens längd, 10–50000 m. |
| `maxSurfaceDistanceMeters` | `4000` | Största kamera–vatten-avstånd där SSR används, 10–20000 m. Effekten tonas ut innan gränsen. |
| `thicknessMeters` | `1.5` | Tillåten djupmarginal vid träff, 0,01–50 m. |
| `normalBiasMeters` | `0.12` | Startförskjutning från vattenytan för att undvika självträffar, 0,01–5 m. |
| `edgeFade` | `0.07` | Tonar ut träffar nära skärmkanten; värdet är en andel av bildens bredd/höjd. |
Upplösning, raymarch, kantutfasning och uppskalning mäts tillsammans som **WATER REFLECTIONS** i debugpanelen. Från rymden används den billigare grundreflektionen. Reflektioner läggs endast på vatten och befintlig molntransmission dämpar dem när moln skymmer ytan. Vid flera hav används det närmaste havets SSR-inställningar för den lokala vyn.
SSR kan bara träffa geometri som finns i kamerans färg- och djupbild. Dolda objekt och objekt utanför bilden får därför en analytisk himmel/molnreserv, och övergången tonas ut. Det är inte RTX-strålspårning eller en fullständig reflektionskarta av omgivningen. Metoden bygger på perspektivkorrekt skärmsökning enligt [McGuire och Mara, Efficient GPU Screen-Space Ray Tracing](https://jcgt.org/published/0003/04/04/).
`--ocean-report` kör ett kontrollerat väggtest (även att mark inte ändras), sparar dag-/nattbilder med reflektionen av/på samt undervattens- och omloppsvyer. Jämförelser och GPU-tid skrivs till konsolen; bilder hamnar i appens `graphics-report/`.
Tekniken är inspirerad av principerna i [Crests beskrivning](https://github.com/JJarvis89/crest-oceanrender#the-tech), med en egen implementation för OpenGL 3.3. Unity-paketet ingår inte som ett beroende. Tekniken är inspirerad av principerna i [Crests beskrivning](https://github.com/JJarvis89/crest-oceanrender#the-tech), med en egen implementation för OpenGL 3.3. Unity-paketet ingår inte som ett beroende.

View File

@@ -201,6 +201,19 @@
}, },
"ocean": { "ocean": {
"enabled": true, "enabled": true,
"screenSpaceReflections": {
"enabled": true,
"reflectSky": true,
"strength": 1,
"resolutionScale": 0.5,
"maxSteps": 48,
"stridePixels": 2,
"maxDistanceMeters": 3000,
"maxSurfaceDistanceMeters": 4000,
"thicknessMeters": 1.5,
"normalBiasMeters": 0.12,
"edgeFade": 0.07
},
"maskFromSurfaceTexture": true, "maskFromSurfaceTexture": true,
"seaLevelMeters": 0, "seaLevelMeters": 0,
"depthMeters": 4000, "depthMeters": 4000,

View File

@@ -87,11 +87,13 @@ Mushjulet kan nu skala fri flyghastighet upp till galaktiska avstånd. F8 och F9
Stjärnsprites ritas som mjuka ljusstreck från föregående till aktuell skärmposition. Både kamerans förflyttning, rotation och gradvisa synfältsändring ingår; beräkningen använder kamerarelativa dubbelprecisionspositioner även mellan galaxer. Strecken växer med skärmrörelsen och ger fartkänsla när stjärnor passerar. Stilla stjärnor blir mjuka punkter igen utan kvarhängande efterbilder. Galax-/nebulosamarkörer, planetmeshar och gränssnitt ingår inte i effekten. Stjärnsprites ritas som mjuka ljusstreck från föregående till aktuell skärmposition. Både kamerans förflyttning, rotation och gradvisa synfältsändring ingår; beräkningen använder kamerarelativa dubbelprecisionspositioner även mellan galaxer. Strecken växer med skärmrörelsen och ger fartkänsla när stjärnor passerar. Stilla stjärnor blir mjuka punkter igen utan kvarhängande efterbilder. Galax-/nebulosamarkörer, planetmeshar och gränssnitt ingår inte i effekten.
`starMotionBlur` i `config.json` styr `enabled`, `shutterFraction` (hur stor del av bildintervallet som bildar spår), `maximumTrailPixels`, `minimumWidthPixels`, `movingBrightness`, `historyResetSeconds` och `starFadeSeconds`. Standardinställningen är 90 % av bildintervallet och högst 180 bildpunkters spår. Kamerahopp, återställda benchmarkvyer, paus, fönsterstorleksbyte och långa bildintervall nollställer historiken. F5 laddar även om stjärnornas vertex-, geometry- och fragmentshaders. `starMotionBlur` i `config.json` styr `enabled`, `shutterFraction` (hur stor del av bildintervallet som bildar spår), `maximumTrailPixels`, `minimumWidthPixels`, `movingBrightness`, `historyResetSeconds` och `starFadeSeconds`. Standardinställningen är 90 % av bildintervallet och högst 180 bildpunkters spår. Strecken fördelar samma totala HDR-ljus som punkterna över en normaliserad Gaussprofil, så kamerarörelse tillför inte ljus. `movingBrightness` är nu **1** som standard; lägre värden dämpar rörliga stjärnor ytterligare, medan högre värden uttryckligen förstärker dem. Kamerahopp, återställda benchmarkvyer, paus, fönsterstorleksbyte och långa bildintervall nollställer historiken. F5 laddar även om stjärnornas vertex-, geometry- och fragmentshaders.
Det lokala stjärnurvalet förankras över 32 stjärngridblock och tonas mellan färdiga urval under 0,3 sekunder. Det gamla urvalet visas tills ersättaren är klar. Detta minskar blinkningar vid laddning utöver den jämnare pixelprofilen och rörelsespåren. Det lokala stjärnurvalet förankras över 32 stjärngridblock och tonas mellan färdiga urval under 0,3 sekunder. Det gamla urvalet visas tills ersättaren är klar. Detta minskar blinkningar vid laddning utöver den jämnare pixelprofilen och rörelsespåren.
`Universe.App.exe --star-trails-report` kör ett OpenGL-test med syntetiska stjärnor: rörelse måste ge längre streck än stillastående, den konfigurerade maxlängden kontrolleras och stopp/kamerahopp måste ge rena punktbilder. Bilderna sparas som `graphics-report/star-motion-blur.png` och `star-motion-stopped.png`. `Universe.App.exe --star-trails-report` kör ett OpenGL-test med syntetiska stjärnor: rörelse måste ge längre streck än stillastående, den konfigurerade maxlängden kontrolleras och stopp/kamerahopp måste ge rena punktbilder. Testet jämför också integrerat HDR-ljus för rörelser från 0,05 till 300 bildpunkter. Bilderna sparas som `graphics-report/star-motion-blur.png` och `star-motion-stopped.png`.
Vattnet har en separat **WATER REFLECTIONS**-rad i debugprofileringen. Jordens `ocean.screenSpaceReflections` i `KnownSolarSystem/solar-systems.json` aktiverar reflektioner av synlig geometri samt den färdiga himlen med moln och stjärnor. Standard är halv upplösning och högst 48 söksteg. `enabled: false` behåller havets analytiska himmel- och molnreflektion. Se solsystemets dokumentation för inställningar och SSR:s begränsningar. `--ocean-report` testar en väggreflektion på GPU:n, jämför dag/natt med effekten av/på och mäter reflektionspasseringen.
### Galaxresetest ### Galaxresetest

View File

@@ -7,7 +7,7 @@
"shutterFraction": 0.9, "shutterFraction": 0.9,
"maximumTrailPixels": 180, "maximumTrailPixels": 180,
"minimumWidthPixels": 1.4, "minimumWidthPixels": 1.4,
"movingBrightness": 1.8, "movingBrightness": 1.0,
"historyResetSeconds": 0.25, "historyResetSeconds": 0.25,
"starFadeSeconds": 0.3 "starFadeSeconds": 0.3
}, },

View File

@@ -85,7 +85,7 @@ internal static class GraphicsReport
camera.SetPlayerPose(center.Add(new(0,0,-12*scale),cell),camera.Forward,camera.Up); camera.SetPlayerPose(center.Add(new(0,0,-12*scale),cell),camera.Forward,camera.Up);
var moving=Frame(stars); var moving=Frame(stars);
var stopped=Frame(stars); var stopped=Frame(stars);
int Coverage(float[] pixels)=>Enumerable.Range(0,width*height).Count(i=>pixels[i*4]>.035); int Coverage(float[] pixels)=>Enumerable.Range(0,width*height).Count(i=>pixels[i*4]>.001);
var movingCoverage=Coverage(moving);var stillCoverage=Coverage(stopped); var movingCoverage=Coverage(moving);var stillCoverage=Coverage(stopped);
if(moving.Any(p=>!float.IsFinite(p))||movingCoverage<stillCoverage*1.5) if(moving.Any(p=>!float.IsFinite(p))||movingCoverage<stillCoverage*1.5)
throw new InvalidOperationException($"Star trails did not stretch the moving stars: moving {movingCoverage}, stopped {stillCoverage} pixels."); throw new InvalidOperationException($"Star trails did not stretch the moving stars: moving {movingCoverage}, stopped {stillCoverage} pixels.");
@@ -95,10 +95,25 @@ internal static class GraphicsReport
if(trails.History.Valid||cut.Zip(stopped,(a,b)=>System.Math.Abs(a-b)).Max()>.0001) if(trails.History.Valid||cut.Zip(stopped,(a,b)=>System.Math.Abs(a-b)).Max()>.0001)
throw new InvalidOperationException("Star trails retained an afterimage after stopping or a camera cut."); throw new InvalidOperationException("Star trails retained an afterimage after stopping or a camera cut.");
using var single=new GpuMesh(gl,7,[0,0,-10,1,1,1,2]); using var single=new GpuMesh(gl,7,[0,0,-10,1,1,1,2]);
double Energy(float[] pixels) => pixels.Where((_,i)=>i%4<3).Sum(v=>(double)v);
var energyRatios = new List<double>();
foreach (var travel in new[] { .05, 1d, 12d, 80d, 180d, 300d })
{
camera.SetPlayerPose(center,new(0,0,-1),new(0,1,0),resetMotionHistory:true);
var point = Frame(single);
var dx=travel*20*System.Math.Tan(camera.FieldOfViewRadians*.5)/width;
camera.SetPlayerPose(center.Add(new(dx*scale,0,0),cell),camera.Forward,camera.Up);
var streak = Frame(single);
var ratio=Energy(streak)/Energy(point); energyRatios.Add(ratio);
// movingBrightness is an optional artistic multiplier; the default 1 conserves HDR energy.
var expected=1+(settings.MovingBrightness-1)*(1-System.Math.Exp(-System.Math.Min(travel*settings.ShutterFraction,settings.MaximumTrailPixels)/14));
if(System.Math.Abs(ratio/expected-1)>.04)
throw new InvalidOperationException($"Star energy changed with motion: {travel} px, ratio {ratio:F4}, expected {expected:F4}.");
}
camera.SetPlayerPose(center,camera.Forward,camera.Up,resetMotionHistory:true);Frame(single); camera.SetPlayerPose(center,camera.Forward,camera.Up,resetMotionHistory:true);Frame(single);
camera.SetPlayerPose(center.Add(new(-6*scale,0,0),cell),camera.Forward,camera.Up); camera.SetPlayerPose(center.Add(new(-6*scale,0,0),cell),camera.Forward,camera.Up);
var bounded=Frame(single); var bounded=Frame(single);
var columns=Enumerable.Range(0,width).Where(x=>Enumerable.Range(0,height).Any(y=>bounded[(y*width+x)*4]>.035)).ToArray(); var columns=Enumerable.Range(0,width).Where(x=>Enumerable.Range(0,height).Any(y=>bounded[(y*width+x)*4]>.001)).ToArray();
var span=columns.Length==0?0:columns[^1]-columns[0]+1; var span=columns.Length==0?0:columns[^1]-columns[0]+1;
if(span<settings.MaximumTrailPixels*.5||span>settings.MaximumTrailPixels+14) if(span<settings.MaximumTrailPixels*.5||span>settings.MaximumTrailPixels+14)
throw new InvalidOperationException($"Star trail pixel limit failed: span {span}, limit {settings.MaximumTrailPixels}."); throw new InvalidOperationException($"Star trail pixel limit failed: span {span}, limit {settings.MaximumTrailPixels}.");
@@ -106,7 +121,7 @@ internal static class GraphicsReport
var directory=Path.Combine(AppContext.BaseDirectory,"graphics-report");Directory.CreateDirectory(directory); var directory=Path.Combine(AppContext.BaseDirectory,"graphics-report");Directory.CreateDirectory(directory);
WritePng(Path.Combine(directory,"star-motion-blur.png"),width,height,moving); WritePng(Path.Combine(directory,"star-motion-blur.png"),width,height,moving);
WritePng(Path.Combine(directory,"star-motion-stopped.png"),width,height,stopped); WritePng(Path.Combine(directory,"star-motion-stopped.png"),width,height,stopped);
Console.WriteLine($"Star trails passed: {movingCoverage} moving vs {stillCoverage} stationary pixels; capped trail {span} px; camera cut/stop clean; OpenGL {gl.GetStringS(GLEnum.Renderer)}."); Console.WriteLine($"Star trails passed: {movingCoverage} moving vs {stillCoverage} stationary pixels; energy ratios {string.Join(", ",energyRatios.Select(r=>r.ToString("F3")))}; capped trail {span} px; camera cut/stop clean; OpenGL {gl.GetStringS(GLEnum.Renderer)}.");
gl.BindFramebuffer(GLEnum.Framebuffer,0);gl.Disable(GLEnum.Blend);gl.Enable(GLEnum.DepthTest);gl.DepthMask(true); gl.BindFramebuffer(GLEnum.Framebuffer,0);gl.Disable(GLEnum.Blend);gl.Enable(GLEnum.DepthTest);gl.DepthMask(true);
} }
@@ -339,6 +354,7 @@ internal static class GraphicsReport
public static void RunOcean(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height) public static void RunOcean(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{ {
ValidateOceanReflections(gl);
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory); var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
var origin = UniversePosition.AtCellCenter(neighborhood.ActiveSystem.Coordinate, cellSize); var origin = UniversePosition.AtCellCenter(neighborhood.ActiveSystem.Coordinate, cellSize);
var camera = new FlyCamera(origin, cellSize); var camera = new FlyCamera(origin, cellSize);
@@ -362,12 +378,96 @@ internal static class GraphicsReport
var after = Capture("earth-ocean-animated.png"); var after = Capture("earth-ocean-animated.png");
var difference = before.Zip(after, (a, b) => System.Math.Abs(a - b)).Average(); 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."); if (difference < .00001) throw new InvalidOperationException("Ocean animation did not change the rendered image.");
renderer.SimulationTimeSeconds = 12;
UniverseNeighborhood WithoutSsr(UniverseNeighborhood world) => world with { ActiveSystem = world.ActiveSystem with
{ Planets = world.ActiveSystem.Planets.Select(p => p.Ocean is { } ocean ? p with
{ Ocean = ocean with { ScreenSpaceReflections = ocean.ScreenSpaceReflections with { Enabled = false } } } : p).ToArray() } };
float[] Snapshot(UniverseNeighborhood world, string file)
{
camera.SetPlayerPose(camera.Position,camera.Forward,camera.Up,resetMotionHistory:true);
renderer.Render(camera,world,0); gl.Finish();
if(gl.GetError()!=GLEnum.NoError)throw new InvalidOperationException("Ocean SSR GL error.");
var pixels=Read(gl,width,height);WritePng(Path.Combine(directory,file),width,height,pixels,false);return pixels;
}
Snapshot(WithoutSsr(neighborhood),"earth-ocean-reflections-off.png");
var withReflections=Snapshot(neighborhood,"earth-ocean-reflections-on.png");
var withoutReflections=Snapshot(WithoutSsr(neighborhood),"earth-ocean-reflections-off.png");
var reflectionDifference=withReflections.Zip(withoutReflections,(a,b)=>System.Math.Abs(a-b)).Average();
if(reflectionDifference<.0001)throw new InvalidOperationException($"Water reflections did not change the production image: {reflectionDifference}.");
renderer.ProfileFrame=true;Snapshot(neighborhood,"earth-ocean-reflections-on.png");renderer.ProfileFrame=false;
var reflectionMs=renderer.FrameTimings.GetValueOrDefault("water reflections");
var earth=neighborhood.ActiveSystem.Planets.Single(p=>p.Id=="earth");
var up=(camera.Position.RelativeTo(origin,cellSize)-earth.PositionMeters).Normalized();
var night=neighborhood with { ActiveSystem=neighborhood.ActiveSystem with
{ Stars=neighborhood.ActiveSystem.Stars.Select(s=>s with {PositionMeters=earth.PositionMeters-up*UniverseScale.AstronomicalUnitMeters}).ToArray() } };
Snapshot(WithoutSsr(night),"earth-ocean-night-reflections-off.png");
var nightOn=Snapshot(night,"earth-ocean-night-reflections-on.png");
var nightOff=Snapshot(WithoutSsr(night),"earth-ocean-night-reflections-off.png");
var nightDifference=nightOn.Zip(nightOff,(a,b)=>System.Math.Abs(a-b)).Average();
if(nightDifference<.00001)throw new InvalidOperationException("The night sky did not reflect in the water.");
renderer.SimulationTimeSeconds = null;
for (var i = 0; i < 18; i++) camera.Update(.1, -camera.Up, neighborhood); for (var i = 0; i < 18; i++) camera.Update(.1, -camera.Up, neighborhood);
Capture("earth-underwater.png"); Capture("earth-underwater.png");
camera.RequestFocus([neighborhood.ActiveSystem], "earth"); camera.RequestFocus([neighborhood.ActiveSystem], "earth");
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood); for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
Capture("earth-ocean-orbit.png"); 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}"); Console.WriteLine($"Ocean scene passed: finite frames, wave animation delta {difference:G5}, reflection delta {reflectionDifference:G5} day / {nightDifference:G5} night; SSR {reflectionMs:F2} ms at {width}x{height}; underwater and orbit views. Full rendering {timer.Elapsed.TotalMilliseconds / 10:F1} ms/frame. Images: {directory}");
}
private static void ValidateOceanReflections(GL gl)
{
const int width=640,height=360; const float far=100000;
using var source=new RenderTarget(gl,width,height,true,auxiliaryColors:2);
using var reflection=new RenderTarget(gl,width/2,height/2);
using var composite=new RenderTarget(gl,width,height);
using var fullscreen=new GpuMesh(gl,3,[-1,-1,0,3,-1,0,-1,3,0]);
using var fixture=ShaderProgram.Create(gl,ShaderSources.Load("fullscreen.vert"),"""
#version 330 core
in vec2 vUv;
layout(location=0)out vec4 color;
layout(location=1)out vec4 normal;
layout(location=2)out vec4 fallback;
void main(){
vec2 p=(vUv*2.0-1.0)*.67;
vec3 ray=normalize(vec3(p.x*1.7777778,p.y,-1));
float wall=-12.0/ray.z,water=ray.y<0.0?-2.0/ray.y:1e8;
vec3 hit=ray*wall;
bool object=abs(hit.x)<3.0&&hit.y>-2.0&&hit.y<4.0;
float t=object?wall:1e8;
color=vec4(.08,.2,.4,1);normal=vec4(0);fallback=vec4(0);
if(object)color=vec4(2,.04,.02,1);
if(water<t){t=water;color=vec4(.03,.07,.12,1);normal=vec4(0,.7,0,1);fallback=vec4(.03,.07,.12,1);}
gl_FragDepth=t<1e7?log2(1.0-t*ray.z)/log2(100001.0):1.0;
}
""");
source.Bind(allColors:true);gl.Disable(GLEnum.Blend);gl.Enable(GLEnum.DepthTest);gl.DepthFunc(GLEnum.Always);gl.DepthMask(true);
fixture.Use();fullscreen.Draw();source.GenerateColorMipmaps();
source.Bind();gl.Finish();var original=Read(gl,width,height);
using var trace=ShaderProgram.Create(gl,ShaderSources.Load("fullscreen.vert"),ShaderSources.Load("ocean-reflection.frag"));
using var resolve=ShaderProgram.Create(gl,ShaderSources.Load("fullscreen.vert"),ShaderSources.Load("ocean-reflection-composite.frag"));
void Bind(int unit,uint texture){gl.ActiveTexture((GLEnum)((int)GLEnum.Texture0+unit));gl.BindTexture(GLEnum.Texture2D,texture);}
reflection.Bind();gl.Disable(GLEnum.DepthTest);gl.DepthMask(false);trace.Use();
Bind(0,source.Color);Bind(1,source.Depth);Bind(2,source.AuxiliaryColors[0]);
trace.Set("uScene",0);trace.Set("uDepth",1);trace.Set("uWaterNormals",2);trace.Set("uFar",far);
trace.Set("uForward",new Vector3(0,0,-1));trace.Set("uRight",Vector3.UnitX);trace.Set("uUp",Vector3.UnitY);
trace.Set("uAspect",(float)width/height);trace.Set("uTanHalfFov",.67f);
trace.Set("uMaxSteps",48);trace.Set("uTraceSettings",new Vector3(100,1.5f,.12f));
trace.Set("uReflectionSettings",new Vector3(.07f,2,.08f));trace.Set("uMaxSurfaceDistance",1000f);
trace.Set("uReflectionStrength",1f);trace.Set("uReflectSky",0);fullscreen.Draw();gl.Finish();
var raw=Read(gl,width/2,height/2);var offset=(height/2/5*(width/2)+width/4)*4;
if(raw[offset+3]<.4 || raw[offset]<raw[offset+1]*4)
throw new InvalidOperationException($"SSR did not reflect the visible red wall: {string.Join(",",raw.Skip(offset).Take(4))}.");
composite.Bind();resolve.Use();Bind(0,source.Color);Bind(1,source.Depth);Bind(2,source.AuxiliaryColors[0]);
Bind(3,source.AuxiliaryColors[1]);Bind(5,reflection.Color);
resolve.Set("uScene",0);resolve.Set("uDepth",1);resolve.Set("uWaterNormals",2);resolve.Set("uWaterFallback",3);
resolve.Set("uReflection",5);resolve.Set("uClouds",0);resolve.Set("uHasClouds",0);resolve.Set("uFar",far);fullscreen.Draw();gl.Finish();
var result=Read(gl,width,height);
var landError=Enumerable.Range(width*height/2,width*height/2).SelectMany(i=>Enumerable.Range(0,3).Select(c=>System.Math.Abs(original[i*4+c]-result[i*4+c]))).Max();
if(landError>.001 || gl.GetError()!=GLEnum.NoError)throw new InvalidOperationException($"SSR modified non-water geometry: {landError}.");
var dir=Path.Combine(AppContext.BaseDirectory,"graphics-report");Directory.CreateDirectory(dir);
WritePng(Path.Combine(dir,"ocean-ssr-wall-test.png"),width,height,result);
Console.WriteLine($"SSR GPU test passed: visible wall reflected, confidence {raw[offset+3]:F3}, non-water error {landError:G3}.");
gl.BindFramebuffer(GLEnum.Framebuffer,0);gl.Enable(GLEnum.DepthTest);gl.DepthFunc(GLEnum.Lequal);gl.DepthMask(true);
} }
public static void RunScene(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height) public static void RunScene(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{ {

View File

@@ -35,6 +35,7 @@ internal sealed class PerformanceOverlay : IDisposable
AddMetric("GAS GIANTS", "GAS GIANTS"); AddMetric("GAS GIANTS", "GAS GIANTS");
AddMetric("RINGS / ROCKS", "RINGS / ROCKS"); AddMetric("RINGS / ROCKS", "RINGS / ROCKS");
AddMetric("OCEAN", "OCEAN"); AddMetric("OCEAN", "OCEAN");
AddMetric("WATER REFLECTIONS", "WATER REFLECTIONS");
AddMetric("ATMOSPHERE", "ATMOSPHERE"); AddMetric("ATMOSPHERE", "ATMOSPHERE");
AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE"); AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE");
AddMetric("SUN GLOW", "SUN GLOW"); AddMetric("SUN GLOW", "SUN GLOW");

View File

@@ -8,10 +8,11 @@ internal sealed unsafe class RenderTarget : IDisposable
public uint Framebuffer { get; } public uint Framebuffer { get; }
public uint Color { get; } public uint Color { get; }
public uint Depth { get; } public uint Depth { get; }
public uint[] AuxiliaryColors { get; }
public int Width { get; } public int Width { get; }
public int Height { get; } public int Height { get; }
public RenderTarget(GL gl, int width, int height, bool depth = false, bool fullPrecision = false) public RenderTarget(GL gl, int width, int height, bool depth = false, bool fullPrecision = false, int auxiliaryColors = 0)
{ {
_gl = gl; _gl = gl;
Width = width; Width = width;
@@ -23,6 +24,15 @@ internal sealed unsafe class RenderTarget : IDisposable
gl.TexImage2D(GLEnum.Texture2D, 0, fullPrecision ? InternalFormat.Rgba32f : InternalFormat.Rgba16f, (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.Float, null); gl.TexImage2D(GLEnum.Texture2D, 0, fullPrecision ? InternalFormat.Rgba32f : InternalFormat.Rgba16f, (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.Float, null);
SetParameters(gl, GLEnum.Linear); SetParameters(gl, GLEnum.Linear);
gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.ColorAttachment0, GLEnum.Texture2D, Color, 0); gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.ColorAttachment0, GLEnum.Texture2D, Color, 0);
AuxiliaryColors = new uint[auxiliaryColors];
for (var i = 0; i < auxiliaryColors; i++)
{
var texture = gl.GenTexture(); AuxiliaryColors[i] = texture;
gl.BindTexture(GLEnum.Texture2D, texture);
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.Rgba16f, (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.Float, null);
SetParameters(gl, GLEnum.Nearest);
gl.FramebufferTexture2D(GLEnum.Framebuffer, (GLEnum)((uint)GLEnum.ColorAttachment1 + i), GLEnum.Texture2D, texture, 0);
}
if (depth) if (depth)
{ {
Depth = gl.GenTexture(); Depth = gl.GenTexture();
@@ -36,10 +46,24 @@ internal sealed unsafe class RenderTarget : IDisposable
gl.BindFramebuffer(GLEnum.Framebuffer, 0); gl.BindFramebuffer(GLEnum.Framebuffer, 0);
} }
public void Bind() public void Bind(bool allColors = false)
{ {
_gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer); _gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer);
_gl.Viewport(0, 0, (uint)Width, (uint)Height); _gl.Viewport(0, 0, (uint)Width, (uint)Height);
if (allColors && AuxiliaryColors.Length > 0)
{
var attachments = stackalloc GLEnum[AuxiliaryColors.Length + 1];
for (var i = 0; i <= AuxiliaryColors.Length; i++) attachments[i] = (GLEnum)((uint)GLEnum.ColorAttachment0 + i);
_gl.DrawBuffers((uint)(AuxiliaryColors.Length + 1), attachments);
}
else _gl.DrawBuffer(GLEnum.ColorAttachment0);
}
public void GenerateColorMipmaps()
{
_gl.BindTexture(GLEnum.Texture2D, Color);
_gl.GenerateMipmap(GLEnum.Texture2D);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
} }
private static void SetParameters(GL gl, GLEnum filter) private static void SetParameters(GL gl, GLEnum filter)
@@ -53,6 +77,7 @@ internal sealed unsafe class RenderTarget : IDisposable
public void Dispose() public void Dispose()
{ {
_gl.DeleteTexture(Color); _gl.DeleteTexture(Color);
foreach (var texture in AuxiliaryColors) _gl.DeleteTexture(texture);
if (Depth != 0) _gl.DeleteTexture(Depth); if (Depth != 0) _gl.DeleteTexture(Depth);
_gl.DeleteFramebuffer(Framebuffer); _gl.DeleteFramebuffer(Framebuffer);
} }

View File

@@ -17,7 +17,7 @@ internal static class ShaderSources
} }
private static string Expand(string name, string 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" }) foreach (var include in new[] { "noise", "planet-height", "camera-ray", "ring-optics", "rock-common", "asteroid-shape", "cloud-common", "screen-space-reflection" })
{ {
var directive = $"#include \"{include}.glsl\""; var directive = $"#include \"{include}.glsl\"";
if (source.Contains(directive, StringComparison.Ordinal)) if (source.Contains(directive, StringComparison.Ordinal))

View File

@@ -0,0 +1,40 @@
#version 330 core
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform sampler2D uWaterNormals;
uniform sampler2D uWaterFallback;
uniform sampler2D uReflection;
uniform sampler2D uClouds;
uniform int uHasClouds;
uniform float uFar;
out vec4 fragColor;
float depth(vec2 uv) { return exp2(textureLod(uDepth,uv,0).r*log2(uFar+1.0))-1.0; }
void main() {
vec3 scene=textureLod(uScene,vUv,0).rgb;
vec4 surface=texture(uWaterNormals,vUv);
float reflectance=length(surface.rgb);
fragColor=vec4(scene,1);
if(surface.a<.5 || reflectance<.0001)return;
// Depth/normal-aware bilinear upsampling prevents a half-resolution shoreline from reflecting onto land.
ivec2 size=textureSize(uReflection,0);
vec2 p=vUv*vec2(size)-.5,base=floor(p),f=fract(p);
float z=depth(vUv),weight=0.0;
vec4 sum=vec4(0);
for(int y=0;y<2;y++)for(int x=0;x<2;x++) {
ivec2 coord=clamp(ivec2(base)+ivec2(x,y),ivec2(0),size-1);
vec2 uv=(vec2(coord)+.5)/vec2(size);
vec4 other=textureLod(uWaterNormals,uv,0);
if(other.a<.5 || length(other.rgb)<.0001)continue;
float w=(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y);
w*=exp(-abs(depth(uv)-z)/max(.3,z*.025));
w*=smoothstep(.8,.99,dot(normalize(surface.rgb),normalize(other.rgb)));
sum+=texelFetch(uReflection,coord,0)*w;weight+=w;
}
if(weight<=.00001 || sum.a<=.00001)return;
float confidence=clamp(sum.a/weight,0.0,1.0);
if(uHasClouds!=0)confidence*=texture(uClouds,vUv).a;
vec3 reflected=sum.rgb/sum.a;
vec3 fallback=texture(uWaterFallback,vUv).rgb;
fragColor=vec4(max(vec3(0),scene+(reflected*reflectance-fallback)*confidence),1);
}

View File

@@ -0,0 +1,17 @@
#version 330 core
#include "camera-ray.glsl"
#include "screen-space-reflection.glsl"
in vec2 vUv;
out vec4 fragColor;
void main() {
fragColor=vec4(0);
vec4 surface=textureLod(uWaterNormals,vUv,0.0);
if(surface.a<.5 || length(surface.rgb)<.0001)return;
vec3 ray=cameraRay(vUv);
float distance=linearDepth(vUv)/max(.001,dot(ray,uForward));
if(distance<=.03 || distance>uMaxSurfaceDistance)return;
vec4 hit=traceReflection(ray*distance,normalize(surface.rgb));
float fade=1.0-smoothstep(uMaxSurfaceDistance*.6,uMaxSurfaceDistance,distance);
fade*=1.0-smoothstep(.6,1.0,uReflectionSettings.z);
fragColor=hit*(fade*uReflectionStrength);
}

View File

@@ -27,7 +27,9 @@ uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2]; uniform vec3 uLightColors[2];
uniform float uLightFlux[2]; uniform float uLightFlux[2];
uniform float uTime; uniform float uTime;
out vec4 fragColor; layout(location=0) out vec4 fragColor;
layout(location=1) out vec4 reflectionNormal;
layout(location=2) out vec4 reflectionFallback;
const float PI=3.14159265359; const float PI=3.14159265359;
vec3 toLocal(vec3 v) { return vec3(dot(v,uAxisX),dot(v,uAxisY),dot(v,uAxisZ)); } 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; } vec3 toWorld(vec3 v) { return uAxisX*v.x+uAxisY*v.y+uAxisZ*v.z; }
@@ -98,25 +100,9 @@ vec3 skyReflection(vec3 direction,vec3 up) {
} }
return sky*(.006+daylight*1.35); 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() { void main() {
// Premultiplied-alpha MRT blending preserves earlier ocean planets outside this one's silhouette.
reflectionNormal=vec4(0); reflectionFallback=vec4(0);
vec3 scene=texture(uScene,vUv).rgb; vec3 scene=texture(uScene,vUv).rgb;
float originalDepth=texture(uDepth,vUv).r; float originalDepth=texture(uDepth,vUv).r;
gl_FragDepth=originalDepth; gl_FragDepth=originalDepth;
@@ -167,7 +153,6 @@ void main() {
vec3 reflectionDirection=reflect(rd,n); vec3 reflectionDirection=reflect(rd,n);
vec3 reflected=skyReflection(reflectionDirection,up); vec3 reflected=skyReflection(reflectionDirection,up);
reflected=cloudReflection(toWorld(p-ro),toWorld(reflectionDirection),reflected); 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)); 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 floorDistance=sceneDistance(refractUv,cameraRay(refractUv));
float thickness=max(.0,min(400.0,floorDistance-t)); float thickness=max(.0,min(400.0,floorDistance-t));
@@ -204,5 +189,8 @@ void main() {
color=color*through+uShallowColor*(1.0-through)*.5; color=color*through+uShallowColor*(1.0-through)*.5;
} }
fragColor=vec4(max(color,vec3(0)),1); fragColor=vec4(max(color,vec3(0)),1);
float reflectance=underwater?0.0:fresnel*uWaterOptics.y*(1.0-foam);
reflectionNormal=vec4(toWorld(n)*reflectance,1);
reflectionFallback=vec4(reflected*reflectance,1);
gl_FragDepth=clamp(log2(1.0+t*max(.001,dot(worldRay,uForward)))/log2(uFar+1.0),0.0,1.0); gl_FragDepth=clamp(log2(1.0+t*max(.001,dot(worldRay,uForward)))/log2(uFar+1.0),0.0,1.0);
} }

View File

@@ -0,0 +1,84 @@
// Perspective-correct screen-space segment traversal, following the DDA principle in
// McGuire & Mara, JCGT 3(4), 2014. Camera-space Z is positive and depth is logarithmic.
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform sampler2D uWaterNormals;
uniform float uFar;
uniform int uMaxSteps;
uniform vec3 uTraceSettings; // maximum ray distance, thickness, normal bias (metres)
uniform vec3 uReflectionSettings; // edge fade, pixel stride, roughness
uniform float uMaxSurfaceDistance;
uniform float uReflectionStrength;
uniform int uReflectSky;
float linearDepth(vec2 uv) { return exp2(textureLod(uDepth,uv,0.0).r*log2(uFar+1.0))-1.0; }
vec3 viewPoint(vec3 world) { return vec3(dot(world,uRight),dot(world,uUp),dot(world,uForward)); }
vec2 projectPoint(vec3 p) { return .5+p.xy/(2.0*p.z*uTanHalfFov*vec2(uAspect,1)); }
float edgeConfidence(vec2 uv) {
float edge=min(min(uv.x,uv.y),min(1.0-uv.x,1.0-uv.y));
return smoothstep(0.0,uReflectionSettings.x,edge);
}
vec4 traceReflection(vec3 world,vec3 normal) {
vec3 reflection=reflect(normalize(world),normal);
vec3 origin=viewPoint(world+normal*uTraceSettings.z),direction=viewPoint(reflection);
if(origin.z<=.02)return vec4(0);
float distanceLimit=uTraceSettings.x;
if(direction.z<0.0)distanceLimit=min(distanceLimit,(.05-origin.z)/direction.z);
vec4 result=vec4(0);
float roughness=uReflectionSettings.z;
float mip=roughness*5.0;
// Infinite/distant sky lookup includes the actual stars, atmospheric colour and cloud texture.
// Opaque foreground pixels are never treated as sky or used to fill missing geometry.
if(uReflectSky!=0 && direction.z>.001) {
vec2 uv=projectPoint(direction);
if(all(greaterThan(uv,vec2(0)))&&all(lessThan(uv,vec2(1)))) {
float z=linearDepth(uv);
if(z>uTraceSettings.x*4.0 && textureLod(uWaterNormals,uv,0.0).a<.5) {
float confidence=edgeConfidence(uv);
result=vec4(textureLod(uScene,uv,mip).rgb*confidence,confidence);
}
}
}
if(distanceLimit<=uTraceSettings.z)return result;
vec3 end=origin+direction*distanceLimit;
vec2 startUv=projectPoint(origin),endUv=projectPoint(end);
vec2 size=vec2(textureSize(uDepth,0));
float pixelLength=length((endUv-startUv)*size);
float stepFraction=max(uReflectionSettings.y/max(pixelLength,1.0),1.0/float(uMaxSteps));
vec3 q0=origin/origin.z,q1=end/end.z;
float k0=1.0/origin.z,k1=1.0/end.z;
float previous=0.0,previousZ=origin.z;
for(int i=0;i<128;i++) {
if(i>=uMaxSteps)break;
float fraction=min(1.0,float(i+1)*stepFraction);
vec2 uv=mix(startUv,endUv,fraction);
if(any(lessThanEqual(uv,vec2(0)))||any(greaterThanEqual(uv,vec2(1))))break;
float rayZ=1.0/mix(k0,k1,fraction);
float sceneZ=linearDepth(uv);
float thickness=uTraceSettings.y+sceneZ*.00005;
bool water=textureLod(uWaterNormals,uv,0.0).a>.5;
if(!water && sceneZ<uFar*.99 && max(previousZ,rayZ)>=sceneZ && min(previousZ,rayZ)<=sceneZ+thickness) {
float lo=previous,hi=fraction;
// Refine the front crossing instead of accepting an entire coarse depth interval.
for(int j=0;j<7;j++) {
float mid=(lo+hi)*.5;
vec2 midUv=mix(startUv,endUv,mid);
float delta=1.0/mix(k0,k1,mid)-linearDepth(midUv);
if(delta>0.0)hi=mid;else lo=mid;
}
float hit=(lo+hi)*.5;
vec2 hitUv=mix(startUv,endUv,hit);
vec3 point=mix(q0,q1,hit)/mix(k0,k1,hit);
float error=point.z-linearDepth(hitUv);
float travel=length(point-origin);
if(abs(error)<thickness && travel>uTraceSettings.z*2.0 && textureLod(uWaterNormals,hitUv,0.0).a<.5) {
float confidence=edgeConfidence(hitUv)*(1.0-smoothstep(distanceLimit*.7,distanceLimit,travel));
confidence*=1.0-smoothstep(thickness*.3,thickness,abs(error));
return vec4(textureLod(uScene,hitUv,mip).rgb*confidence,confidence);
}
}
previous=fraction; previousZ=rayZ;
if(fraction>=1.0)break;
}
return result;
}

View File

@@ -4,14 +4,21 @@ flat in vec3 gColor;
flat in float gLength; flat in float gLength;
flat in float gSigma; flat in float gSigma;
out vec4 fragColor; out vec4 fragColor;
// Gaussian convolved with a normalized shutter interval. Its integral is the
// same for a stationary point and a long streak (no extra light from area).
float erfApprox(float x) {
float t=1.0/(1.0+.3275911*abs(x));
float p=(((((1.061405429*t-1.453152027)*t)+1.421413741)*t-.284496736)*t+.254829592)*t;
return sign(x)*(1.0-p*exp(-x*x));
}
void main() { void main() {
float closest=clamp(gPixel.x,0.0,gLength);
vec2 delta=vec2(gPixel.x-closest,gPixel.y);
float sigma2=gSigma*gSigma+1.0/12.0; float sigma2=gSigma*gSigma+1.0/12.0;
float profile=exp(-dot(delta,delta)/(2.0*sigma2)); float sigma=sqrt(sigma2);
float along=gLength>.5?mix(.32,1.0,smoothstep(0.0,1.0,closest/gLength)):1.0; float along;
float head=exp(-((gPixel.x-gLength)*(gPixel.x-gLength)+gPixel.y*gPixel.y)/(2.0*sigma2)); if(gLength<.2)along=exp(-pow(gPixel.x-gLength*.5,2.0)/(2.0*sigma2));
float brightness=profile*along+head*.15; else along=1.253314137*sigma/gLength*(erfApprox(gPixel.x/(1.414213562*sigma))
if(brightness<.003)discard; -erfApprox((gPixel.x-gLength)/(1.414213562*sigma)));
float brightness=exp(-gPixel.y*gPixel.y/(2.0*sigma2))*max(0.0,along);
if(brightness<.00001)discard;
fragColor=vec4(gColor*brightness,0); fragColor=vec4(gColor*brightness,0);
} }

View File

@@ -33,26 +33,28 @@ void main() {
if(tail.w<nearPlane)tail=mix(head,tail,(head.w-nearPlane)/(head.w-tail.w)); if(tail.w<nearPlane)tail=mix(head,tail,(head.w-nearPlane)/(head.w-tail.w));
vec2 h=head.xy/head.w*uViewport.xy*.5; vec2 h=head.xy/head.w*uViewport.xy*.5;
vec2 t=mix(h,tail.xy/tail.w*uViewport.xy*.5,uShutter); vec2 t=mix(h,tail.xy/tail.w*uViewport.xy*.5,uShutter);
float fullLength=length(h-t);
if(fullLength>uMaximumTrailPixels)t=h-(h-t)*(uMaximumTrailPixels/fullLength);
vec2 originalTail=t;
gLength=length(h-t);
vec2 delta=h-t; vec2 delta=h-t;
float lo=0.0,hi=1.0; float lo=0.0,hi=1.0;
vec2 bounds=uViewport.xy*.5+vec2(8.0); vec2 bounds=uViewport.xy*.5+vec2(8.0);
if(!clipAxis(t.x,delta.x,bounds.x,lo,hi)||!clipAxis(t.y,delta.y,bounds.y,lo,hi))return; if(!clipAxis(t.x,delta.x,bounds.x,lo,hi)||!clipAxis(t.y,delta.y,bounds.y,lo,hi))return;
h=t+delta*hi;t+=delta*lo; h=t+delta*hi;t+=delta*lo;
float distancePixels=length(h-t); vec2 direction=length(h-t)>.001?normalize(h-t):vec2(1,0);
if(distancePixels>uMaximumTrailPixels)t=h-(h-t)*(uMaximumTrailPixels/distancePixels);
gLength=length(h-t);
vec2 direction=gLength>.001?(h-t)/gLength:vec2(1,0);
vec2 perpendicular=vec2(-direction.y,direction.x); vec2 perpendicular=vec2(-direction.y,direction.x);
// A minimum Gaussian footprint averages subpixel motion instead of toggling // A minimum Gaussian footprint averages subpixel motion instead of toggling
// a one-pixel point on and off as the camera passes it. // a one-pixel point on and off as the camera passes it.
gSigma=max(uMinimumWidthPixels/2.0,star[0].size*.3); gSigma=max(uMinimumWidthPixels/2.0,star[0].size*.3);
float radius=gSigma*3.0; float radius=sqrt(gSigma*gSigma+1.0/12.0)*3.5;
float moving=1.0-exp(-gLength/14.0); float moving=1.0-exp(-gLength/14.0);
float boost=mix(1.0,uMovingBrightness,moving); float boost=mix(1.0,uMovingBrightness,moving);
gColor=star[0].color*.7*boost; gColor=star[0].color*.805*boost;
vertex(t-direction*radius-perpendicular*radius,vec2(-radius,-radius)); float start=length(t-originalTail),end=length(h-originalTail);
vertex(t-direction*radius+perpendicular*radius,vec2(-radius,radius)); vertex(t-direction*radius-perpendicular*radius,vec2(start-radius,-radius));
vertex(h+direction*radius-perpendicular*radius,vec2(gLength+radius,-radius)); vertex(t-direction*radius+perpendicular*radius,vec2(start-radius,radius));
vertex(h+direction*radius+perpendicular*radius,vec2(gLength+radius,radius)); vertex(h+direction*radius-perpendicular*radius,vec2(end+radius,-radius));
vertex(h+direction*radius+perpendicular*radius,vec2(end+radius,radius));
EndPrimitive(); EndPrimitive();
} }

View File

@@ -45,6 +45,8 @@ internal sealed class UniverseRenderer : IDisposable
private ShaderProgram _sunGlow; private ShaderProgram _sunGlow;
private ShaderProgram _blackHole; private ShaderProgram _blackHole;
private ShaderProgram _ocean; private ShaderProgram _ocean;
private ShaderProgram _oceanReflection;
private ShaderProgram _oceanReflectionComposite;
private ShaderProgram _clouds; private ShaderProgram _clouds;
private ShaderProgram _cloudComposite; private ShaderProgram _cloudComposite;
private readonly ShaderProgram _overlay; private readonly ShaderProgram _overlay;
@@ -52,6 +54,10 @@ internal sealed class UniverseRenderer : IDisposable
private readonly CloudNoiseTexture _cloudNoise; private readonly CloudNoiseTexture _cloudNoise;
private readonly SurfaceMaterials _materials; private readonly SurfaceMaterials _materials;
private RenderTarget? _cloudRaw; private RenderTarget? _cloudRaw;
private RenderTarget? _waterReflectionRaw;
private RenderTarget _waterReflectionTarget = null!;
private bool _oceanRendered;
private string? _cloudLayerPlanetId;
internal RenderTarget? CloudLayer => _cloudRaw; internal RenderTarget? CloudLayer => _cloudRaw;
private readonly GpuMesh _navigationStars; private readonly GpuMesh _navigationStars;
private RenderTarget _scene = null!; private RenderTarget _scene = null!;
@@ -118,6 +124,8 @@ internal sealed class UniverseRenderer : IDisposable
_sunGlow = Program("fullscreen.vert", "sun-glow.frag"); _sunGlow = Program("fullscreen.vert", "sun-glow.frag");
_blackHole = Program("fullscreen.vert", "black-hole.frag"); _blackHole = Program("fullscreen.vert", "black-hole.frag");
_ocean = Program("fullscreen.vert", "ocean.frag"); _ocean = Program("fullscreen.vert", "ocean.frag");
_oceanReflection = Program("fullscreen.vert", "ocean-reflection.frag");
_oceanReflectionComposite = Program("fullscreen.vert", "ocean-reflection-composite.frag");
_clouds = Program("fullscreen.vert", "cloud.frag"); _clouds = Program("fullscreen.vert", "cloud.frag");
_cloudComposite = Program("fullscreen.vert", "cloud-composite.frag"); _cloudComposite = Program("fullscreen.vert", "cloud-composite.frag");
_overlay = ShaderProgram.Create(gl, """ _overlay = ShaderProgram.Create(gl, """
@@ -178,7 +186,8 @@ internal sealed class UniverseRenderer : IDisposable
foreach (var pair in new[] { ("terrain.vert","terrain.frag"),("star.vert","star.frag"),("fullscreen.vert","sky.frag"),("point.vert","point.frag"), foreach (var pair in new[] { ("terrain.vert","terrain.frag"),("star.vert","star.frag"),("fullscreen.vert","sky.frag"),("point.vert","point.frag"),
("fullscreen.vert","atmosphere.frag"),("fullscreen.vert","bright.frag"),("fullscreen.vert","blur.frag"),("fullscreen.vert","final.frag"),("fullscreen.vert","ring.frag"), ("fullscreen.vert","atmosphere.frag"),("fullscreen.vert","bright.frag"),("fullscreen.vert","blur.frag"),("fullscreen.vert","final.frag"),("fullscreen.vert","ring.frag"),
("fullscreen.vert","gas.frag"),("rock.vert","rock.frag"),("rock-point.vert","rock-point.frag"),("fullscreen.vert","sun-glow.frag"),("fullscreen.vert","ocean.frag"), ("fullscreen.vert","gas.frag"),("rock.vert","rock.frag"),("rock-point.vert","rock-point.frag"),("fullscreen.vert","sun-glow.frag"),("fullscreen.vert","ocean.frag"),
("fullscreen.vert","cloud.frag"),("fullscreen.vert","cloud-composite.frag"),("fullscreen.vert","black-hole.frag") }) ("fullscreen.vert","cloud.frag"),("fullscreen.vert","cloud-composite.frag"),("fullscreen.vert","black-hole.frag"),
("fullscreen.vert","ocean-reflection.frag"),("fullscreen.vert","ocean-reflection-composite.frag") })
replacements.Add(Program(pair.Item1, pair.Item2)); replacements.Add(Program(pair.Item1, pair.Item2));
_starTrails.ReloadShaders(); _starTrails.ReloadShaders();
} }
@@ -198,6 +207,8 @@ internal sealed class UniverseRenderer : IDisposable
_ocean.Dispose(); _ocean = replacements[13]; _ocean.Dispose(); _ocean = replacements[13];
_clouds.Dispose(); _clouds = replacements[14]; _cloudComposite.Dispose(); _cloudComposite = replacements[15]; _clouds.Dispose(); _clouds = replacements[14]; _cloudComposite.Dispose(); _cloudComposite = replacements[15];
_blackHole.Dispose(); _blackHole = replacements[16]; _blackHole.Dispose(); _blackHole = replacements[16];
_oceanReflection.Dispose(); _oceanReflection = replacements[17];
_oceanReflectionComposite.Dispose(); _oceanReflectionComposite = replacements[18];
} }
public void SetActiveSystem(SectorCoordinate coordinate) public void SetActiveSystem(SectorCoordinate coordinate)
@@ -236,9 +247,11 @@ internal sealed class UniverseRenderer : IDisposable
_width = System.Math.Max(width, 1); _width = System.Math.Max(width, 1);
_height = System.Math.Max(height, 1); _height = System.Math.Max(height, 1);
_cloudRaw?.Dispose(); _cloudRaw = null; _cloudRaw?.Dispose(); _cloudRaw = null;
_waterReflectionRaw?.Dispose(); _waterReflectionRaw = null; _waterReflectionTarget?.Dispose();
_scene?.Dispose(); _gasInput?.Dispose(); _glareTarget?.Dispose(); _scatterA?.Dispose(); _scatterB?.Dispose(); _bloomA?.Dispose(); _bloomB?.Dispose(); _scene?.Dispose(); _gasInput?.Dispose(); _glareTarget?.Dispose(); _scatterA?.Dispose(); _scatterB?.Dispose(); _bloomA?.Dispose(); _bloomB?.Dispose();
_blackHoleTarget?.Dispose(); _blackHoleTarget?.Dispose();
_scene = new(_gl, _width, _height, true); _scene = new(_gl, _width, _height, true, auxiliaryColors: 2);
_waterReflectionTarget = new(_gl, _width, _height);
_gasInput = new(_gl, _width, _height, true); _gasInput = new(_gl, _width, _height, true);
_glareTarget = new(_gl, _width, _height); _glareTarget = new(_gl, _width, _height);
_blackHoleTarget = new(_gl, _width, _height); _blackHoleTarget = new(_gl, _width, _height);
@@ -276,10 +289,12 @@ internal sealed class UniverseRenderer : IDisposable
var starPositions = system.Stars.Select(s => center.Add(s.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)).ToArray(); var starPositions = system.Stars.Select(s => center.Add(s.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)).ToArray();
BindNoise(); BindNoise();
_gpuProfiler.BeginFrame(DebugMode); _gpuProfiler.BeginFrame(DebugMode);
_scene.Bind(); _oceanRendered = false; _cloudLayerPlanetId = null;
_scene.Bind(allColors: true);
_gl.DepthMask(true); _gl.DepthMask(true);
_gl.ClearColor(0, 0, 0, 1); _gl.ClearColor(0, 0, 0, 0);
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_scene.Bind();
_gl.Disable(GLEnum.Blend); _gl.Disable(GLEnum.Blend);
_gl.Disable(GLEnum.DepthTest); _gl.Disable(GLEnum.DepthTest);
_gl.DepthMask(false); _gl.DepthMask(false);
@@ -330,6 +345,10 @@ internal sealed class UniverseRenderer : IDisposable
composed = DrawClouds(camera, system, far, composed); composed = DrawClouds(camera, system, far, composed);
_gpuProfiler.EndPass(); _gpuProfiler.EndPass();
Mark("clouds"); Mark("clouds");
_gpuProfiler.BeginPass("WATER REFLECTIONS");
composed = DrawOceanReflections(camera, system, far, composed);
_gpuProfiler.EndPass();
Mark("water reflections");
_gpuProfiler.BeginPass("BLACK HOLE / LENSING"); _gpuProfiler.BeginPass("BLACK HOLE / LENSING");
composed = DrawBlackHoles(camera, system, far, composed); composed = DrawBlackHoles(camera, system, far, composed);
_gpuProfiler.EndPass(); _gpuProfiler.EndPass();
@@ -674,7 +693,9 @@ internal sealed class UniverseRenderer : IDisposable
_gl.BindFramebuffer(GLEnum.DrawFramebuffer, _gasInput.Framebuffer); _gl.BindFramebuffer(GLEnum.DrawFramebuffer, _gasInput.Framebuffer);
_gl.BlitFramebuffer(0, 0, _width, _height, 0, 0, _width, _height, _gl.BlitFramebuffer(0, 0, _width, _height, 0, 0, _width, _height,
ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit, BlitFramebufferFilter.Nearest); ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit, BlitFramebufferFilter.Nearest);
_scene.Bind(); _gl.Enable(GLEnum.DepthTest); _gl.DepthFunc(GLEnum.Always); _gl.DepthMask(true); _scene.Bind(allColors: true); _gl.Enable(GLEnum.DepthTest); _gl.DepthFunc(GLEnum.Always); _gl.DepthMask(true);
_gl.Enable(GLEnum.Blend); _gl.BlendFunc(GLEnum.One, GLEnum.OneMinusSrcAlpha);
_oceanRendered = true;
_ocean.Use(); SetCameraRay(_ocean, camera); BindPlanetTerrain(_ocean, planet, textures); _ocean.Use(); SetCameraRay(_ocean, camera); BindPlanetTerrain(_ocean, planet, textures);
BindCloudSettings(_ocean, camera, system, planet, relative); BindCloudSettings(_ocean, camera, system, planet, relative);
BindTexture(0, _gasInput.Color); BindTexture(1, _gasInput.Depth); BindTexture(0, _gasInput.Color); BindTexture(1, _gasInput.Depth);
@@ -717,9 +738,49 @@ internal sealed class UniverseRenderer : IDisposable
System.Math.Pow((s.PositionMeters - planet.PositionMeters).Length / UniverseScale.AstronomicalUnitMeters, 2), .02, 7)).ToArray()); System.Math.Pow((s.PositionMeters - planet.PositionMeters).Length / UniverseScale.AstronomicalUnitMeters, 2), .02, 7)).ToArray());
_fullscreen.Draw(); _fullscreen.Draw();
} }
_gl.Disable(GLEnum.Blend); _scene.Bind();
_gl.DepthFunc(GLEnum.Lequal); _gl.DepthFunc(GLEnum.Lequal);
} }
private RenderTarget DrawOceanReflections(FlyCamera camera, StarSystemData system, float far, RenderTarget source)
{
if (!_oceanRendered) return source;
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
// Only a nearby ocean needs detailed SSR; orbital views retain the cheaper analytic reflection.
var planet = system.Planets.Where(p => p.Ocean is { Enabled: true, ScreenSpaceReflections: { Enabled: true, Strength: > 0 } })
.Where(p => System.Math.Abs(center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters).Length
- p.RadiusMeters - p.Ocean!.SeaLevelMeters) < p.Ocean.ScreenSpaceReflections.MaxSurfaceDistanceMeters)
.MinBy(p => center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters).Length - p.RadiusMeters);
if (planet is null) return source;
var r = planet.Ocean!.ScreenSpaceReflections;
var width = System.Math.Max(1, (int)System.Math.Round(_width * r.ResolutionScale));
var height = System.Math.Max(1, (int)System.Math.Round(_height * r.ResolutionScale));
if (_waterReflectionRaw is null || _waterReflectionRaw.Width != width || _waterReflectionRaw.Height != height)
{ _waterReflectionRaw?.Dispose(); _waterReflectionRaw = new(_gl, width, height); }
source.GenerateColorMipmaps();
_gl.Disable(GLEnum.Blend); _gl.Disable(GLEnum.DepthTest); _gl.DepthMask(false);
_waterReflectionRaw.Bind(); _oceanReflection.Use(); SetCameraRay(_oceanReflection, camera);
BindTexture(0, source.Color); BindTexture(1, _scene.Depth); BindTexture(2, _scene.AuxiliaryColors[0]);
_oceanReflection.Set("uScene", 0); _oceanReflection.Set("uDepth", 1); _oceanReflection.Set("uWaterNormals", 2);
_oceanReflection.Set("uFar", far); _oceanReflection.Set("uMaxSteps", r.MaxSteps);
_oceanReflection.Set("uTraceSettings", new Vector3((float)r.MaxDistanceMeters, (float)r.ThicknessMeters, (float)r.NormalBiasMeters));
_oceanReflection.Set("uReflectionSettings", new Vector3((float)r.EdgeFade, (float)r.StridePixels, (float)planet.Ocean.Roughness));
_oceanReflection.Set("uMaxSurfaceDistance", (float)r.MaxSurfaceDistanceMeters);
_oceanReflection.Set("uReflectionStrength", (float)r.Strength); _oceanReflection.Set("uReflectSky", r.ReflectSky ? 1 : 0);
_fullscreen.Draw();
_waterReflectionTarget.Bind(); _oceanReflectionComposite.Use();
BindTexture(0, source.Color); BindTexture(1, _scene.Depth); BindTexture(2, _scene.AuxiliaryColors[0]);
BindTexture(3, _scene.AuxiliaryColors[1]); BindTexture(5, _waterReflectionRaw.Color);
if (_cloudRaw is not null) BindTexture(6, _cloudRaw.Color);
_oceanReflectionComposite.Set("uScene", 0); _oceanReflectionComposite.Set("uDepth", 1);
_oceanReflectionComposite.Set("uWaterNormals", 2); _oceanReflectionComposite.Set("uWaterFallback", 3);
_oceanReflectionComposite.Set("uReflection", 5); _oceanReflectionComposite.Set("uClouds", 6);
_oceanReflectionComposite.Set("uHasClouds", _cloudLayerPlanetId == planet.Id ? 1 : 0);
_oceanReflectionComposite.Set("uFar", far); _fullscreen.Draw();
_gl.Enable(GLEnum.DepthTest); _gl.DepthMask(true);
return _waterReflectionTarget;
}
private void DrawRings(FlyCamera camera, StarSystemData system, float far) private void DrawRings(FlyCamera camera, StarSystemData system, float far)
{ {
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters); var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
@@ -968,6 +1029,7 @@ internal sealed class UniverseRenderer : IDisposable
_cloudComposite.Set("uScene", 0); _cloudComposite.Set("uDepth", 1); _cloudComposite.Set("uClouds", 2); _cloudComposite.Set("uScene", 0); _cloudComposite.Set("uDepth", 1); _cloudComposite.Set("uClouds", 2);
_cloudComposite.Set("uCloudTexel", new Vector3(1f / width, 1f / height, 0)); _cloudComposite.Set("uCloudTexel", new Vector3(1f / width, 1f / height, 0));
_fullscreen.Draw(); source = target; _fullscreen.Draw(); source = target;
_cloudLayerPlanetId = planet.Id;
} }
return source; return source;
} }
@@ -1119,6 +1181,7 @@ internal sealed class UniverseRenderer : IDisposable
_sunGlow.Dispose(); _glareTarget.Dispose(); _sunGlow.Dispose(); _glareTarget.Dispose();
_blackHole.Dispose(); _blackHoleTarget.Dispose(); _blackHole.Dispose(); _blackHoleTarget.Dispose();
_ocean.Dispose(); _ocean.Dispose();
_oceanReflection.Dispose(); _oceanReflectionComposite.Dispose(); _waterReflectionRaw?.Dispose(); _waterReflectionTarget.Dispose();
_clouds.Dispose(); _cloudComposite.Dispose(); _cloudNoise.Dispose(); _emptyCloudDensity.Dispose(); _materials.Dispose(); _cloudRaw?.Dispose(); _clouds.Dispose(); _cloudComposite.Dispose(); _cloudNoise.Dispose(); _emptyCloudDensity.Dispose(); _materials.Dispose(); _cloudRaw?.Dispose();
_scene.Dispose(); _scatterA.Dispose(); _scatterB.Dispose(); _bloomA.Dispose(); _bloomB.Dispose(); _noise.Dispose(); _scene.Dispose(); _scatterA.Dispose(); _scatterB.Dispose(); _bloomA.Dispose(); _bloomB.Dispose(); _noise.Dispose();
} }

View File

@@ -31,6 +31,16 @@ internal static class VisualSettingsValidator
} }
public static void Ocean(OceanSettings o, string id) public static void Ocean(OceanSettings o, string id)
{ {
var r = o.ScreenSpaceReflections ?? throw new InvalidDataException($"'{id}': ocean.screenSpaceReflections cannot be null.");
Range(r.Strength, 0, 1, id, "ocean.screenSpaceReflections.strength");
Range(r.ResolutionScale, .25, 1, id, "ocean.screenSpaceReflections.resolutionScale");
Range(r.MaxSteps, 8, 128, id, "ocean.screenSpaceReflections.maxSteps");
Range(r.StridePixels, 1, 16, id, "ocean.screenSpaceReflections.stridePixels");
Range(r.MaxDistanceMeters, 10, 50000, id, "ocean.screenSpaceReflections.maxDistanceMeters");
Range(r.MaxSurfaceDistanceMeters, 10, 20000, id, "ocean.screenSpaceReflections.maxSurfaceDistanceMeters");
Range(r.ThicknessMeters, .01, 50, id, "ocean.screenSpaceReflections.thicknessMeters");
Range(r.NormalBiasMeters, .01, 5, id, "ocean.screenSpaceReflections.normalBiasMeters");
Range(r.EdgeFade, .01, .3, id, "ocean.screenSpaceReflections.edgeFade");
Range(o.SeaLevelMeters, -100000, 100000, id, "ocean.seaLevelMeters"); Range(o.SeaLevelMeters, -100000, 100000, id, "ocean.seaLevelMeters");
Range(o.DepthMeters, 1, 100000, id, "ocean.depthMeters"); Range(o.DepthMeters, 1, 100000, id, "ocean.depthMeters");
Range(o.WaveAmplitudeMeters, 0, 30, id, "ocean.waveAmplitudeMeters"); Range(o.WaveAmplitudeMeters, 0, 30, id, "ocean.waveAmplitudeMeters");

View File

@@ -5,6 +5,7 @@ namespace Universe.Core.Generation;
/// <summary>Metres, seconds and linear RGB. A null ocean on a planet disables water.</summary> /// <summary>Metres, seconds and linear RGB. A null ocean on a planet disables water.</summary>
public sealed record OceanSettings public sealed record OceanSettings
{ {
public OceanReflectionSettings ScreenSpaceReflections { get; init; } = new();
public bool Enabled { get; init; } = true; public bool Enabled { get; init; } = true;
public bool MaskFromSurfaceTexture { get; init; } = true; public bool MaskFromSurfaceTexture { get; init; } = true;
public double SeaLevelMeters { get; init; } public double SeaLevelMeters { get; init; }
@@ -23,6 +24,21 @@ public sealed record OceanSettings
public float[] Absorption { get; init; } = [.12f, .045f, .022f]; public float[] Absorption { get; init; } = [.12f, .045f, .022f];
} }
public sealed record OceanReflectionSettings
{
public bool Enabled { get; init; } = true;
public bool ReflectSky { get; init; } = true;
public double Strength { get; init; } = 1;
public double ResolutionScale { get; init; } = .5;
public int MaxSteps { get; init; } = 48;
public double StridePixels { get; init; } = 2;
public double MaxDistanceMeters { get; init; } = 3000;
public double MaxSurfaceDistanceMeters { get; init; } = 4000;
public double ThicknessMeters { get; init; } = 1.5;
public double NormalBiasMeters { get; init; } = .12;
public double EdgeFade { get; init; } = .07;
}
public readonly record struct OceanWave(DVector3 Direction, double WaveNumber, double Amplitude, double AngularFrequency); public readonly record struct OceanWave(DVector3 Direction, double WaveNumber, double Amplitude, double AngularFrequency);
public static class OceanSurface public static class OceanSurface

View File

@@ -6,7 +6,7 @@ public sealed record StarMotionBlurSettings
public double ShutterFraction { get; init; } = .9; public double ShutterFraction { get; init; } = .9;
public double MaximumTrailPixels { get; init; } = 180; public double MaximumTrailPixels { get; init; } = 180;
public double MinimumWidthPixels { get; init; } = 1.4; public double MinimumWidthPixels { get; init; } = 1.4;
public double MovingBrightness { get; init; } = 1.8; public double MovingBrightness { get; init; } = 1;
public double HistoryResetSeconds { get; init; } = .25; public double HistoryResetSeconds { get; init; } = .25;
public double StarFadeSeconds { get; init; } = .3; public double StarFadeSeconds { get; init; } = .3;
@@ -15,7 +15,7 @@ public sealed record StarMotionBlurSettings
if (!double.IsFinite(ShutterFraction) || ShutterFraction is < 0 or > 2 if (!double.IsFinite(ShutterFraction) || ShutterFraction is < 0 or > 2
|| !double.IsFinite(MaximumTrailPixels) || MaximumTrailPixels is < 1 or > 1024 || !double.IsFinite(MaximumTrailPixels) || MaximumTrailPixels is < 1 or > 1024
|| !double.IsFinite(MinimumWidthPixels) || MinimumWidthPixels is < .5 or > 6 || !double.IsFinite(MinimumWidthPixels) || MinimumWidthPixels is < .5 or > 6
|| !double.IsFinite(MovingBrightness) || MovingBrightness is < 1 or > 4 || !double.IsFinite(MovingBrightness) || MovingBrightness is < 0 or > 4
|| !double.IsFinite(HistoryResetSeconds) || HistoryResetSeconds is < .05 or > 2 || !double.IsFinite(HistoryResetSeconds) || HistoryResetSeconds is < .05 or > 2
|| !double.IsFinite(StarFadeSeconds) || StarFadeSeconds is < .05 or > 2) || !double.IsFinite(StarFadeSeconds) || StarFadeSeconds is < .05 or > 2)
throw new InvalidDataException("config.json: invalid starMotionBlur settings."); throw new InvalidDataException("config.json: invalid starMotionBlur settings.");

View File

@@ -3,11 +3,44 @@ using Universe.Core.Generation;
using Universe.Core.Math; using Universe.Core.Math;
using Universe.Core.Physics; using Universe.Core.Physics;
using Universe.Core.World; using Universe.Core.World;
using Universe.Core.Configuration;
using System.Text.Json;
namespace Universe.Tests; namespace Universe.Tests;
public sealed class OceanTests public sealed class OceanTests
{ {
[Fact]
public void PlanetJsonRoundTripsReflectionQualityAndRejectsInvalidGpuBudgets()
{
var quality = new OceanReflectionSettings { Enabled = true, ReflectSky = false, Strength = .7, ResolutionScale = .75,
MaxSteps = 96, StridePixels = 3, MaxDistanceMeters = 8000, MaxSurfaceDistanceMeters = 6000,
ThicknessMeters = 2.5, NormalBiasMeters = .2, EdgeFade = .1 };
var path = Path.Combine(Path.GetTempPath(), $"universe-ocean-{Guid.NewGuid():N}.json");
KnownSolarSystemCatalog Load(OceanReflectionSettings? settings)
{
var definition = new SolarSystemDocument { Systems = [new SolarSystemDefinition
{
Id = "water-system", Name = "Water test", Sector = new(0, 0, 0),
Stars = [new StarDefinition { Id = "sun", Name = "Sun", RadiusMeters = 695700000 }],
Planets = [new PlanetDefinition { Id = "ocean", Name = "Ocean", RadiusMeters = 6371000,
Orbit = new() { SemiMajorAxisAu = 1 }, Ocean = new() { MaskFromSurfaceTexture = false, ScreenSpaceReflections = settings! } }]
}] };
File.WriteAllText(path, JsonSerializer.Serialize(definition));
return KnownSolarSystemCatalog.Load(path);
}
try
{
var parsed = Load(quality).GetById("water-system").Planets.Single().Ocean!.ScreenSpaceReflections;
Assert.Equal(quality, parsed);
foreach (var invalid in new OceanReflectionSettings?[] { quality with { MaxSteps = 0 }, quality with { MaxSteps = 129 },
quality with { ResolutionScale = 0 }, quality with { ThicknessMeters = 0 }, quality with { NormalBiasMeters = -1 }, null })
{
Assert.Throws<InvalidDataException>(() => Load(invalid));
}
}
finally { File.Delete(path); }
}
[Fact] [Fact]
public void WaterMaskDistinguishesBlueWaterFromLandAndIce() public void WaterMaskDistinguishesBlueWaterFromLandAndIce()
{ {