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] 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 +}