From a7fdcfa760051f5c583e51f9d2d1c080216a926a Mon Sep 17 00:00:00 2001 From: siimav <1120038+siimav@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:15:14 +0300 Subject: [PATCH 1/2] Improve cliffifier --- .../RSSKopernicus/Earth/Earth.cfg | 3 +- Source/GUI.cs | 22 ++ .../PQSMod_VertexDefineCoastSmooth.cs | 249 +++++++++++++++++- .../KopernicusMods/VertexDefineCoastSmooth.cs | 17 ++ .../BatchPQSMod_VertexDefineCoastSmooth.cs | 169 ++++++++++-- 5 files changed, 436 insertions(+), 24 deletions(-) diff --git a/GameData/RealSolarSystem/RSSKopernicus/Earth/Earth.cfg b/GameData/RealSolarSystem/RSSKopernicus/Earth/Earth.cfg index 65a07432..1d905ab7 100644 --- a/GameData/RealSolarSystem/RSSKopernicus/Earth/Earth.cfg +++ b/GameData/RealSolarSystem/RSSKopernicus/Earth/Earth.cfg @@ -447,7 +447,8 @@ { minOffset = -15 maxOffset = 15 - slopeScale = 8 + coastSpacings = 2 + gradientStencil = 1 order = 105 } VertexHeightNoiseVertHeight diff --git a/Source/GUI.cs b/Source/GUI.cs index 4e28760a..54991bcb 100644 --- a/Source/GUI.cs +++ b/Source/GUI.cs @@ -37,6 +37,8 @@ public class RealSolarSystemEditor : MonoBehaviour private string _sMinHeightOffset; private string _sMaxHeightOffset; private string _sSlopeScale; + private string _sCoastSpacings; + private string _sGradientStencil; private string _sRssDefineOrder; private string _sHeightStart; @@ -228,6 +230,8 @@ private void ShowGUI(int windowID) _sMinHeightOffset = _pModRssDefine.minHeightOffset.ToString(); _sMaxHeightOffset = _pModRssDefine.maxHeightOffset.ToString(); _sSlopeScale = _pModRssDefine.slopeScale.ToString(); + _sCoastSpacings = _pModRssDefine.coastSpacings.ToString(); + _sGradientStencil = _pModRssDefine.gradientStencil.ToString(); _sRssDefineOrder = _pModRssDefine.order.ToString(); } @@ -387,6 +391,24 @@ private void ShowGUI(int windowID) _pModRssDefine.slopeScale = val; } + GUILayout.BeginHorizontal(); + GUILayout.Label("coastSpacings: "); + GUILayout.EndHorizontal(); + _sCoastSpacings = GUILayout.TextField(_sCoastSpacings); + if (double.TryParse(_sCoastSpacings, out double coastSpacings)) + { + _pModRssDefine.coastSpacings = coastSpacings; + } + + GUILayout.BeginHorizontal(); + GUILayout.Label("gradientStencil (texels): "); + GUILayout.EndHorizontal(); + _sGradientStencil = GUILayout.TextField(_sGradientStencil); + if (double.TryParse(_sGradientStencil, out double gradientStencil)) + { + _pModRssDefine.gradientStencil = gradientStencil; + } + GUILayout.BeginHorizontal(); GUILayout.Label("Mod order: "); GUILayout.EndHorizontal(); diff --git a/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs b/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs index d9e185b2..292002c0 100644 --- a/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs +++ b/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs @@ -4,6 +4,7 @@ */ using System; +using UnityEngine; namespace RealSolarSystem { @@ -14,16 +15,81 @@ public class PQSMod_VertexDefineCoastSmooth : PQSMod { public double minHeightOffset; public double maxHeightOffset; + + // Legacy fixed slopeScale behaviour. Has no effect in adaptive mode. + // This is unavoidably either too soft on flat coasts or steep enough to quantise every vertex onto the two + // plateaus - and once that happens the waterline snaps to the vertex grid's edge midpoints + // and turns into a staircase. public double slopeScale; + // Target half-width of the land/water transition, in vertex spacings of the quad being + // built. When positive the mod runs in adaptive mode: the ramp is sized from the local + // height map gradient so that the coast always crosses sea level over roughly this + // distance, whatever the terrain does. Zero (the default) keeps the fixed slopeScale behaviour. + // + // Spacings rather than metres because a quad's vertex spacing doubles for every subdivision + // level below the maximum, and the maximum itself moves with the terrain detail preset. A + // width fixed in metres is only correct in a shell around the camera. + public double coastSpacings; + + // Half-width of the central difference used to measure that gradient, in height map texels. + public double gradientStencil; + private double minHeight; private double maxHeight; + private double invDepth; + private double invRise; + private bool bandCrossesSeaLevel; + + private bool isAdaptive; + private MapSO heightMap; + private double mapDeformity; + private double du; + private double dv; + private double invTwoDu; + private double invTwoDv; + private double metresPerU; + private double metresPerV; + private double[] levelSpacing; + private double maxLevelSpacing; + + // Resolved adaptive setup. Exposed so the Burst path can mirror this mod exactly instead of + // repeating the height map search and re-deriving the constants - the two implementations + // have to agree vertex for vertex or terrain changes depending on whether BurstPQS is + // installed. Only meaningful while IsAdaptive is true. + public bool IsAdaptive => isAdaptive; + public MapSO AdaptiveHeightMap => heightMap; + public double AdaptiveMapDeformity => mapDeformity; + public double AdaptiveStencilU => du; + public double AdaptiveStencilV => dv; + public double AdaptiveMetresPerU => metresPerU; + public double AdaptiveMetresPerV => metresPerV; + + /// + /// Half-width of the ramp in metres of ground for a quad at the given subdivision level. + /// The Burst path resolves this once per quad rather than per vertex, since it is handed + /// the quad up front. + /// + public double AdaptiveRampWidth(int subdivision) + { + if (levelSpacing == null) + { + return 0.0; + } + + double spacing = subdivision >= 0 && subdivision < levelSpacing.Length + ? levelSpacing[subdivision] + : maxLevelSpacing; + return coastSpacings * spacing; + } private void Reset() { minHeightOffset = -1.0; maxHeightOffset = 1.0; slopeScale = 1.0; + coastSpacings = 0.0; + gradientStencil = 1.0; } public override void OnSetup() @@ -31,18 +97,100 @@ public override void OnSetup() requirements = PQS.ModiferRequirements.MeshCustomNormals; minHeight = sphere.radius + minHeightOffset; maxHeight = sphere.radius + maxHeightOffset; + isAdaptive = false; + + // The two halves of the band are scaled independently, so the offsets need not be + // symmetric. Dropping the seabed further than the land rises steepens the waterline + // without turning coastal lowland into a mesa - the deep side is hidden under the ocean. + bandCrossesSeaLevel = minHeightOffset < 0.0 && maxHeightOffset > 0.0; + if (!bandCrossesSeaLevel) + { + Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name} is inactive: band [{minHeightOffset}, {maxHeightOffset}] does not cross sea level"); + return; + } + + invDepth = -1.0 / minHeightOffset; + invRise = 1.0 / maxHeightOffset; + + if (coastSpacings > 0.0) + { + isAdaptive = BindHeightMap(); + if (isAdaptive) + { + requirements |= PQS.ModiferRequirements.VertexMapCoords; + BuildSpacingTable(); + } + } + + // The rendered waterline sits where the mesh crosses sea level, and with different + // amplitudes either side the crossing is pulled towards the shallower one - a constant + // bias of tens of metres that no ramp width fixes. Keep the band symmetric. + if (isAdaptive && Math.Abs(maxHeightOffset + minHeightOffset) > 1E-6) + { + Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name}:" + + $" asymmetric band [{minHeightOffset}, {maxHeightOffset}] displaces the waterline" + + " off the height map contour; prefer equal offsets"); + } } public override void OnVertexBuildHeight(PQS.VertexBuildData data) { - if (data.vertHeight > minHeight && data.vertHeight < maxHeight) + if (!bandCrossesSeaLevel) + { + return; + } + + if (data.vertHeight <= minHeight || data.vertHeight >= maxHeight) { - // 7th order polynomial smoothstep. - double x = (data.vertHeight - minHeight) / (maxHeight - minHeight); - x = Math.Min(Math.Max(0.0, (x - 0.5) * slopeScale + 0.5), 1.0); // No Math.clamp? - double y = -20.0 * Math.Pow(x, 7.0) + 70 * Math.Pow(x, 6.0) - 84.0 * Math.Pow(x, 5.0) + 35.0 * Math.Pow(x, 4.0); - data.vertHeight = y * (maxHeight - minHeight) + minHeight; + return; + } + + double height = data.vertHeight - sphere.radius; + + // Signed position within the band: sea level at 0, the two band edges at -1 and 1. + double t; + if (isAdaptive) + { + // Grade the coast over a fixed number of vertex spacings of whatever quad is being + // built, so distant low-detail quads get a proportionally wider ramp instead of + // collapsing onto the plateaus. buildQuad is null on the GetSurfaceHeight path, + // which has no mesh to grade, so answer those at the finest level. + double spacing = maxLevelSpacing; + if (data.buildQuad != null) + { + int level = data.buildQuad.subdivision; + if (level >= 0 && level < levelSpacing.Length) + { + spacing = levelSpacing[level]; + } + } + + // Raw height the terrain gains over that distance of ground. Capping it at the band + // keeps the ramp finishing exactly on the plateau, with no step at the edge. + // Grouped so this is the same product the Burst path folds into its per-quad ramp + // width; multiplication is not associative in floating point and the two + // implementations have to agree to the last bit. + double window = GetLocalGradient(data) * (coastSpacings * spacing); + window = Math.Min(window, height < 0.0 ? -minHeightOffset : maxHeightOffset); + // Not Math.Sign, which throws on NaN - and a NaN height slips through the band test + // above, since every comparison against NaN is false. + t = window > 0.0 ? height / window : (height < 0.0 ? -1.0 : (height > 0.0 ? 1.0 : 0.0)); } + else + { + // slopeScale below 1 leaves a step at the band edges, same as it always has. + t = (height < 0.0 ? height * invDepth : height * invRise) * slopeScale; + } + t = Math.Min(Math.Max(-1.0, t), 1.0); + + // Odd extension of the 7th order smoothstep onto [-1, 1], i.e. 2 * S((t + 1) / 2) - 1. + // Sea level is an exact fixed point of this, so the waterline stays on the height + // map's own contour instead of drifting. + double x = (t + 1.0) * 0.5; + double x2 = x * x; + double s = 2.0 * (x2 * x2 * (35.0 - 84.0 * x + 70.0 * x2 - 20.0 * x2 * x)) - 1.0; + + data.vertHeight = sphere.radius + (s < 0.0 ? -s * minHeightOffset : s * maxHeightOffset); } public override double GetVertexMaxHeight() @@ -54,5 +202,94 @@ public override double GetVertexMinHeight() { return minHeightOffset; } + + /// + /// Magnitude of the height map's slope at this vertex, in metres of rise per metre travelled. + /// + private double GetLocalGradient(PQS.VertexBuildData data) + { + // GetPixelFloat wraps both axes, which is right for longitude but would jump across the + // pole in v, so keep the stencil inside the map vertically. + double v = Math.Min(Math.Max(data.v, dv), 1.0 - dv); + + double dHdu = mapDeformity * invTwoDu * + (heightMap.GetPixelFloat(data.u + du, v) - heightMap.GetPixelFloat(data.u - du, v)); + double dHdv = mapDeformity * invTwoDv * + (heightMap.GetPixelFloat(data.u, v + dv) - heightMap.GetPixelFloat(data.u, v - dv)); + + // u spans the full circumference, v spans pole to pole; meridians converge with latitude. + // directionFromCenter is a unit radial, so the length of its horizontal part is exactly cos(latitude) + Vector3d dir = data.directionFromCenter; + double cosLat = Math.Sqrt(dir.x * dir.x + dir.z * dir.z); + if (cosLat < 1E-3) + { + cosLat = 1E-3; + } + + double gu = dHdu / (metresPerU * cosLat); + double gv = dHdv / metresPerV; + return Math.Sqrt(gu * gu + gv * gv); + } + + /// + /// Vertex spacing for every subdivision level. A quad at level L spans (pi * R / 2) / 2^L + /// and carries cacheSideVertCount vertices per side, so spacing doubles for each level + /// below the maximum - which is exactly how far a coast has to be graded to stay smooth. + /// + private void BuildSpacingTable() + { + int intervals = PQS.cacheSideVertCount > 1 ? PQS.cacheSideVertCount - 1 : 1; + double rootEdge = Math.PI * sphere.radius * 0.5; + + levelSpacing = new double[sphere.maxLevel + 1]; + for (int level = 0; level <= sphere.maxLevel; level++) + { + levelSpacing[level] = rootEdge / (1 << level) / intervals; + } + maxLevelSpacing = levelSpacing[sphere.maxLevel]; + } + + /// + /// Locates the VertexHeightMap this body's coastline comes from, so the ramp can be sized + /// from the map's own gradient. Sampling the map rather than differencing the mesh keeps the + /// measured gradient independent of subdivision level, so the waterline itself stays put as + /// quads split - only the steepness either side of it changes. + /// + private bool BindHeightMap() + { + PQSMod_VertexHeightMap best = null; + foreach (PQSMod_VertexHeightMap mod in sphere.GetComponentsInChildren(true)) + { + // Mirror the filtering PQS itself applies when it assembles the mod list. + if (mod.heightMap == null || !mod.modEnabled || !mod.gameObject.activeSelf) + { + continue; + } + if (best == null || mod.order < best.order) + { + best = mod; + } + } + + if (best == null) + { + Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name}: coastWidth is set but no VertexHeightMap was found, falling back to fixed slopeScale"); + return false; + } + + heightMap = best.heightMap; + // The stock mod adds heightMapOffset + heightMapDeformity * pixel; the offset is constant + // and drops out of a difference, so only the deformity scales the gradient. + mapDeformity = best.heightMapDeformity; + + double stencil = gradientStencil > 0.0 ? gradientStencil : 1.0; + du = stencil / heightMap.Width; + dv = stencil / heightMap.Height; + invTwoDu = 1.0 / (2.0 * du); + invTwoDv = 1.0 / (2.0 * dv); + metresPerU = 2.0 * Math.PI * sphere.radius; + metresPerV = Math.PI * sphere.radius; + return true; + } } } diff --git a/Source/KopernicusMods/VertexDefineCoastSmooth.cs b/Source/KopernicusMods/VertexDefineCoastSmooth.cs index 7230495b..a3a9f0c6 100644 --- a/Source/KopernicusMods/VertexDefineCoastSmooth.cs +++ b/Source/KopernicusMods/VertexDefineCoastSmooth.cs @@ -34,5 +34,22 @@ public NumericParser slopeScale get { return Mod.slopeScale; } set { Mod.slopeScale = value; } } + + // Target half-width of the transition, in vertex spacings. Positive enables adaptive mode, + // which sizes the ramp from the height map gradient and ignores slopeScale. + [ParserTarget("coastSpacings")] + public NumericParser coastSpacings + { + get { return Mod.coastSpacings; } + set { Mod.coastSpacings = value; } + } + + // Central difference half-width used to measure that gradient, in height map texels. + [ParserTarget("gradientStencil")] + public NumericParser gradientStencil + { + get { return Mod.gradientStencil; } + set { Mod.gradientStencil = value; } + } } } \ No newline at end of file diff --git a/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs b/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs index c35468b1..8ba32efc 100644 --- a/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs +++ b/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs @@ -1,52 +1,187 @@ -using System; +using System; using BurstPQS; -using BurstPQS.Util; +using BurstPQS.Map; using RealSolarSystem; using Unity.Burst; +using UnityEngine; [BurstCompile] [BatchPQSMod(typeof(PQSMod_VertexDefineCoastSmooth))] public class BatchPQSMod_VertexDefineCoastSmooth : BatchPQSMod { + private bool _mapSupported; + public BatchPQSMod_VertexDefineCoastSmooth(PQSMod_VertexDefineCoastSmooth mod) : base(mod) { } + public override void OnSetup() + { + base.OnSetup(); + + // The stock mod has already resolved which height map the coastline comes from; all this + // has to decide is whether BurstPQS can read it. If it cannot, fall back to the fixed + // slopeScale ramp and say so, because silently dropping adaptive mode here would make the + // terrain depend on whether BurstPQS happens to be installed. + _mapSupported = Mod.IsAdaptive + && Mod.AdaptiveHeightMap != null + && BurstMapSO.IsSupported(Mod.AdaptiveHeightMap); + + if (Mod.IsAdaptive && !_mapSupported) + { + Debug.LogWarning("[RealSolarSystem] VertexDefineCoastSmooth: height map" + + $" {Mod.AdaptiveHeightMap?.GetType().Name ?? "(none)"} is not supported by BurstPQS," + + " falling back to fixed slopeScale"); + } + } + public override void OnQuadPreBuild(PQ quad, BatchPQSJobSet jobSet) { base.OnQuadPreBuild(quad, jobSet); - jobSet.Add(new BuildJob + + double minHeightOffset = Mod.minHeightOffset; + double maxHeightOffset = Mod.maxHeightOffset; + if (minHeightOffset >= 0.0 || maxHeightOffset <= 0.0) { - minHeightOffset = Mod.minHeightOffset, - maxHeightOffset = Mod.maxHeightOffset, + // Band does not straddle sea level; the stock mod logs this and goes inert. + return; + } + + BuildJob job = new BuildJob + { + minHeightOffset = minHeightOffset, + maxHeightOffset = maxHeightOffset, + invDepth = -1.0 / minHeightOffset, + invRise = 1.0 / maxHeightOffset, slopeScale = Mod.slopeScale, - }); + }; + + if (_mapSupported) + { + double du = Mod.AdaptiveStencilU; + double dv = Mod.AdaptiveStencilV; + + job.isAdaptive = true; + job.heightMap = BurstMapSO.Create(Mod.AdaptiveHeightMap); + job.mapDeformity = Mod.AdaptiveMapDeformity; + job.du = du; + job.dv = dv; + job.invTwoDu = 1.0 / (2.0 * du); + job.invTwoDv = 1.0 / (2.0 * dv); + job.metresPerU = Mod.AdaptiveMetresPerU; + job.metresPerV = Mod.AdaptiveMetresPerV; + + // The subdivision level is known here, so the ramp width is resolved once per quad + // instead of once per vertex the way the stock path has to do it. + job.rampWidth = Mod.AdaptiveRampWidth(quad.subdivision); + } + + jobSet.Add(job); } [BurstCompile] - struct BuildJob : IBatchPQSHeightJob + struct BuildJob : IBatchPQSHeightJob, IDisposable { public double minHeightOffset; public double maxHeightOffset; + public double invDepth; + public double invRise; public double slopeScale; + public bool isAdaptive; + public BurstMapSO heightMap; + public double mapDeformity; + public double du; + public double dv; + public double invTwoDu; + public double invTwoDv; + public double metresPerU; + public double metresPerV; + public double rampWidth; + public void BuildHeights(in BuildHeightsData data) { - var minHeight = data.sphere.radius + minHeightOffset; - var maxHeight = data.sphere.radius + maxHeightOffset; + double radius = data.sphere.radius; + double minHeight = radius + minHeightOffset; + double maxHeight = radius + maxHeightOffset; + + // Each of these properties returns the span by value, so hoist them out of the loop. + var vertHeight = data.vertHeight; + var uCoord = data.u; + var vCoord = data.v; + var direction = data.directionFromCenter; for (int i = 0; i < data.VertexCount; ++i) { - if (data.vertHeight[i] > minHeight && data.vertHeight[i] < maxHeight) + double height = vertHeight[i]; + if (height <= minHeight || height >= maxHeight) { - // 7th order polynomial smoothstep. - double x = (data.vertHeight[i] - minHeight) / (maxHeight - minHeight); - x = MathUtil.Clamp01((x - 0.5) * slopeScale + 0.5); - double y = -20.0 * Math.Pow(x, 7.0) + 70 * Math.Pow(x, 6.0) - 84.0 * Math.Pow(x, 5.0) + - 35.0 * Math.Pow(x, 4.0); - data.vertHeight[i] = y * (maxHeight - minHeight) + minHeight; + continue; } + + height -= radius; + + // Signed position within the band: sea level at 0, the two band edges at -1 and 1. + double t; + if (isAdaptive) + { + // Raw height the terrain gains over rampWidth metres of ground. Capping it at + // the band keeps the ramp finishing exactly on the plateau, with no step. + double window = Gradient(uCoord[i], vCoord[i], direction[i]) * rampWidth; + window = Math.Min(window, height < 0.0 ? -minHeightOffset : maxHeightOffset); + t = window > 0.0 ? height / window : (height < 0.0 ? -1.0 : (height > 0.0 ? 1.0 : 0.0)); + } + else + { + t = (height < 0.0 ? height * invDepth : height * invRise) * slopeScale; + } + t = Math.Min(Math.Max(-1.0, t), 1.0); + + // Odd extension of the 7th order smoothstep onto [-1, 1], i.e. 2 * S((t + 1) / 2) - 1. + // Sea level is an exact fixed point of this, so the waterline stays on the height + // map's own contour instead of drifting. + double x = (t + 1.0) * 0.5; + double x2 = x * x; + double s = 2.0 * (x2 * x2 * (35.0 - 84.0 * x + 70.0 * x2 - 20.0 * x2 * x)) - 1.0; + + vertHeight[i] = radius + (s < 0.0 ? -s * minHeightOffset : s * maxHeightOffset); + } + } + + /// + /// Magnitude of the height map's slope at this vertex, in metres of rise per metre + /// travelled. Must stay in step with PQSMod_VertexDefineCoastSmooth.LocalGradient. + /// + private double Gradient(double u, double v, Vector3d dir) + { + // GetPixelFloat wraps both axes, which is right for longitude but would jump across the + // pole in v, so keep the stencil inside the map vertically. + v = Math.Min(Math.Max(v, dv), 1.0 - dv); + + double dHdu = mapDeformity * invTwoDu * + (heightMap.GetPixelFloat(u + du, v) - heightMap.GetPixelFloat(u - du, v)); + double dHdv = mapDeformity * invTwoDv * + (heightMap.GetPixelFloat(u, v + dv) - heightMap.GetPixelFloat(u, v - dv)); + + // directionFromCenter is a unit radial, so the length of its horizontal part is exactly + // cos(latitude), which is cheaper than Math.Cos and avoids needing the latitude span. + double cosLat = Math.Sqrt(dir.x * dir.x + dir.z * dir.z); + if (cosLat < 1E-3) + { + cosLat = 1E-3; + } + + double gu = dHdu / (metresPerU * cosLat); + double gv = dHdv / metresPerV; + return Math.Sqrt(gu * gu + gv * gv); + } + + public void Dispose() + { + if (isAdaptive) + { + heightMap.Dispose(); } } } -} \ No newline at end of file +} From c0613c9a22f599681d632919a154d0305f736218 Mon Sep 17 00:00:00 2001 From: siimav <1120038+siimav@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:58:25 +0300 Subject: [PATCH 2/2] Cleanup --- .../PQSMod_VertexDefineCoastSmooth.cs | 110 +++++++++--------- .../BatchPQSMod_VertexDefineCoastSmooth.cs | 37 +++--- 2 files changed, 70 insertions(+), 77 deletions(-) diff --git a/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs b/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs index 292002c0..81aaac45 100644 --- a/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs +++ b/Source/KopernicusMods/PQSMod_VertexDefineCoastSmooth.cs @@ -16,20 +16,18 @@ public class PQSMod_VertexDefineCoastSmooth : PQSMod public double minHeightOffset; public double maxHeightOffset; - // Legacy fixed slopeScale behaviour. Has no effect in adaptive mode. - // This is unavoidably either too soft on flat coasts or steep enough to quantise every vertex onto the two - // plateaus - and once that happens the waterline snaps to the vertex grid's edge midpoints - // and turns into a staircase. + // Legacy fixed-width ramp, ignored in adaptive mode. One width is either too soft on flat + // coasts or so steep that every vertex lands on a plateau, which puts the waterline on the + // vertex grid and turns it into a staircase. public double slopeScale; - // Target half-width of the land/water transition, in vertex spacings of the quad being - // built. When positive the mod runs in adaptive mode: the ramp is sized from the local - // height map gradient so that the coast always crosses sea level over roughly this - // distance, whatever the terrain does. Zero (the default) keeps the fixed slopeScale behaviour. - // - // Spacings rather than metres because a quad's vertex spacing doubles for every subdivision - // level below the maximum, and the maximum itself moves with the terrain detail preset. A - // width fixed in metres is only correct in a shell around the camera. + // Half-width of the land/water transition, in vertex spacings of the quad being built. + // Positive enables adaptive mode, where the ramp is sized from the local height map + // gradient so the coast crosses sea level over this distance whatever the terrain does. + // Zero keeps the fixed slopeScale ramp. + // Spacings and not metres: spacing doubles with every subdivision level below the maximum, + // and the maximum moves with the terrain detail preset, so a width in metres would only be + // right in a shell around the camera. public double coastSpacings; // Half-width of the central difference used to measure that gradient, in height map texels. @@ -39,7 +37,7 @@ public class PQSMod_VertexDefineCoastSmooth : PQSMod private double maxHeight; private double invDepth; private double invRise; - private bool bandCrossesSeaLevel; + private bool isActive; private bool isAdaptive; private MapSO heightMap; @@ -53,10 +51,11 @@ public class PQSMod_VertexDefineCoastSmooth : PQSMod private double[] levelSpacing; private double maxLevelSpacing; - // Resolved adaptive setup. Exposed so the Burst path can mirror this mod exactly instead of - // repeating the height map search and re-deriving the constants - the two implementations - // have to agree vertex for vertex or terrain changes depending on whether BurstPQS is - // installed. Only meaningful while IsAdaptive is true. + // False when the mod has nothing valid to do, so the BurstPQS mod can skip it as well. + public bool IsActive => isActive; + + // Resolved adaptive setup, read by the BurstPQS mod so both paths use the same map and the + // same constants. Only meaningful while IsAdaptive is true. public bool IsAdaptive => isAdaptive; public MapSO AdaptiveHeightMap => heightMap; public double AdaptiveMapDeformity => mapDeformity; @@ -66,9 +65,7 @@ public class PQSMod_VertexDefineCoastSmooth : PQSMod public double AdaptiveMetresPerV => metresPerV; /// - /// Half-width of the ramp in metres of ground for a quad at the given subdivision level. - /// The Burst path resolves this once per quad rather than per vertex, since it is handed - /// the quad up front. + /// Ramp half-width in metres of ground for a quad at the given subdivision level. /// public double AdaptiveRampWidth(int subdivision) { @@ -98,12 +95,9 @@ public override void OnSetup() minHeight = sphere.radius + minHeightOffset; maxHeight = sphere.radius + maxHeightOffset; isAdaptive = false; + isActive = false; - // The two halves of the band are scaled independently, so the offsets need not be - // symmetric. Dropping the seabed further than the land rises steepens the waterline - // without turning coastal lowland into a mesa - the deep side is hidden under the ocean. - bandCrossesSeaLevel = minHeightOffset < 0.0 && maxHeightOffset > 0.0; - if (!bandCrossesSeaLevel) + if (minHeightOffset >= 0.0 || maxHeightOffset <= 0.0) { Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name} is inactive: band [{minHeightOffset}, {maxHeightOffset}] does not cross sea level"); return; @@ -122,9 +116,18 @@ public override void OnSetup() } } - // The rendered waterline sits where the mesh crosses sea level, and with different - // amplitudes either side the crossing is pulled towards the shallower one - a constant - // bias of tens of metres that no ramp width fixes. Keep the band symmetric. + // Reset() only runs in the editor, so an unset slopeScale is 0 and not the default + // above. A zero ramp maps the whole band onto sea level, which is worse than nothing. + if (!isAdaptive && slopeScale <= 0.0) + { + Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name} is inactive: adaptive mode is off and slopeScale is {slopeScale}"); + return; + } + + isActive = true; + + // With different amplitudes either side, the mesh crosses sea level nearer the + // shallower one, biasing the waterline off the height map contour by tens of metres. if (isAdaptive && Math.Abs(maxHeightOffset + minHeightOffset) > 1E-6) { Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name}:" @@ -135,7 +138,7 @@ public override void OnSetup() public override void OnVertexBuildHeight(PQS.VertexBuildData data) { - if (!bandCrossesSeaLevel) + if (!isActive) { return; } @@ -151,10 +154,9 @@ public override void OnVertexBuildHeight(PQS.VertexBuildData data) double t; if (isAdaptive) { - // Grade the coast over a fixed number of vertex spacings of whatever quad is being - // built, so distant low-detail quads get a proportionally wider ramp instead of - // collapsing onto the plateaus. buildQuad is null on the GetSurfaceHeight path, - // which has no mesh to grade, so answer those at the finest level. + // Grading over a fixed number of vertex spacings gives low-detail quads further out + // a proportionally wider ramp, instead of collapsing them onto the plateaus. + // buildQuad is null on the GetSurfaceHeight path, which has no mesh to grade. double spacing = maxLevelSpacing; if (data.buildQuad != null) { @@ -165,27 +167,23 @@ public override void OnVertexBuildHeight(PQS.VertexBuildData data) } } - // Raw height the terrain gains over that distance of ground. Capping it at the band - // keeps the ramp finishing exactly on the plateau, with no step at the edge. - // Grouped so this is the same product the Burst path folds into its per-quad ramp - // width; multiplication is not associative in floating point and the two - // implementations have to agree to the last bit. + // Height the terrain gains over that distance. Capping at the band makes the ramp + // finish exactly on the plateau. The product is grouped to match the Burst path, + // which folds it into a per-quad width; float multiply is not associative. double window = GetLocalGradient(data) * (coastSpacings * spacing); window = Math.Min(window, height < 0.0 ? -minHeightOffset : maxHeightOffset); - // Not Math.Sign, which throws on NaN - and a NaN height slips through the band test - // above, since every comparison against NaN is false. + // Not Math.Sign, which throws on NaN, and a NaN height passes the band test above. t = window > 0.0 ? height / window : (height < 0.0 ? -1.0 : (height > 0.0 ? 1.0 : 0.0)); } else { - // slopeScale below 1 leaves a step at the band edges, same as it always has. + // slopeScale below 1 leaves a step at the band edges. t = (height < 0.0 ? height * invDepth : height * invRise) * slopeScale; } t = Math.Min(Math.Max(-1.0, t), 1.0); // Odd extension of the 7th order smoothstep onto [-1, 1], i.e. 2 * S((t + 1) / 2) - 1. - // Sea level is an exact fixed point of this, so the waterline stays on the height - // map's own contour instead of drifting. + // Sea level is an exact fixed point, so the waterline stays on the height map contour. double x = (t + 1.0) * 0.5; double x2 = x * x; double s = 2.0 * (x2 * x2 * (35.0 - 84.0 * x + 70.0 * x2 - 20.0 * x2 * x)) - 1.0; @@ -208,8 +206,8 @@ public override double GetVertexMinHeight() /// private double GetLocalGradient(PQS.VertexBuildData data) { - // GetPixelFloat wraps both axes, which is right for longitude but would jump across the - // pole in v, so keep the stencil inside the map vertically. + // GetPixelFloat wraps both axes. Right for longitude, but in v it would jump the pole, + // so keep the stencil inside the map. double v = Math.Min(Math.Max(data.v, dv), 1.0 - dv); double dHdu = mapDeformity * invTwoDu * @@ -217,8 +215,8 @@ private double GetLocalGradient(PQS.VertexBuildData data) double dHdv = mapDeformity * invTwoDv * (heightMap.GetPixelFloat(data.u, v + dv) - heightMap.GetPixelFloat(data.u, v - dv)); - // u spans the full circumference, v spans pole to pole; meridians converge with latitude. - // directionFromCenter is a unit radial, so the length of its horizontal part is exactly cos(latitude) + // u spans the circumference, v pole to pole, and meridians converge with latitude. + // directionFromCenter is a unit radial, so its horizontal length is exactly cos(lat). Vector3d dir = data.directionFromCenter; double cosLat = Math.Sqrt(dir.x * dir.x + dir.z * dir.z); if (cosLat < 1E-3) @@ -232,9 +230,8 @@ private double GetLocalGradient(PQS.VertexBuildData data) } /// - /// Vertex spacing for every subdivision level. A quad at level L spans (pi * R / 2) / 2^L - /// and carries cacheSideVertCount vertices per side, so spacing doubles for each level - /// below the maximum - which is exactly how far a coast has to be graded to stay smooth. + /// Vertex spacing per subdivision level. A quad at level L spans (pi * R / 2) / 2^L across + /// cacheSideVertCount vertices, so spacing doubles for each level below the maximum. /// private void BuildSpacingTable() { @@ -250,10 +247,9 @@ private void BuildSpacingTable() } /// - /// Locates the VertexHeightMap this body's coastline comes from, so the ramp can be sized - /// from the map's own gradient. Sampling the map rather than differencing the mesh keeps the - /// measured gradient independent of subdivision level, so the waterline itself stays put as - /// quads split - only the steepness either side of it changes. + /// Finds the VertexHeightMap the coastline comes from. Sampling the map instead of + /// differencing the mesh keeps the gradient independent of subdivision level, so the + /// waterline stays put as quads split and only the steepness either side changes. /// private bool BindHeightMap() { @@ -273,13 +269,13 @@ private bool BindHeightMap() if (best == null) { - Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name}: coastWidth is set but no VertexHeightMap was found, falling back to fixed slopeScale"); + Debug.LogWarning($"[RealSolarSystem] VertexDefineCoastSmooth on {sphere.name}: coastSpacings is set but no VertexHeightMap was found, falling back to fixed slopeScale"); return false; } heightMap = best.heightMap; - // The stock mod adds heightMapOffset + heightMapDeformity * pixel; the offset is constant - // and drops out of a difference, so only the deformity scales the gradient. + // The stock mod adds offset + deformity * pixel. The offset is constant and cancels in + // a difference, so only the deformity scales the gradient. mapDeformity = best.heightMapDeformity; double stencil = gradientStencil > 0.0 ? gradientStencil : 1.0; diff --git a/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs b/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs index 8ba32efc..78760ae2 100644 --- a/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs +++ b/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs @@ -19,10 +19,9 @@ public override void OnSetup() { base.OnSetup(); - // The stock mod has already resolved which height map the coastline comes from; all this - // has to decide is whether BurstPQS can read it. If it cannot, fall back to the fixed - // slopeScale ramp and say so, because silently dropping adaptive mode here would make the - // terrain depend on whether BurstPQS happens to be installed. + // The stock mod has already picked the height map, so this only decides whether BurstPQS + // can read it. Falling back quietly would make terrain depend on whether BurstPQS is + // installed, so say so in the log. _mapSupported = Mod.IsAdaptive && Mod.AdaptiveHeightMap != null && BurstMapSO.IsSupported(Mod.AdaptiveHeightMap); @@ -39,14 +38,15 @@ public override void OnQuadPreBuild(PQ quad, BatchPQSJobSet jobSet) { base.OnQuadPreBuild(quad, jobSet); - double minHeightOffset = Mod.minHeightOffset; - double maxHeightOffset = Mod.maxHeightOffset; - if (minHeightOffset >= 0.0 || maxHeightOffset <= 0.0) + // The stock mod has already validated the offsets and logged if they are unusable. + if (!Mod.IsActive) { - // Band does not straddle sea level; the stock mod logs this and goes inert. return; } + double minHeightOffset = Mod.minHeightOffset; + double maxHeightOffset = Mod.maxHeightOffset; + BuildJob job = new BuildJob { minHeightOffset = minHeightOffset, @@ -71,8 +71,7 @@ public override void OnQuadPreBuild(PQ quad, BatchPQSJobSet jobSet) job.metresPerU = Mod.AdaptiveMetresPerU; job.metresPerV = Mod.AdaptiveMetresPerV; - // The subdivision level is known here, so the ramp width is resolved once per quad - // instead of once per vertex the way the stock path has to do it. + // The level is known here, so the ramp width is per quad rather than per vertex. job.rampWidth = Mod.AdaptiveRampWidth(quad.subdivision); } @@ -125,8 +124,8 @@ public void BuildHeights(in BuildHeightsData data) double t; if (isAdaptive) { - // Raw height the terrain gains over rampWidth metres of ground. Capping it at - // the band keeps the ramp finishing exactly on the plateau, with no step. + // Height the terrain gains over rampWidth of ground. Capping at the band makes + // the ramp finish exactly on the plateau. double window = Gradient(uCoord[i], vCoord[i], direction[i]) * rampWidth; window = Math.Min(window, height < 0.0 ? -minHeightOffset : maxHeightOffset); t = window > 0.0 ? height / window : (height < 0.0 ? -1.0 : (height > 0.0 ? 1.0 : 0.0)); @@ -138,8 +137,7 @@ public void BuildHeights(in BuildHeightsData data) t = Math.Min(Math.Max(-1.0, t), 1.0); // Odd extension of the 7th order smoothstep onto [-1, 1], i.e. 2 * S((t + 1) / 2) - 1. - // Sea level is an exact fixed point of this, so the waterline stays on the height - // map's own contour instead of drifting. + // Sea level is an exact fixed point, so the waterline stays on the height map contour. double x = (t + 1.0) * 0.5; double x2 = x * x; double s = 2.0 * (x2 * x2 * (35.0 - 84.0 * x + 70.0 * x2 - 20.0 * x2 * x)) - 1.0; @@ -149,13 +147,13 @@ public void BuildHeights(in BuildHeightsData data) } /// - /// Magnitude of the height map's slope at this vertex, in metres of rise per metre - /// travelled. Must stay in step with PQSMod_VertexDefineCoastSmooth.LocalGradient. + /// Height map slope at this vertex, in metres of rise per metre travelled. Must stay in + /// step with PQSMod_VertexDefineCoastSmooth.GetLocalGradient. /// private double Gradient(double u, double v, Vector3d dir) { - // GetPixelFloat wraps both axes, which is right for longitude but would jump across the - // pole in v, so keep the stencil inside the map vertically. + // GetPixelFloat wraps both axes. Right for longitude, but in v it would jump the pole, + // so keep the stencil inside the map. v = Math.Min(Math.Max(v, dv), 1.0 - dv); double dHdu = mapDeformity * invTwoDu * @@ -163,8 +161,7 @@ private double Gradient(double u, double v, Vector3d dir) double dHdv = mapDeformity * invTwoDv * (heightMap.GetPixelFloat(u, v + dv) - heightMap.GetPixelFloat(u, v - dv)); - // directionFromCenter is a unit radial, so the length of its horizontal part is exactly - // cos(latitude), which is cheaper than Math.Cos and avoids needing the latitude span. + // directionFromCenter is a unit radial, so its horizontal length is exactly cos(lat). double cosLat = Math.Sqrt(dir.x * dir.x + dir.z * dir.z); if (cosLat < 1E-3) {