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..81aaac45 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,78 @@ public class PQSMod_VertexDefineCoastSmooth : PQSMod
{
public double minHeightOffset;
public double maxHeightOffset;
+
+ // 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;
+ // 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.
+ public double gradientStencil;
+
private double minHeight;
private double maxHeight;
+ private double invDepth;
+ private double invRise;
+ private bool isActive;
+
+ 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;
+
+ // 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;
+ public double AdaptiveStencilU => du;
+ public double AdaptiveStencilV => dv;
+ public double AdaptiveMetresPerU => metresPerU;
+ public double AdaptiveMetresPerV => metresPerV;
+
+ ///
+ /// Ramp half-width in metres of ground for a quad at the given subdivision level.
+ ///
+ 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 +94,101 @@ public override void OnSetup()
requirements = PQS.ModiferRequirements.MeshCustomNormals;
minHeight = sphere.radius + minHeightOffset;
maxHeight = sphere.radius + maxHeightOffset;
+ isAdaptive = false;
+ isActive = false;
+
+ 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;
+ }
+
+ invDepth = -1.0 / minHeightOffset;
+ invRise = 1.0 / maxHeightOffset;
+
+ if (coastSpacings > 0.0)
+ {
+ isAdaptive = BindHeightMap();
+ if (isAdaptive)
+ {
+ requirements |= PQS.ModiferRequirements.VertexMapCoords;
+ BuildSpacingTable();
+ }
+ }
+
+ // 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}:"
+ + $" 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 (!isActive)
+ {
+ return;
+ }
+
+ if (data.vertHeight <= minHeight || data.vertHeight >= maxHeight)
+ {
+ 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)
+ {
+ // 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)
+ {
+ int level = data.buildQuad.subdivision;
+ if (level >= 0 && level < levelSpacing.Length)
+ {
+ spacing = levelSpacing[level];
+ }
+ }
+
+ // 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 passes the band test above.
+ t = window > 0.0 ? height / window : (height < 0.0 ? -1.0 : (height > 0.0 ? 1.0 : 0.0));
+ }
+ else
{
- // 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;
+ // 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, 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;
+
+ data.vertHeight = sphere.radius + (s < 0.0 ? -s * minHeightOffset : s * maxHeightOffset);
}
public override double GetVertexMaxHeight()
@@ -54,5 +200,92 @@ 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. 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 *
+ (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 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)
+ {
+ cosLat = 1E-3;
+ }
+
+ double gu = dHdu / (metresPerU * cosLat);
+ double gv = dHdv / metresPerV;
+ return Math.Sqrt(gu * gu + gv * gv);
+ }
+
+ ///
+ /// 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()
+ {
+ 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];
+ }
+
+ ///
+ /// 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()
+ {
+ 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}: coastSpacings is set but no VertexHeightMap was found, falling back to fixed slopeScale");
+ return false;
+ }
+
+ heightMap = best.heightMap;
+ // 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;
+ 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..78760ae2 100644
--- a/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs
+++ b/Source/RealSolarSystem.BurstPQS/BatchPQSMod_VertexDefineCoastSmooth.cs
@@ -1,52 +1,184 @@
-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 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);
+
+ 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
+
+ // The stock mod has already validated the offsets and logged if they are unusable.
+ if (!Mod.IsActive)
{
- minHeightOffset = Mod.minHeightOffset,
- maxHeightOffset = Mod.maxHeightOffset,
+ return;
+ }
+
+ double minHeightOffset = Mod.minHeightOffset;
+ double maxHeightOffset = Mod.maxHeightOffset;
+
+ 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 level is known here, so the ramp width is per quad rather than per vertex.
+ 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)
+ {
+ // 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));
+ }
+ 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, 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;
+
+ vertHeight[i] = radius + (s < 0.0 ? -s * minHeightOffset : s * maxHeightOffset);
+ }
+ }
+
+ ///
+ /// 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. 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 *
+ (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 its horizontal length is exactly cos(lat).
+ 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
+}