From cb5070dc1c0d5da9dd46a55a7ca11951cdedd049 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 1 Nov 2026 21:46:25 +0100 Subject: [PATCH] =?UTF-8?q?screen=20space=20reflections=20vatten=20och=20d?= =?UTF-8?q?=C3=A4mpad=20motion=20blur=20p=C3=A5=20sk=C3=A4rnor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KnownSolarSystem/README.md | 26 ++++- KnownSolarSystem/solar-systems.json | 13 +++ README.md | 6 +- config.json | 2 +- src/Universe.App/Graphics/GraphicsReport.cs | 108 +++++++++++++++++- .../Graphics/PerformanceOverlay.cs | 1 + src/Universe.App/Graphics/RenderTarget.cs | 29 ++++- src/Universe.App/Graphics/ShaderSources.cs | 2 +- .../ocean-reflection-composite.frag.glsl | 40 +++++++ .../Shaders/ocean-reflection.frag.glsl | 17 +++ .../Graphics/Shaders/ocean.frag.glsl | 28 ++--- .../Shaders/screen-space-reflection.glsl | 84 ++++++++++++++ .../Graphics/Shaders/star-trail.frag.glsl | 21 ++-- .../Graphics/Shaders/star-trail.geom.glsl | 22 ++-- src/Universe.App/Graphics/UniverseRenderer.cs | 73 +++++++++++- .../Configuration/VisualSettingsValidator.cs | 10 ++ src/Universe.Core/Generation/OceanSettings.cs | 16 +++ .../Generation/StarMotionBlurSettings.cs | 4 +- tests/Universe.Tests/OceanTests.cs | 33 ++++++ 19 files changed, 480 insertions(+), 55 deletions(-) create mode 100644 src/Universe.App/Graphics/Shaders/ocean-reflection-composite.frag.glsl create mode 100644 src/Universe.App/Graphics/Shaders/ocean-reflection.frag.glsl create mode 100644 src/Universe.App/Graphics/Shaders/screen-space-reflection.glsl diff --git a/KnownSolarSystem/README.md b/KnownSolarSystem/README.md index 1151dbf..bf5f22a 100644 --- a/KnownSolarSystem/README.md +++ b/KnownSolarSystem/README.md @@ -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 | | `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. diff --git a/KnownSolarSystem/solar-systems.json b/KnownSolarSystem/solar-systems.json index d29aa06..e24d3b7 100644 --- a/KnownSolarSystem/solar-systems.json +++ b/KnownSolarSystem/solar-systems.json @@ -201,6 +201,19 @@ }, "ocean": { "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, "seaLevelMeters": 0, "depthMeters": 4000, diff --git a/README.md b/README.md index 0118e9d..0b87f08 100644 --- a/README.md +++ b/README.md @@ -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. -`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. -`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 diff --git a/config.json b/config.json index 54496c1..f7bc1a5 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,7 @@ "shutterFraction": 0.9, "maximumTrailPixels": 180, "minimumWidthPixels": 1.4, - "movingBrightness": 1.8, + "movingBrightness": 1.0, "historyResetSeconds": 0.25, "starFadeSeconds": 0.3 }, diff --git a/src/Universe.App/Graphics/GraphicsReport.cs b/src/Universe.App/Graphics/GraphicsReport.cs index 8521c81..b24085f 100644 --- a/src/Universe.App/Graphics/GraphicsReport.cs +++ b/src/Universe.App/Graphics/GraphicsReport.cs @@ -85,7 +85,7 @@ internal static class GraphicsReport camera.SetPlayerPose(center.Add(new(0,0,-12*scale),cell),camera.Forward,camera.Up); var moving=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); if(moving.Any(p=>!float.IsFinite(p))||movingCoverageSystem.Math.Abs(a-b)).Max()>.0001) 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]); + double Energy(float[] pixels) => pixels.Where((_,i)=>i%4<3).Sum(v=>(double)v); + var energyRatios = new List(); + 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.Add(new(-6*scale,0,0),cell),camera.Forward,camera.Up); 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; if(spansettings.MaximumTrailPixels+14) 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); WritePng(Path.Combine(directory,"star-motion-blur.png"),width,height,moving); 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); } @@ -339,6 +354,7 @@ internal static class GraphicsReport 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 origin = UniversePosition.AtCellCenter(neighborhood.ActiveSystem.Coordinate, cellSize); var camera = new FlyCamera(origin, cellSize); @@ -362,12 +378,96 @@ internal static class GraphicsReport 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."); + 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); 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}"); + 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(waterEnumerable.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) { diff --git a/src/Universe.App/Graphics/PerformanceOverlay.cs b/src/Universe.App/Graphics/PerformanceOverlay.cs index 6a658c6..04a6b36 100644 --- a/src/Universe.App/Graphics/PerformanceOverlay.cs +++ b/src/Universe.App/Graphics/PerformanceOverlay.cs @@ -35,6 +35,7 @@ internal sealed class PerformanceOverlay : IDisposable AddMetric("GAS GIANTS", "GAS GIANTS"); AddMetric("RINGS / ROCKS", "RINGS / ROCKS"); AddMetric("OCEAN", "OCEAN"); + AddMetric("WATER REFLECTIONS", "WATER REFLECTIONS"); AddMetric("ATMOSPHERE", "ATMOSPHERE"); AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE"); AddMetric("SUN GLOW", "SUN GLOW"); diff --git a/src/Universe.App/Graphics/RenderTarget.cs b/src/Universe.App/Graphics/RenderTarget.cs index 0337bfd..dc8a86e 100644 --- a/src/Universe.App/Graphics/RenderTarget.cs +++ b/src/Universe.App/Graphics/RenderTarget.cs @@ -8,10 +8,11 @@ internal sealed unsafe class RenderTarget : IDisposable public uint Framebuffer { get; } public uint Color { get; } public uint Depth { get; } + public uint[] AuxiliaryColors { get; } public int Width { 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; 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); SetParameters(gl, GLEnum.Linear); 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) { Depth = gl.GenTexture(); @@ -36,10 +46,24 @@ internal sealed unsafe class RenderTarget : IDisposable gl.BindFramebuffer(GLEnum.Framebuffer, 0); } - public void Bind() + public void Bind(bool allColors = false) { _gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer); _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) @@ -53,6 +77,7 @@ internal sealed unsafe class RenderTarget : IDisposable public void Dispose() { _gl.DeleteTexture(Color); + foreach (var texture in AuxiliaryColors) _gl.DeleteTexture(texture); if (Depth != 0) _gl.DeleteTexture(Depth); _gl.DeleteFramebuffer(Framebuffer); } diff --git a/src/Universe.App/Graphics/ShaderSources.cs b/src/Universe.App/Graphics/ShaderSources.cs index 0ffc619..b81c606 100644 --- a/src/Universe.App/Graphics/ShaderSources.cs +++ b/src/Universe.App/Graphics/ShaderSources.cs @@ -17,7 +17,7 @@ internal static class ShaderSources } 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\""; if (source.Contains(directive, StringComparison.Ordinal)) diff --git a/src/Universe.App/Graphics/Shaders/ocean-reflection-composite.frag.glsl b/src/Universe.App/Graphics/Shaders/ocean-reflection-composite.frag.glsl new file mode 100644 index 0000000..ed676b2 --- /dev/null +++ b/src/Universe.App/Graphics/Shaders/ocean-reflection-composite.frag.glsl @@ -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); +} diff --git a/src/Universe.App/Graphics/Shaders/ocean-reflection.frag.glsl b/src/Universe.App/Graphics/Shaders/ocean-reflection.frag.glsl new file mode 100644 index 0000000..085dd16 --- /dev/null +++ b/src/Universe.App/Graphics/Shaders/ocean-reflection.frag.glsl @@ -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); +} diff --git a/src/Universe.App/Graphics/Shaders/ocean.frag.glsl b/src/Universe.App/Graphics/Shaders/ocean.frag.glsl index e1ccbdd..c88fee7 100644 --- a/src/Universe.App/Graphics/Shaders/ocean.frag.glsl +++ b/src/Universe.App/Graphics/Shaders/ocean.frag.glsl @@ -27,7 +27,9 @@ uniform vec3 uLightDirections[2]; uniform vec3 uLightColors[2]; uniform float uLightFlux[2]; 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; 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; } @@ -98,25 +100,9 @@ vec3 skyReflection(vec3 direction,vec3 up) { } 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.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=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)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; +} diff --git a/src/Universe.App/Graphics/Shaders/star-trail.frag.glsl b/src/Universe.App/Graphics/Shaders/star-trail.frag.glsl index 7006a01..50ff2a1 100644 --- a/src/Universe.App/Graphics/Shaders/star-trail.frag.glsl +++ b/src/Universe.App/Graphics/Shaders/star-trail.frag.glsl @@ -4,14 +4,21 @@ flat in vec3 gColor; flat in float gLength; flat in float gSigma; 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() { - 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 profile=exp(-dot(delta,delta)/(2.0*sigma2)); - float along=gLength>.5?mix(.32,1.0,smoothstep(0.0,1.0,closest/gLength)):1.0; - float head=exp(-((gPixel.x-gLength)*(gPixel.x-gLength)+gPixel.y*gPixel.y)/(2.0*sigma2)); - float brightness=profile*along+head*.15; - if(brightness<.003)discard; + float sigma=sqrt(sigma2); + float along; + if(gLength<.2)along=exp(-pow(gPixel.x-gLength*.5,2.0)/(2.0*sigma2)); + else along=1.253314137*sigma/gLength*(erfApprox(gPixel.x/(1.414213562*sigma)) + -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); } diff --git a/src/Universe.App/Graphics/Shaders/star-trail.geom.glsl b/src/Universe.App/Graphics/Shaders/star-trail.geom.glsl index 713dd7c..b656300 100644 --- a/src/Universe.App/Graphics/Shaders/star-trail.geom.glsl +++ b/src/Universe.App/Graphics/Shaders/star-trail.geom.glsl @@ -33,26 +33,28 @@ void main() { if(tail.wuMaximumTrailPixels)t=h-(h-t)*(uMaximumTrailPixels/fullLength); + vec2 originalTail=t; + gLength=length(h-t); vec2 delta=h-t; float lo=0.0,hi=1.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; h=t+delta*hi;t+=delta*lo; - float distancePixels=length(h-t); - if(distancePixels>uMaximumTrailPixels)t=h-(h-t)*(uMaximumTrailPixels/distancePixels); - gLength=length(h-t); - vec2 direction=gLength>.001?(h-t)/gLength:vec2(1,0); + vec2 direction=length(h-t)>.001?normalize(h-t):vec2(1,0); vec2 perpendicular=vec2(-direction.y,direction.x); // A minimum Gaussian footprint averages subpixel motion instead of toggling // a one-pixel point on and off as the camera passes it. 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 boost=mix(1.0,uMovingBrightness,moving); - gColor=star[0].color*.7*boost; - vertex(t-direction*radius-perpendicular*radius,vec2(-radius,-radius)); - vertex(t-direction*radius+perpendicular*radius,vec2(-radius,radius)); - vertex(h+direction*radius-perpendicular*radius,vec2(gLength+radius,-radius)); - vertex(h+direction*radius+perpendicular*radius,vec2(gLength+radius,radius)); + gColor=star[0].color*.805*boost; + float start=length(t-originalTail),end=length(h-originalTail); + vertex(t-direction*radius-perpendicular*radius,vec2(start-radius,-radius)); + vertex(t-direction*radius+perpendicular*radius,vec2(start-radius,radius)); + vertex(h+direction*radius-perpendicular*radius,vec2(end+radius,-radius)); + vertex(h+direction*radius+perpendicular*radius,vec2(end+radius,radius)); EndPrimitive(); } diff --git a/src/Universe.App/Graphics/UniverseRenderer.cs b/src/Universe.App/Graphics/UniverseRenderer.cs index fdd6d79..5123dd0 100644 --- a/src/Universe.App/Graphics/UniverseRenderer.cs +++ b/src/Universe.App/Graphics/UniverseRenderer.cs @@ -45,6 +45,8 @@ internal sealed class UniverseRenderer : IDisposable private ShaderProgram _sunGlow; private ShaderProgram _blackHole; private ShaderProgram _ocean; + private ShaderProgram _oceanReflection; + private ShaderProgram _oceanReflectionComposite; private ShaderProgram _clouds; private ShaderProgram _cloudComposite; private readonly ShaderProgram _overlay; @@ -52,6 +54,10 @@ internal sealed class UniverseRenderer : IDisposable private readonly CloudNoiseTexture _cloudNoise; private readonly SurfaceMaterials _materials; private RenderTarget? _cloudRaw; + private RenderTarget? _waterReflectionRaw; + private RenderTarget _waterReflectionTarget = null!; + private bool _oceanRendered; + private string? _cloudLayerPlanetId; internal RenderTarget? CloudLayer => _cloudRaw; private readonly GpuMesh _navigationStars; private RenderTarget _scene = null!; @@ -118,6 +124,8 @@ internal sealed class UniverseRenderer : IDisposable _sunGlow = Program("fullscreen.vert", "sun-glow.frag"); _blackHole = Program("fullscreen.vert", "black-hole.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"); _cloudComposite = Program("fullscreen.vert", "cloud-composite.frag"); _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"), ("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","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)); _starTrails.ReloadShaders(); } @@ -198,6 +207,8 @@ internal sealed class UniverseRenderer : IDisposable _ocean.Dispose(); _ocean = replacements[13]; _clouds.Dispose(); _clouds = replacements[14]; _cloudComposite.Dispose(); _cloudComposite = replacements[15]; _blackHole.Dispose(); _blackHole = replacements[16]; + _oceanReflection.Dispose(); _oceanReflection = replacements[17]; + _oceanReflectionComposite.Dispose(); _oceanReflectionComposite = replacements[18]; } public void SetActiveSystem(SectorCoordinate coordinate) @@ -236,9 +247,11 @@ internal sealed class UniverseRenderer : IDisposable _width = System.Math.Max(width, 1); _height = System.Math.Max(height, 1); _cloudRaw?.Dispose(); _cloudRaw = null; + _waterReflectionRaw?.Dispose(); _waterReflectionRaw = null; _waterReflectionTarget?.Dispose(); _scene?.Dispose(); _gasInput?.Dispose(); _glareTarget?.Dispose(); _scatterA?.Dispose(); _scatterB?.Dispose(); _bloomA?.Dispose(); _bloomB?.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); _glareTarget = 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(); BindNoise(); _gpuProfiler.BeginFrame(DebugMode); - _scene.Bind(); + _oceanRendered = false; _cloudLayerPlanetId = null; + _scene.Bind(allColors: true); _gl.DepthMask(true); - _gl.ClearColor(0, 0, 0, 1); + _gl.ClearColor(0, 0, 0, 0); _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + _scene.Bind(); _gl.Disable(GLEnum.Blend); _gl.Disable(GLEnum.DepthTest); _gl.DepthMask(false); @@ -330,6 +345,10 @@ internal sealed class UniverseRenderer : IDisposable composed = DrawClouds(camera, system, far, composed); _gpuProfiler.EndPass(); Mark("clouds"); + _gpuProfiler.BeginPass("WATER REFLECTIONS"); + composed = DrawOceanReflections(camera, system, far, composed); + _gpuProfiler.EndPass(); + Mark("water reflections"); _gpuProfiler.BeginPass("BLACK HOLE / LENSING"); composed = DrawBlackHoles(camera, system, far, composed); _gpuProfiler.EndPass(); @@ -674,7 +693,9 @@ internal sealed class UniverseRenderer : IDisposable _gl.BindFramebuffer(GLEnum.DrawFramebuffer, _gasInput.Framebuffer); _gl.BlitFramebuffer(0, 0, _width, _height, 0, 0, _width, _height, 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); BindCloudSettings(_ocean, camera, system, planet, relative); 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()); _fullscreen.Draw(); } + _gl.Disable(GLEnum.Blend); _scene.Bind(); _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) { 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("uCloudTexel", new Vector3(1f / width, 1f / height, 0)); _fullscreen.Draw(); source = target; + _cloudLayerPlanetId = planet.Id; } return source; } @@ -1119,6 +1181,7 @@ internal sealed class UniverseRenderer : IDisposable _sunGlow.Dispose(); _glareTarget.Dispose(); _blackHole.Dispose(); _blackHoleTarget.Dispose(); _ocean.Dispose(); + _oceanReflection.Dispose(); _oceanReflectionComposite.Dispose(); _waterReflectionRaw?.Dispose(); _waterReflectionTarget.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(); } diff --git a/src/Universe.Core/Configuration/VisualSettingsValidator.cs b/src/Universe.Core/Configuration/VisualSettingsValidator.cs index 018e6a4..35633d9 100644 --- a/src/Universe.Core/Configuration/VisualSettingsValidator.cs +++ b/src/Universe.Core/Configuration/VisualSettingsValidator.cs @@ -31,6 +31,16 @@ internal static class VisualSettingsValidator } 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.DepthMeters, 1, 100000, id, "ocean.depthMeters"); Range(o.WaveAmplitudeMeters, 0, 30, id, "ocean.waveAmplitudeMeters"); diff --git a/src/Universe.Core/Generation/OceanSettings.cs b/src/Universe.Core/Generation/OceanSettings.cs index 2ee77cb..40ab5f5 100644 --- a/src/Universe.Core/Generation/OceanSettings.cs +++ b/src/Universe.Core/Generation/OceanSettings.cs @@ -5,6 +5,7 @@ namespace Universe.Core.Generation; /// Metres, seconds and linear RGB. A null ocean on a planet disables water. public sealed record OceanSettings { + public OceanReflectionSettings ScreenSpaceReflections { get; init; } = new(); public bool Enabled { get; init; } = true; public bool MaskFromSurfaceTexture { get; init; } = true; public double SeaLevelMeters { get; init; } @@ -23,6 +24,21 @@ public sealed record OceanSettings 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 static class OceanSurface diff --git a/src/Universe.Core/Generation/StarMotionBlurSettings.cs b/src/Universe.Core/Generation/StarMotionBlurSettings.cs index d54cc10..6f3e001 100644 --- a/src/Universe.Core/Generation/StarMotionBlurSettings.cs +++ b/src/Universe.Core/Generation/StarMotionBlurSettings.cs @@ -6,7 +6,7 @@ public sealed record StarMotionBlurSettings public double ShutterFraction { get; init; } = .9; public double MaximumTrailPixels { get; init; } = 180; 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 StarFadeSeconds { get; init; } = .3; @@ -15,7 +15,7 @@ public sealed record StarMotionBlurSettings if (!double.IsFinite(ShutterFraction) || ShutterFraction is < 0 or > 2 || !double.IsFinite(MaximumTrailPixels) || MaximumTrailPixels is < 1 or > 1024 || !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(StarFadeSeconds) || StarFadeSeconds is < .05 or > 2) throw new InvalidDataException("config.json: invalid starMotionBlur settings."); diff --git a/tests/Universe.Tests/OceanTests.cs b/tests/Universe.Tests/OceanTests.cs index 64eac8e..f5ff4e4 100644 --- a/tests/Universe.Tests/OceanTests.cs +++ b/tests/Universe.Tests/OceanTests.cs @@ -3,11 +3,44 @@ using Universe.Core.Generation; using Universe.Core.Math; using Universe.Core.Physics; using Universe.Core.World; +using Universe.Core.Configuration; +using System.Text.Json; namespace Universe.Tests; 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(() => Load(invalid)); + } + } + finally { File.Delete(path); } + } [Fact] public void WaterMaskDistinguishesBlueWaterFromLandAndIce() {