diff --git a/Runtime/Procedural/HeightMaps/HeightMapChannel.cs b/Runtime/Procedural/HeightMaps/HeightMapChannel.cs
new file mode 100644
index 0000000..6b89476
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/HeightMapChannel.cs
@@ -0,0 +1,45 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** How an image layer reads a height out of a texture.
+
+ The single-channel entries take the height from that channel and cover the
+ whole rect at full weight. The paired entries take the height from the
+ colour channel and the coverage from alpha, so a cut-out
+ image only affects the map where it is opaque - which is what lets a
+ stamped shape with transparent corners blend in instead of writing a
+ square.
+
+ Coverage is not the same thing as height: under Add and Subtract it scales
+ the contribution, under Replace it is the crossfade back to what is
+ underneath. That is the same split the circle's falloff already has.
+
+ The int values are passed to HeightMapShape.gdshader as
+ shape_image_channel - do not renumber without updating the shader.
+ */
+ public enum HeightMapChannel
+ {
+ R = 0,
+ G = 1,
+ B = 2,
+ A = 3,
+
+ RA = 4,
+ GA = 5,
+ BA = 6
+ }
+
+ public static class HeightMapChannelX
+ {
+ /** True for the paired entries, where alpha is read as coverage. */
+ public static bool UsesAlphaCoverage( this HeightMapChannel channel )
+ {
+ return channel == HeightMapChannel.RA
+ || channel == HeightMapChannel.GA
+ || channel == HeightMapChannel.BA;
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/HeightMapChannel.cs.uid b/Runtime/Procedural/HeightMaps/HeightMapChannel.cs.uid
new file mode 100644
index 0000000..5e3ac00
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/HeightMapChannel.cs.uid
@@ -0,0 +1 @@
+uid://d3nx7qd3h2eim
diff --git a/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
index b9e6a9d..4884b43 100644
--- a/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
+++ b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
@@ -124,7 +124,59 @@ namespace Rokojori
[Export]
public DrawableTexture2D heightMapTarget;
- /** Materials to push terrainHeightMap / terrainCenterXZ / terrainSizeXZ /
+ /** World-space normals baked out of the finished height map, encoded as
+ n * 0.5 + 0.5 - see HeightMapNormals.gdshader.
+
+ Left empty, no normal pass runs at all and a terrain material has to
+ take its own derivative per fragment. Assigned, the map is rendered once
+ after every layer has drawn and pushed to `materials` as
+ terrainNormalMap, which is both cheaper per fragment and the only way the
+ smoothing below can exist - a consumer taking its own 1-texel central
+ difference has no way to be smoothed.
+ */
+ [Export]
+ public DrawableTexture2D normalMapTarget;
+
+ float _normalRadius = 1;
+
+ /** How far apart the normal pass's taps are, in texels. Widening the stencil
+ low-passes the slope, which is what removes texel stair-stepping without
+ touching the height map itself.
+ */
+ [Export]
+ public float normalRadius
+ {
+ get => _normalRadius;
+ set { _normalRadius = value; SetDirty(); }
+ }
+
+ int _normalSamples = 1;
+
+ /** How many concentric stencils, at 1x / 2x / ... normalRadius, get
+ averaged. One wide stencil misses the detail between its taps; averaging
+ several keeps the fine slope and still drops the noise. Costs 8 taps
+ each, over the whole map.
+ */
+ [Export( PropertyHint.Range, "1,8" )]
+ public int normalSamples
+ {
+ get => _normalSamples;
+ set { _normalSamples = value; SetDirty(); }
+ }
+
+ float _normalStrength = 1;
+
+ /** Scales the slope before it is normalized - below 1 flattens the shading,
+ above 1 exaggerates it. Shading only; the geometry is unaffected.
+ */
+ [Export]
+ public float normalStrength
+ {
+ get => _normalStrength;
+ set { _normalStrength = value; SetDirty(); }
+ }
+
+ /** Materials to push terrainHeightMap / terrainNormalMap / terrainCenterXZ / terrainSizeXZ /
terrainMinHeight / terrainMaxHeight onto after every render.
This is the whole wiring between the generator and anything rendering the
@@ -255,6 +307,33 @@ namespace Rokojori
return WorldBoxToRect( c - r, c + r );
}
+ /** The texel rect a rotated XZ quad covers - the axis-aligned bounds of its
+ four corners. A rotated layer therefore touches more texels than it
+ fills; the shader still rejects everything outside the quad itself.
+ */
+ public Rect2I WorldQuadToRect( Vector2 center, Vector2 axisX, Vector2 axisZ, Vector2 size )
+ {
+ var x = axisX * ( size.X / 2 );
+ var z = axisZ * ( size.Y / 2 );
+
+ var a = center + x + z;
+ var b = center + x - z;
+ var c = center - x + z;
+ var d = center - x - z;
+
+ var min = new Vector2(
+ Mathf.Min( Mathf.Min( a.X, b.X ), Mathf.Min( c.X, d.X ) ),
+ Mathf.Min( Mathf.Min( a.Y, b.Y ), Mathf.Min( c.Y, d.Y ) )
+ );
+
+ var max = new Vector2(
+ Mathf.Max( Mathf.Max( a.X, b.X ), Mathf.Max( c.X, d.X ) ),
+ Mathf.Max( Mathf.Max( a.Y, b.Y ), Mathf.Max( c.Y, d.Y ) )
+ );
+
+ return WorldBoxToRect( min, max );
+ }
+
/* HEIGHT NORMALIZATION
------------------------------------------------------------------------
@@ -372,6 +451,8 @@ namespace Rokojori
renderedLayers++;
}
+ RenderNormalMap();
+
PushToMaterials();
}
finally
@@ -410,8 +491,25 @@ namespace Rokojori
Called by layers after they have set up `shapeMaterial`; the rect and
blend-mode uniforms are set here, so a layer cannot get the world mapping
wrong.
+
+ `source` and `mask` fill the shader's hint_blit_source0 and
+ hint_blit_source1 - an image layer passes its texture and optionally a
+ mask, a purely procedural shape passes neither. Both slots are always
+ bound, to the shared white 1x1 stand-in when a layer has nothing for
+ them: an unbound blit source arrives black, so "no mask" would otherwise
+ have to be a flag, while white is already a no-op multiplier.
*/
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode )
+ {
+ DrawShape( rect, blendMode, null, null );
+ }
+
+ public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source )
+ {
+ DrawShape( rect, blendMode, source, null );
+ }
+
+ public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source, Texture2D mask )
{
if ( heightMapTarget == null || rect.Size.X <= 0 || rect.Size.Y <= 0 )
{
@@ -429,10 +527,14 @@ namespace Rokojori
try
{
- // A blit needs a source even when the shader samples none, so a shared
- // 1x1 stands in. The shape is entirely procedural from world XZ.
- scratch.BlitRect(
- new Rect2I( 0, 0, rect.Size.X, rect.Size.Y ), dummySource, White, 0, material
+ _blitSources[ 0 ] = source != null ? source : (Texture2D) dummySource;
+ _blitSources[ 1 ] = mask != null ? mask : (Texture2D) dummySource;
+
+ // BlitRectMulti rather than BlitRect, because the explicit sources array
+ // is the documented way to fill more than source 0.
+ scratch.BlitRectMulti(
+ new Rect2I( 0, 0, rect.Size.X, rect.Size.Y ),
+ _blitSources, TextureStackCompositor.noExtraTargets, White, 0, material
);
if ( _blendBlit == null )
@@ -450,6 +552,10 @@ namespace Rokojori
}
}
+ /** Reused rather than allocated per blit - a drag re-renders every frame. */
+ readonly Godot.Collections.Array _blitSources
+ = new Godot.Collections.Array(){ null, null };
+
DrawableTexture2D _dummySource;
DrawableTexture2D dummySource
@@ -530,7 +636,75 @@ namespace Rokojori
}
- /** Pushes the map and its world mapping onto every assigned material. */
+ /* THE NORMAL PASS
+ ------------------------------------------------------------------------
+ One full-map blit with the finished height map as the source. It runs
+ after the layers rather than per layer, because a normal is a derivative
+ of the composited result - taking it per layer and blending the results
+ would be wrong wherever two layers overlap.
+
+ Nothing clears the normal map: the shader writes every texel with blending
+ disabled, so the previous render is fully overwritten.
+ */
+
+ HeightMapNormalsMaterial _normalsMaterial;
+
+ HeightMapNormalsMaterial normalsMaterial
+ {
+ get
+ {
+ if ( _normalsMaterial == null )
+ {
+ _normalsMaterial = new HeightMapNormalsMaterial();
+ }
+
+ return _normalsMaterial;
+ }
+ }
+
+ DrawableTexture2D _setupNormalTarget;
+ Vector2I _setupNormalSize;
+ TextureStackFormat _setupNormalFormat;
+
+ /** Flat up, encoded - what an unrendered normal map should read as. */
+ static readonly Color FlatNormal = new Color( 0.5f, 1, 0.5f, 1 );
+
+ void RenderNormalMap()
+ {
+ if ( normalMapTarget == null || heightMapTarget == null )
+ {
+ return;
+ }
+
+ var wanted = textureSize;
+
+ // Same allocation rule as the height map - Setup() reallocates and that
+ // emits the texture's changed signal, which rebuilds the inspector.
+ if ( _setupNormalTarget != normalMapTarget
+ || _setupNormalSize != wanted
+ || _setupNormalFormat != format )
+ {
+ normalMapTarget.Setup( wanted.X, wanted.Y, format.ToDrawableFormat(), FlatNormal, false );
+
+ _setupNormalTarget = normalMapTarget;
+ _setupNormalSize = wanted;
+ _setupNormalFormat = format;
+ }
+
+ var material = normalsMaterial;
+
+ material.sizeXZ.Set( size );
+ material.minHeight.Set( minHeight );
+ material.maxHeight.Set( maxHeight );
+ material.radius.Set( normalRadius );
+ material.samples.Set( normalSamples );
+ material.strength.Set( normalStrength );
+
+ normalMapTarget.BlitRect( fullRect, heightMapTarget, White, 0, material );
+ }
+
+
+ /** Pushes the maps and their world mapping onto every assigned material. */
public void PushToMaterials()
{
if ( materials == null )
@@ -548,6 +722,7 @@ namespace Rokojori
}
TerrainOffsetShader.heightMap.Set( material, heightMapTarget );
+ TerrainOffsetShader.normalMap.Set( material, normalMapTarget );
TerrainOffsetShader.centerXZ.Set( material, centerXZ );
TerrainOffsetShader.sizeXZ.Set( material, size );
TerrainOffsetShader.minHeight.Set( material, minHeight );
@@ -556,15 +731,16 @@ namespace Rokojori
}
- /** Rough VRAM cost of the map itself, in bytes. The scratch buffer is
- rect-sized and pooled, so it is not counted - a layer covering the whole
- map would add one more of these.
+ /** Rough VRAM cost of the maps, in bytes. The scratch buffer is rect-sized
+ and pooled, so it is not counted - a layer covering the whole map would
+ add one more of these.
*/
public long EstimateMemory()
{
var r = textureSize;
+ var maps = normalMapTarget != null ? 2 : 1;
- return (long) r.X * r.Y * format.BytesPerPixel();
+ return (long) r.X * r.Y * format.BytesPerPixel() * maps;
}
}
}
diff --git a/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs
index 0a4b561..8ef6666 100644
--- a/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs
+++ b/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs
@@ -7,40 +7,31 @@ namespace Rokojori
{
/** A circular hill, crater or plateau, centered on the node's own position.
- Full `height` inside innerRadius, fading to nothing at outerRadius. The
- first primitive, and the template for the ones that follow: it computes the
- texel rect it touches, configures the shared shape material and hands the
- blit back to the generator.
+ Full height inside innerRadius, fading to nothing at outerRadius. The first
+ primitive, and the template for the ones that follow: it computes the texel
+ rect it touches, configures the shared shape material and hands the blit
+ back to the generator.
+
+ Under Add and Subtract the height comes from `height` and is dragged with
+ the vertical gizmo handle; under Replace it is the node's own Y position,
+ and the circle writes that one flat level across the inner radius. A circle
+ has no per-texel amount of its own, so its Replace amplitude is always 0.
Only XZ position is read, not rotation or scale - a circle has no
orientation, and scaling a radius through the node transform would make the
- inspector's radius values lie. Later non-radial primitives will read the
- basis.
+ inspector's radius values lie.
*/
[Tool]
[GlobalClass]
public partial class CircleHeightMapGeneratorLayer:HeightMapGeneratorLayer
{
- float _height = 10;
-
- /** In meters. A height to add or subtract for Add/Subtract, the absolute
- world height to flatten to for Replace.
- */
- [Export]
- public float height
- {
- get => _height;
- set { _height = value; SetDirty(); }
- }
-
float _innerRadius = 10;
/** Radius in meters that gets the full height. */
[Export]
public float innerRadius
{
- get => _innerRadius;
- set { _innerRadius = value; SetDirty(); }
+ get => _innerRadius; set { _innerRadius = value; SetDirty(); }
}
float _outerRadius = 20;
@@ -49,8 +40,7 @@ namespace Rokojori
[Export]
public float outerRadius
{
- get => _outerRadius;
- set { _outerRadius = value; SetDirty(); }
+ get => _outerRadius; set { _outerRadius = value; SetDirty(); }
}
bool _smoothFalloff = true;
@@ -61,8 +51,7 @@ namespace Rokojori
[Export]
public bool smoothFalloff
{
- get => _smoothFalloff;
- set { _smoothFalloff = value; SetDirty(); }
+ get => _smoothFalloff; set { _smoothFalloff = value; SetDirty(); }
}
@@ -96,9 +85,86 @@ namespace Rokojori
material.innerRadius.Set( inner );
material.outerRadius.Set( outer );
material.smoothFalloff.Set( smoothFalloff );
- material.value.Set( NormalizeHeight( generator, height ) );
+
+ // Flat under Replace: the whole output is the node's Y, with the falloff
+ // as the crossfade. Under Add/Subtract the amplitude is the height.
+ SetShapeValue( generator, blendMode.IsAbsolute() ? 0 : height );
generator.DrawShape( rect, blendMode );
}
+
+
+#if TOOLS
+
+ /* GIZMOS
+ ------------------------------------------------------------------------
+ The two radius handles sit on different axes - inner on +X, outer on +Z -
+ so they never land on top of each other when the two radii are equal,
+ which is the one configuration a shared axis would make uneditable.
+ */
+
+ public const int HandleIdInnerRadius = HandleIdCustom;
+ public const int HandleIdOuterRadius = HandleIdCustom + 1;
+
+ protected override void DrawShapeGizmo( List lines, List handles, List ids )
+ {
+ var x = ToGizmoLocal( Vector3.Right );
+ var z = ToGizmoLocal( Vector3.Back );
+
+ Gizmos.AddCircle( lines, Vector3.Zero, x * innerRadius, z * innerRadius, 48 );
+ Gizmos.AddCircle( lines, Vector3.Zero, x * outerRadius, z * outerRadius, 48 );
+
+ handles.Add( x * innerRadius );
+ ids.Add( HandleIdInnerRadius );
+
+ handles.Add( z * outerRadius );
+ ids.Add( HandleIdOuterRadius );
+ }
+
+ protected override string GetShapeHandleName( int id )
+ {
+ return id == HandleIdInnerRadius ? "Inner Radius" : "Outer Radius";
+ }
+
+ protected override Variant GetShapeHandleValue( int id )
+ {
+ return Variant.From( id == HandleIdInnerRadius ? innerRadius : outerRadius );
+ }
+
+ protected override void SetShapeHandle( int id, Camera3D camera, Vector2 point )
+ {
+ Vector3 worldPoint;
+
+ if ( ! ProjectOntoGroundPlane( camera, point, dragStartPosition, out worldPoint ) )
+ {
+ return;
+ }
+
+ var center = GlobalPosition;
+ var radius = new Vector2( worldPoint.X - center.X, worldPoint.Z - center.Z ).Length();
+
+ if ( id == HandleIdInnerRadius )
+ {
+ innerRadius = radius;
+ }
+ else
+ {
+ outerRadius = radius;
+ }
+ }
+
+ protected override void RestoreShapeHandle( int id, Variant restore )
+ {
+ if ( id == HandleIdInnerRadius )
+ {
+ innerRadius = restore.AsSingle();
+ }
+ else
+ {
+ outerRadius = restore.AsSingle();
+ }
+ }
+
+#endif
}
}
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
index 65cce63..b897f6a 100644
--- a/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
@@ -77,6 +77,16 @@ namespace Rokojori
*/
protected void SetDirty()
{
+#if TOOLS
+ // A radius or a size edited in the inspector moves the gizmo handles, and
+ // nothing else would tell the editor to redraw them - a transform change
+ // does that on its own, an exported value does not.
+ if ( Engine.IsEditorHint() )
+ {
+ UpdateGizmos();
+ }
+#endif
+
var g = generator;
if ( g == null )
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
index 86c039c..96cb1af 100644
--- a/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
@@ -8,35 +8,273 @@ namespace Rokojori
/** A HeightMapDrawLayer that generates its content procedurally and blends it
onto the map.
- Everything these layers have in common is the blend mode, which is also
- what decides how a subclass reads its own height value: a delta for Add and
- Subtract, an absolute world height for Replace - see
- HeightMapBlendMode.IsAbsolute().
+ What these layers have in common is the blend mode and a height, and the
+ two are coupled: Add and Subtract read `height` as a delta
+ from whatever is underneath, so the node's own Y means nothing to them and
+ the height is dragged with its own gizmo handle instead. Replace reads the
+ node's Y position as the absolute world level, and
+ `height` only still matters for shapes that carry a per-texel amount - the
+ image layer's amplitude. See SetShapeValue().
- Subclasses are expected to configure generator.shapeMaterial and then call
- generator.DrawShape( rect, blendMode ), which keeps the world-to-texel
- mapping and the blit itself in one place.
+ Subclasses are expected to configure generator.shapeMaterial, call
+ SetShapeValue() and then generator.DrawShape( rect, blendMode ), which
+ keeps the world-to-texel mapping and the blit itself in one place.
+
+ Under TOOLS this is also the gizmo base: it draws the height handle and
+ dispatches everything else to the shape's own handles, so a subclass only
+ describes its outline and says what its handles do.
*/
[Tool]
public abstract partial class HeightMapGeneratorLayer:HeightMapDrawLayer
+#if TOOLS
+ , GizmoDrawerWithHandles
+#endif
{
HeightMapBlendMode _blendMode = HeightMapBlendMode.Add;
[Export]
public HeightMapBlendMode blendMode
{
- get => _blendMode;
- set { _blendMode = value; SetDirty(); }
+ get => _blendMode; set { _blendMode = value; SetDirty(); }
}
- /** The layer's height in meters, turned into the normalized value the shape
- shader wants - which conversion applies depends on the blend mode.
+ float _height = 10;
+
+ /** In meters. The height to add or subtract for Add/Subtract - the node's Y
+ is ignored there - and the amplitude on top of the node's Y for Replace,
+ which a flat shape like the circle simply does not use.
*/
- protected float NormalizeHeight( HeightMapGenerator generator, float worldHeight )
+ [Export]
+ public float height
{
- return blendMode.IsAbsolute()
- ? generator.NormalizeLevel( worldHeight )
- : generator.NormalizeDelta( worldHeight );
+ get => _height; set { _height = value; SetDirty(); }
}
+
+ /** Writes the two normalized height uniforms for this layer's blend mode.
+
+ `amplitude` is the per-texel height range the shape's own `amount` is
+ multiplied by. A shape with no per-texel amount passes 0 under Replace,
+ because its whole output is then the flat base level.
+ */
+ protected void SetShapeValue( HeightMapGenerator generator, float amplitude )
+ {
+ var material = generator.shapeMaterial;
+
+ material.valueBase.Set( blendMode.IsAbsolute() ? generator.NormalizeLevel( GlobalPosition.Y ) : 0 );
+ material.valueScale.Set( generator.NormalizeDelta( amplitude ) );
+ }
+
+
+#if TOOLS
+
+ /* GIZMOS
+ ------------------------------------------------------------------------
+ Handle positions are in node-local space and the gizmo is drawn with the
+ node's full global transform, so every world-space offset goes through
+ ToGizmoLocal() - otherwise a rotated or scaled parent would move the
+ handles off the shape they are supposed to be editing.
+
+ Dragging reads the mouse ray against either the horizontal plane through
+ the layer ( radii, edges, corners ) or the vertical line through it
+ ( the height ). There is no undo integration, matching SplinePoint - a
+ cancelled drag restores the values the drag started from, which is what
+ CommitHandle's `cancel` is for.
+ */
+
+ /** Every shape layer has a height handle at id 0; a subclass numbers its own
+ from HandleIdCustom upwards.
+ */
+ public const int HandleIdHeight = 0;
+ public const int HandleIdCustom = 1;
+
+ /** Replace takes its level from the node's Y, which the normal move gizmo
+ already edits - so there is nothing for a height handle to do there.
+ */
+ protected bool usesHeightHandle => ! blendMode.IsAbsolute();
+
+ /** The outline, the handles and their ids, all in node-local space. */
+ protected abstract void DrawShapeGizmo( List lines, List handles, List ids );
+
+ protected virtual string GetShapeHandleName( int id ) { return ""; }
+ protected virtual Variant GetShapeHandleValue( int id ) { return Variant.From( 0 ); }
+ protected virtual void SetShapeHandle( int id, Camera3D camera, Vector2 point ) {}
+ protected virtual void RestoreShapeHandle( int id, Variant restore ) {}
+
+ /** A world-space offset from the node, as the gizmo's local space sees it. */
+ protected Vector3 ToGizmoLocal( Vector3 worldOffset )
+ {
+ var basis = GlobalTransform.Basis;
+
+ // A fully flattened basis has no inverse - draw unscaled rather than throw.
+ if ( Mathf.Abs( basis.Determinant() ) < 0.000001f )
+ {
+ return worldOffset;
+ }
+
+ return basis.Inverse() * worldOffset;
+ }
+
+ public void DrawGizmo( EditorNode3DGizmoPlugin gizmoPlugin, EditorNode3DGizmo gizmo )
+ {
+ gizmo.Clear();
+
+ var lines = new List();
+ var handles = new List();
+ var ids = new List();
+
+ DrawShapeGizmo( lines, handles, ids );
+
+ if ( usesHeightHandle )
+ {
+ var top = ToGizmoLocal( new Vector3( 0, height, 0 ) );
+
+ // The stem is what makes the sign readable - a negative height hangs
+ // below the node instead of looking like a small positive one.
+ lines.Add( Vector3.Zero );
+ lines.Add( top );
+
+ handles.Add( top );
+ ids.Add( HandleIdHeight );
+ }
+
+ var material = gizmoPlugin.GetMaterial( "main", gizmo );
+
+ if ( lines.Count > 0 )
+ {
+ gizmo.AddLines( lines.ToArray(), material );
+ }
+
+ if ( handles.Count > 0 )
+ {
+ var handleMaterial = gizmoPlugin.GetMaterial( "handles", gizmo );
+
+ gizmo.AddHandles( handles.ToArray(), handleMaterial ?? material, ids.ToArray() );
+ }
+ }
+
+ public string GetHandleName( EditorNode3DGizmo gizmo, int handleId, bool secondary )
+ {
+ return handleId == HandleIdHeight ? "Height" : GetShapeHandleName( handleId );
+ }
+
+ float _dragStartHeight;
+ Vector3 _dragStartPosition;
+
+ /** Where the layer sat when the drag started. The drag plane is anchored
+ here rather than on the live position, so an edge drag that moves the
+ node does not drag the plane out from under itself.
+ */
+ protected Vector3 dragStartPosition => _dragStartPosition;
+
+ public Variant GetHandleValue( EditorNode3DGizmo gizmo, int handleId, bool secondary )
+ {
+ _dragStartHeight = height;
+ _dragStartPosition = GlobalPosition;
+
+ return handleId == HandleIdHeight ? Variant.From( height ) : GetShapeHandleValue( handleId );
+ }
+
+ public void SetHandle( EditorNode3DGizmo gizmo, int id, bool secondary, Camera3D camera, Vector2 point )
+ {
+ if ( id == HandleIdHeight )
+ {
+ float along;
+
+ if ( ProjectOntoAxis( camera, point, _dragStartPosition, Vector3.Up, out along ) )
+ {
+ height = along;
+ }
+ }
+ else
+ {
+ SetShapeHandle( id, camera, point );
+ }
+
+ UpdateGizmos();
+ }
+
+ public void CommitHandle( EditorNode3DGizmo gizmo, int id, bool secondary, Variant restore, bool cancel )
+ {
+ if ( ! cancel )
+ {
+ return;
+ }
+
+ // Edge handles move the node as well as resizing it, so the position is
+ // restored for every handle, not just the ones that obviously moved it.
+ GlobalPosition = _dragStartPosition;
+
+ if ( id == HandleIdHeight )
+ {
+ height = _dragStartHeight;
+ }
+ else
+ {
+ RestoreShapeHandle( id, restore );
+ }
+
+ UpdateGizmos();
+ }
+
+
+ /** Where the mouse ray crosses the horizontal plane through `origin`. */
+ protected static bool ProjectOntoGroundPlane(
+ Camera3D camera, Vector2 point, Vector3 origin, out Vector3 worldPoint
+ )
+ {
+ worldPoint = origin;
+
+ var plane = new Godot.Plane( Vector3.Up, origin );
+
+ var intersection = plane.IntersectsRay(
+ camera.ProjectRayOrigin( point ), camera.ProjectRayNormal( point )
+ );
+
+ if ( intersection == null )
+ {
+ return false;
+ }
+
+ worldPoint = (Vector3) intersection;
+
+ return true;
+ }
+
+ /** How far along `axis` from `origin` the mouse ray passes closest - the
+ standard closest-point-between-two-lines solve. The height handle slides
+ on a line rather than in a plane, and a plane would be unusable from a
+ top-down view anyway.
+ */
+ protected static bool ProjectOntoAxis(
+ Camera3D camera, Vector2 point, Vector3 origin, Vector3 axis, out float along
+ )
+ {
+ along = 0;
+
+ var rayOrigin = camera.ProjectRayOrigin( point );
+ var rayDirection = camera.ProjectRayNormal( point );
+
+ var w = origin - rayOrigin;
+
+ var a = axis.Dot( axis );
+ var b = axis.Dot( rayDirection );
+ var c = rayDirection.Dot( rayDirection );
+ var d = axis.Dot( w );
+ var e = rayDirection.Dot( w );
+
+ var denominator = a * c - b * b;
+
+ // Looking straight down the axis - there is no unique closest point.
+ if ( Mathf.Abs( denominator ) < 0.000001f )
+ {
+ return false;
+ }
+
+ along = ( b * e - c * d ) / denominator;
+
+ return true;
+ }
+
+#endif
}
}
diff --git a/Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs
new file mode 100644
index 0000000..b811165
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs
@@ -0,0 +1,418 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Draws a texture into the height map as a rectangle in the world.
+
+ The second primitive, and the first one that is not procedural: the
+ texture goes in as the blit's source and the shader samples it in the
+ rect's own space, so a rotated layer needs no matrix and no UV rect of its
+ own.
+
+ Placement is the node's XZ position, `size` in meters, and
+ the node's Y rotation. Scale is deliberately ignored, the same way the
+ circle ignores it - the size is in meters and the gizmo edits it directly,
+ and letting the transform scale it too would make the inspector's numbers
+ lie about the footprint.
+
+ Height follows the blend mode, as everywhere in this
+ family: Add and Subtract read `height` as the amplitude of the sampled
+ channel, so a white texel adds the full height and a black one adds
+ nothing; Replace puts the node's Y at a black texel and Y + height at a
+ white one, writing the image as an absolute landform.
+
+ Coverage is what `channel` splits off. A single-channel
+ entry covers the whole rect, which makes the texture's black regions flat
+ rather than absent. The paired entries read alpha as coverage instead, so
+ a cut-out stamp only touches the map where it is opaque and crossfades on
+ its own alpha edge.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class ImageHeightMapGeneratorLayer:HeightMapGeneratorLayer
+ {
+ Texture2D _texture;
+
+ /** The height source. Sampled with linear filtering and no repeat - outside
+ the rect the layer contributes nothing at all.
+ */
+ [Export]
+ public Texture2D texture
+ {
+ get => _texture; set { _texture = value; SetDirty(); }
+ }
+
+ Vector2 _size = new Vector2( 50, 50 );
+
+ /** The rect's extent in world meters, along the node's own X and Z axes. */
+ [Export]
+ public Vector2 size
+ {
+ get => _size; set { _size = value; SetDirty(); }
+ }
+
+ HeightMapChannel _channel = HeightMapChannel.R;
+
+ /** Which channel carries the height, and whether alpha is read as coverage
+ - see HeightMapChannel.
+ */
+ [Export]
+ public HeightMapChannel channel
+ {
+ get => _channel; set { _channel = value; SetDirty(); }
+ }
+
+ bool _infinite = false;
+
+ /** Tiles the layer across the whole map instead of stopping
+ at its rect.
+
+ `size` stops being the footprint and becomes the tiling cell, which is
+ what makes this worth having over just setting size to the map's: a base
+ noise layer wants a small cell repeated everywhere, and stretching one
+ copy over 400 m would give it no detail. The texture has to be seamless
+ for this to look right - the sampler wraps, so a non-seamless one shows
+ a grid of its own edges.
+
+ A mask is the natural companion: with this on, the mask stops tiling and
+ covers the map instead, so it can fade the noise out regionally.
+ */
+ [Export]
+ public bool infinite
+ {
+ get => _infinite; set { _infinite = value; SetDirty(); }
+ }
+
+
+ Texture2D _mask;
+
+ /** An extra multiplier on the layer's coverage, on top of whatever the
+ channel already contributes.
+
+ It multiplies coverage, not height, which is what makes
+ it behave as a mask under every blend mode: a masked-out region falls
+ back to what is underneath rather than being flattened to the layer's
+ base level, exactly the way the circle's falloff does.
+
+ Mapped to the layer's own rect normally, and to the whole map
+ when `infinite` is on - a tiling layer's rect is a cell, and a mask
+ repeated per cell could not fade anything regionally. Either way it is
+ clamped, so outside its rect it extends its border value instead of
+ dropping to zero.
+ */
+ [Export]
+ public Texture2D mask
+ {
+ get => _mask; set { _mask = value; SetDirty(); }
+ }
+
+ HeightMapChannel _maskChannel = HeightMapChannel.A;
+
+ /** Which channel of the mask is read. The paired entries multiply the colour
+ channel by alpha, for a mask that carries both.
+ */
+ [Export]
+ public HeightMapChannel maskChannel
+ {
+ get => _maskChannel; set { _maskChannel = value; SetDirty(); }
+ }
+
+
+ /* ORIENTATION
+ ------------------------------------------------------------------------
+ Taken from the basis rather than from Rotation.Y, so a layer under a
+ rotated parent still lines up: the two world axes are the basis axes
+ flattened into XZ. A basis tilted fully onto its side leaves one of them
+ with no horizontal component, which is why both have a fallback.
+ */
+
+ public Vector2 axisX
+ {
+ get
+ {
+ var v = GlobalBasis.X;
+ var xz = new Vector2( v.X, v.Z );
+
+ return xz.LengthSquared() < 0.000001f ? new Vector2( 1, 0 ) : xz.Normalized();
+ }
+ }
+
+ public Vector2 axisZ
+ {
+ get
+ {
+ var v = GlobalBasis.Z;
+ var xz = new Vector2( v.X, v.Z );
+
+ if ( xz.LengthSquared() < 0.000001f )
+ {
+ var x = axisX;
+
+ return new Vector2( -x.Y, x.X );
+ }
+
+ return xz.Normalized();
+ }
+ }
+
+ public Vector2 centerXZ
+ {
+ get
+ {
+ var p = GlobalPosition;
+
+ return new Vector2( p.X, p.Z );
+ }
+ }
+
+
+ public override void RenderToHeightMap( HeightMapGenerator generator )
+ {
+ if ( texture == null || size.X <= 0 || size.Y <= 0 )
+ {
+ return;
+ }
+
+ var center = centerXZ;
+ var x = axisX;
+ var z = axisZ;
+
+ // Infinite covers everything, so there is nothing to clip against and the
+ // rect is simply the map.
+ var rect = infinite
+ ? generator.fullRect
+ : generator.WorldQuadToRect( center, x, z, size );
+
+ if ( rect.Size.X <= 0 || rect.Size.Y <= 0 )
+ {
+ return;
+ }
+
+ var material = generator.shapeMaterial;
+
+ material.Reset();
+ material.shapeType.Set( (int) HeightMapShapeType.Image );
+ material.center.Set( center );
+ material.axisX.Set( x );
+ material.axisZ.Set( z );
+ material.imageSize.Set( size );
+ material.imageChannel.Set( (int) channel );
+ material.imageTiling.Set( infinite );
+
+ material.maskChannel.Set( (int) maskChannel );
+
+ if ( infinite )
+ {
+ material.SetMaskRect(
+ generator.centerXZ, new Vector2( 1, 0 ), new Vector2( 0, 1 ), generator.size
+ );
+ }
+ else
+ {
+ material.SetMaskRect( center, x, z, size );
+ }
+
+ // The image has a per-texel amount under every blend mode, so unlike the
+ // circle its amplitude is the height even under Replace.
+ SetShapeValue( generator, height );
+
+ generator.DrawShape( rect, blendMode, texture, mask );
+ }
+
+
+#if TOOLS
+
+ /* GIZMOS
+ ------------------------------------------------------------------------
+ Four edge handles resize one dimension each, keeping the opposite edge
+ where it is - which means they move the node as well as changing `size`,
+ and that is the point: dragging an edge should feel like dragging an
+ edge, not like scaling around the center.
+
+ They sit a little outside the outline rather than on it, because a handle
+ exactly on the edge is hard to tell apart from the line it is on and
+ overlaps the corner handles at a small size.
+
+ The four corner handles rotate instead of resizing. A corner carries the
+ rect's diagonal direction, so pointing it at the mouse is the whole
+ interaction - no accumulated delta, no drag origin to get wrong.
+ */
+
+ public const int HandleIdEdgeFirst = HandleIdCustom;
+ public const int HandleIdCornerFirst = HandleIdCustom + 4;
+
+ const float MinHalfSize = 0.01f;
+
+ /** +1 for an edge on the axis's positive side, -1 for the negative one. */
+ static readonly float[] edgeSigns = { -1, 1, -1, 1 };
+
+ /** True when the edge is on the X axis, false for Z. */
+ static readonly bool[] edgeOnX = { true, true, false, false };
+
+ static readonly string[] edgeNames = { "Size -X", "Size +X", "Size -Z", "Size +Z" };
+
+ static readonly float[] cornerSignsX = { -1, 1, 1, -1 };
+ static readonly float[] cornerSignsZ = { -1, -1, 1, 1 };
+
+ float _editorHandleDistance = 0.08f;
+
+ /** How far outside the rect the edge handles sit, as a fraction of the
+ rect's longer side. A gizmo handle is a constant size on screen, so an
+ offset in pixels is not available here without the viewport camera - a
+ fraction keeps the handles clear of the outline at any scale instead.
+ */
+ [Export]
+ public float editorHandleDistance
+ {
+ get => _editorHandleDistance; set { _editorHandleDistance = value; SetDirty(); }
+ }
+
+ float handleOffset => editorHandleDistance * Mathf.Max( size.X, size.Y );
+
+ Vector3 axisX3 { get { var a = axisX; return new Vector3( a.X, 0, a.Y ); } }
+ Vector3 axisZ3 { get { var a = axisZ; return new Vector3( a.X, 0, a.Y ); } }
+
+ Vector3 EdgeAxis( int edge )
+ {
+ return edgeOnX[ edge ] ? axisX3 : axisZ3;
+ }
+
+ float EdgeHalfSize( int edge )
+ {
+ return ( edgeOnX[ edge ] ? size.X : size.Y ) / 2;
+ }
+
+ protected override void DrawShapeGizmo( List lines, List handles, List ids )
+ {
+ var x = axisX3 * ( size.X / 2 );
+ var z = axisZ3 * ( size.Y / 2 );
+
+ var corners = new Vector3[ 4 ];
+
+ for ( var i = 0; i < 4; i++ )
+ {
+ corners[ i ] = ToGizmoLocal( x * cornerSignsX[ i ] + z * cornerSignsZ[ i ] );
+ }
+
+ for ( var i = 0; i < 4; i++ )
+ {
+ lines.Add( corners[ i ] );
+ lines.Add( corners[ ( i + 1 ) % 4 ] );
+
+ handles.Add( corners[ i ] );
+ ids.Add( HandleIdCornerFirst + i );
+ }
+
+ var offset = handleOffset;
+
+ for ( var i = 0; i < 4; i++ )
+ {
+ var out3 = EdgeAxis( i ) * edgeSigns[ i ] * ( EdgeHalfSize( i ) + offset );
+
+ handles.Add( ToGizmoLocal( out3 ) );
+ ids.Add( HandleIdEdgeFirst + i );
+ }
+ }
+
+ protected override string GetShapeHandleName( int id )
+ {
+ return id >= HandleIdCornerFirst ? "Rotation" : edgeNames[ id - HandleIdEdgeFirst ];
+ }
+
+ protected override Variant GetShapeHandleValue( int id )
+ {
+ return id >= HandleIdCornerFirst ? Variant.From( GlobalRotation.Y ) : Variant.From( size );
+ }
+
+ protected override void SetShapeHandle( int id, Camera3D camera, Vector2 point )
+ {
+ Vector3 worldPoint;
+
+ if ( ! ProjectOntoGroundPlane( camera, point, dragStartPosition, out worldPoint ) )
+ {
+ return;
+ }
+
+ if ( id >= HandleIdCornerFirst )
+ {
+ RotateToCorner( id - HandleIdCornerFirst, worldPoint );
+ }
+ else
+ {
+ DragEdge( id - HandleIdEdgeFirst, worldPoint );
+ }
+ }
+
+ /** Moves one edge to the mouse, keeping the opposite one fixed.
+
+ Everything is measured along the edge's axis from the current center:
+ the opposite edge sits at -sign * half, the dragged one lands where the
+ mouse is minus the handle's own outward offset, and the center ends up
+ halfway between the two. Recomputed from the live state each call rather
+ than from a drag origin, which is why repeatedly calling it converges
+ instead of drifting.
+ */
+ void DragEdge( int edge, Vector3 worldPoint )
+ {
+ var axis = EdgeAxis( edge );
+ var sign = edgeSigns[ edge ];
+ var oldHalf = EdgeHalfSize( edge );
+
+ var along = ( worldPoint - GlobalPosition ).Dot( axis ) - sign * handleOffset;
+
+ var half = Mathf.Max( ( sign * along + oldHalf ) / 2, MinHalfSize );
+
+ GlobalPosition += axis * ( sign * half - sign * oldHalf );
+
+ size = edgeOnX[ edge ] ? new Vector2( half * 2, size.Y ) : new Vector2( size.X, half * 2 );
+ }
+
+ /** Points the dragged corner at the mouse.
+
+ The corner's direction in the node's unrotated frame is
+ fixed by its signs and the rect's proportions, so the new yaw is just the
+ mouse's angle minus that one. Atan2( x, z ) is the angle convention a Y
+ rotation uses, so no conversion is needed either.
+ */
+ void RotateToCorner( int corner, Vector3 worldPoint )
+ {
+ var delta = worldPoint - GlobalPosition;
+
+ if ( new Vector2( delta.X, delta.Z ).LengthSquared() < 0.000001f )
+ {
+ return;
+ }
+
+ var localX = cornerSignsX[ corner ] * size.X / 2;
+ var localZ = cornerSignsZ[ corner ] * size.Y / 2;
+
+ var yaw = Mathf.Atan2( delta.X, delta.Z ) - Mathf.Atan2( localX, localZ );
+
+ var rotation = GlobalRotation;
+
+ GlobalRotation = new Vector3( rotation.X, yaw, rotation.Z );
+
+ SetDirty();
+ }
+
+ protected override void RestoreShapeHandle( int id, Variant restore )
+ {
+ if ( id >= HandleIdCornerFirst )
+ {
+ var rotation = GlobalRotation;
+
+ GlobalRotation = new Vector3( rotation.X, restore.AsSingle(), rotation.Z );
+ SetDirty();
+
+ return;
+ }
+
+ size = restore.AsVector2();
+ }
+
+#endif
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs.uid b/Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs.uid
new file mode 100644
index 0000000..77ca036
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs.uid
@@ -0,0 +1 @@
+uid://msavssuoeirh
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader
new file mode 100644
index 0000000..b88496b
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader
@@ -0,0 +1,102 @@
+// Bakes a world-space normal map out of a HeightMapGenerator's height map.
+//
+// One blit over the whole map with the height map as the source, run after every
+// layer has drawn. The terrain material then samples this instead of taking its
+// own central difference per fragment - getTerrainNormal in
+// Shading/Library/Terrain.gdshaderinc - which both moves the work out of the
+// fragment shader and makes the smoothing a property of the map rather than
+// something every consumer has to agree on.
+//
+// Normals are stored encoded, n * 0.5 + 0.5, so the map works in an 8-bit format
+// as well as a half one. They are world space, not tangent space: the map is a
+// heightfield over world XZ, so there is no tangent frame to be relative to and
+// nothing to reconstruct.
+//
+// SMOOTHING
+// ---------
+// A heightfield's normal is a derivative, so it amplifies whatever noise the
+// height has - and a 1-texel central difference amplifies it most. Two knobs:
+//
+// normal_radius - how far apart the taps are, in texels. Widening the stencil
+// low-passes the slope itself, which is what removes texel
+// stair-stepping without touching the height map.
+// normal_samples - how many concentric stencils at 1x, 2x ... the radius get
+// averaged. One wide stencil misses detail between its taps;
+// averaging several keeps the fine slope and still drops the
+// noise.
+//
+// Each stencil is a full 3x3 Sobel rather than a 4-tap cross, because at a wide
+// radius a cross only sees two lines through the texel and reads a diagonal
+// ridge as flat.
+
+shader_type texture_blit;
+render_mode blend_disabled;
+
+uniform sampler2D normal_height_map : hint_blit_source0, filter_linear, repeat_disable;
+
+// The map's world extent in meters, which is what turns a height difference
+// into a slope. Without it the normals would depend on the resolution.
+uniform vec2 normal_size_xz = vec2( 100.0, 100.0 );
+
+uniform float normal_min_height = 0.0;
+uniform float normal_max_height = 100.0;
+
+uniform float normal_radius = 1.0;
+uniform int normal_samples = 1;
+
+// Scales the slope before normalizing - below 1 flattens the shading, above 1
+// exaggerates it. Does not touch the geometry, only the normal.
+uniform float normal_strength = 1.0;
+
+
+float heightMapNormals_height( vec2 uv )
+{
+ return mix( normal_min_height, normal_max_height, texture( normal_height_map, uv ).r );
+}
+
+void blit()
+{
+ vec2 mapSize = vec2( textureSize( normal_height_map, 0 ) );
+ vec2 texel = 1.0 / mapSize;
+ vec2 metersPerTexel = normal_size_xz / mapSize;
+
+ int samples = clamp( normal_samples, 1, 8 );
+ float radius = max( normal_radius, 0.25 );
+
+ vec2 gradient = vec2( 0.0, 0.0 );
+
+ for ( int i = 1; i <= samples; i++ )
+ {
+ float r = radius * float( i );
+
+ vec2 offset = texel * r;
+ vec2 meters = metersPerTexel * r;
+
+ float hMM = heightMapNormals_height( UV + vec2( -offset.x, -offset.y ) );
+ float hM0 = heightMapNormals_height( UV + vec2( -offset.x, 0.0 ) );
+ float hMP = heightMapNormals_height( UV + vec2( -offset.x, offset.y ) );
+
+ float h0M = heightMapNormals_height( UV + vec2( 0.0, -offset.y ) );
+ float h0P = heightMapNormals_height( UV + vec2( 0.0, offset.y ) );
+
+ float hPM = heightMapNormals_height( UV + vec2( offset.x, -offset.y ) );
+ float hP0 = heightMapNormals_height( UV + vec2( offset.x, 0.0 ) );
+ float hPP = heightMapNormals_height( UV + vec2( offset.x, offset.y ) );
+
+ // Sobel. The weights sum to 4 per side, so dividing by 8 * r-in-meters is
+ // the same central difference over 2r the 4-tap version would give.
+ gradient.x += ( ( hPM + 2.0 * hP0 + hPP ) - ( hMM + 2.0 * hM0 + hMP ) ) / ( 8.0 * meters.x );
+ gradient.y += ( ( hMP + 2.0 * h0P + hPP ) - ( hMM + 2.0 * h0M + hPM ) ) / ( 8.0 * meters.y );
+ }
+
+ gradient /= float( samples );
+
+ // The map's V runs along world +Z, so neither axis is flipped.
+ vec3 normal = normalize( vec3(
+ -gradient.x * normal_strength,
+ 1.0,
+ -gradient.y * normal_strength
+ ) );
+
+ COLOR0 = vec4( normal * 0.5 + 0.5, 1.0 );
+}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader.uid b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader.uid
new file mode 100644
index 0000000..d2c70e0
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader.uid
@@ -0,0 +1 @@
+uid://bfajwkc0lw7ve
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs
new file mode 100644
index 0000000..ee4a032
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs
@@ -0,0 +1,52 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ public class HeightMapNormalsShader
+ {
+ public static readonly CachedResource shader = new CachedResource(
+ "res://addons/rokojori_action_library/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader"
+ );
+
+ public static readonly Vector2PropertyName sizeXZ = Vector2PropertyName.Create( "normal_size_xz" );
+ public static readonly FloatPropertyName minHeight = FloatPropertyName.Create( "normal_min_height" );
+ public static readonly FloatPropertyName maxHeight = FloatPropertyName.Create( "normal_max_height" );
+
+ public static readonly FloatPropertyName radius = FloatPropertyName.Create( "normal_radius" );
+ public static readonly IntPropertyName samples = IntPropertyName.Create( "normal_samples" );
+ public static readonly FloatPropertyName strength = FloatPropertyName.Create( "normal_strength" );
+ }
+
+ /** Material for HeightMapNormals.gdshader.
+
+ One instance per HeightMapGenerator, like the shape material. The height
+ map is not a property here - it arrives as the blit's source, which is what
+ hint_blit_source0 binds to.
+ */
+ [Tool]
+ public partial class HeightMapNormalsMaterial:CustomMaterial
+ {
+ public readonly CustomMaterialProperty sizeXZ;
+ public readonly CustomMaterialProperty minHeight;
+ public readonly CustomMaterialProperty maxHeight;
+
+ public readonly CustomMaterialProperty radius;
+ public readonly CustomMaterialProperty samples;
+ public readonly CustomMaterialProperty strength;
+
+ public HeightMapNormalsMaterial()
+ {
+ Shader = HeightMapNormalsShader.shader.Get();
+
+ sizeXZ = new CustomMaterialProperty( this, HeightMapNormalsShader.sizeXZ );
+ minHeight = new CustomMaterialProperty( this, HeightMapNormalsShader.minHeight );
+ maxHeight = new CustomMaterialProperty( this, HeightMapNormalsShader.maxHeight );
+
+ radius = new CustomMaterialProperty( this, HeightMapNormalsShader.radius );
+ samples = new CustomMaterialProperty( this, HeightMapNormalsShader.samples );
+ strength = new CustomMaterialProperty( this, HeightMapNormalsShader.strength );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs.uid b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs.uid
new file mode 100644
index 0000000..348f9d5
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs.uid
@@ -0,0 +1 @@
+uid://cg4dtty0p55f8
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
index 0ab3f0b..4e8ad25 100644
--- a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
@@ -1,9 +1,9 @@
// Content pass of a HeightMapGeneratorLayer's shape.
//
-// Writes one procedural shape's height contribution into the compositor's
-// scratch buffer with blending disabled. The scratch buffer is then blended onto
-// the height map by a plain BlitMaterial, which is where Add / Subtract / Replace
-// actually happen - so this shader never reads the map and no ping-pong buffer is
+// Writes one shape's height contribution into the compositor's scratch buffer
+// with blending disabled. The scratch buffer is then blended onto the height map
+// by a plain BlitMaterial, which is where Add / Subtract / Replace actually
+// happen - so this shader never reads the map and no ping-pong buffer is
// involved.
//
// Height lives in the R channel (G and B carry the same value so the map is
@@ -16,6 +16,13 @@
// still drawn at its true position and size. ( The TextureStack had a bug here
// once - clipping the rect while keeping rect-local UV silently rescaled the
// content. Deriving world space from the clipped rect cannot have that problem. )
+//
+// Every shape produces two values, because a falloff means two different things
+// depending on the blend mode:
+// amount - how much of the layer's height this texel gets, 0..1
+// coverage - how much this texel is affected at all, 0..1
+// Under Add/Subtract coverage scales the contribution; under Replace it is the
+// alpha the Mix blend crossfades with.
shader_type texture_blit;
render_mode blend_disabled;
@@ -24,75 +31,195 @@ render_mode blend_disabled;
uniform vec2 shape_rect_world_min = vec2( 0.0, 0.0 );
uniform vec2 shape_rect_world_size = vec2( 1.0, 1.0 );
-// HeightMapShapeType. Only Circle exists so far; the switch is the extension
-// point for the primitives that follow.
+// HeightMapShapeType - 0 Circle, 1 Image.
uniform int shape_type = 0;
// HeightMapBlendMode - 0 Add, 1 Subtract, 2 Replace.
uniform int shape_blend_mode = 0;
-// Normalized 0..1 height, already converted by the generator: a delta of the
-// min-to-max range for Add/Subtract, an absolute level for Replace.
-uniform float shape_value = 0.0;
+// Normalized height, split into two uniforms so one formula covers both
+// readings: the stored level is shape_value_base + shape_value_scale * amount.
+//
+// Add/Subtract leave base at 0 and put the layer's delta in scale. Replace puts
+// the node's own world height in base, and scale is the amplitude layered on
+// top of it - which is 0 for a shape with no per-texel amount of its own, like
+// the circle, and the image layer's height for one that has.
+uniform float shape_value_base = 0.0;
+uniform float shape_value_scale = 0.0;
+// Circle.
uniform vec2 shape_center = vec2( 0.0, 0.0 );
uniform float shape_inner_radius = 0.0;
uniform float shape_outer_radius = 1.0;
uniform bool shape_smooth_falloff = true;
+// Image. The texture arrives as the blit's source 0; the layer's rect is
+// described by its two world-space XZ axes and its size in meters, so a rotated
+// layer needs no matrix and no UV rect.
+//
+// repeat_enable because a tiling layer relies on the sampler wrapping - which is
+// also why nothing here calls fract(), that would put a filtering seam on every
+// tile border. The non-tiling path emulates clamp-to-edge in UV instead.
+uniform sampler2D shape_image : hint_blit_source0, filter_linear, repeat_enable;
+uniform vec2 shape_axis_x = vec2( 1.0, 0.0 );
+uniform vec2 shape_axis_z = vec2( 0.0, 1.0 );
+uniform vec2 shape_size = vec2( 1.0, 1.0 );
+uniform bool shape_image_tiling = false;
+
+// HeightMapChannel - 0 R, 1 G, 2 B, 3 A, 4 RA, 5 GA, 6 BA.
+uniform int shape_image_channel = 0;
+
+// An extra multiplier on coverage, from source 1. An absent mask is bound to the
+// shared white 1x1 stand-in, so "no mask" needs no flag - it just multiplies by
+// 1 - and the mask is always clamped, so outside its own rect it extends its
+// border value rather than disappearing.
+uniform sampler2D shape_mask : hint_blit_source1, filter_linear, repeat_enable;
+uniform int shape_mask_channel = 3;
+uniform vec2 shape_mask_center = vec2( 0.0, 0.0 );
+uniform vec2 shape_mask_axis_x = vec2( 1.0, 0.0 );
+uniform vec2 shape_mask_axis_z = vec2( 0.0, 1.0 );
+uniform vec2 shape_mask_size = vec2( 1.0, 1.0 );
+
/** 1 inside the inner radius, falling to 0 at the outer radius. */
-float heightMapShape_circle( vec2 worldXZ )
+void heightMapShape_circle( vec2 worldXZ, out float amount, out float coverage )
{
float distance = length( worldXZ - shape_center );
float inner = min( shape_inner_radius, shape_outer_radius );
float outer = max( shape_inner_radius, shape_outer_radius );
+ float weight;
+
// A zero-width band is a hard edge, not a division by zero.
if ( outer <= inner )
{
- return distance <= outer ? 1.0 : 0.0;
+ weight = distance <= outer ? 1.0 : 0.0;
}
-
- float t = clamp( ( distance - inner ) / ( outer - inner ), 0.0, 1.0 );
- float weight = 1.0 - t;
-
- if ( shape_smooth_falloff )
+ else
{
- weight = weight * weight * ( 3.0 - 2.0 * weight );
+ float t = clamp( ( distance - inner ) / ( outer - inner ), 0.0, 1.0 );
+
+ weight = 1.0 - t;
+
+ if ( shape_smooth_falloff )
+ {
+ weight = weight * weight * ( 3.0 - 2.0 * weight );
+ }
}
- return weight;
+ amount = weight;
+
+ // The circle has no per-texel height of its own: under Replace it writes one
+ // flat level and the falloff is the crossfade, so it belongs in coverage;
+ // under Add/Subtract the falloff already scales the height through amount and
+ // coverage has to stay out of the way, or it would be applied twice.
+ coverage = shape_blend_mode == 2 ? weight : 1.0;
}
-float heightMapShape_weight( vec2 worldXZ )
+float heightMapShape_channel( vec4 color, int channel )
{
- switch ( shape_type )
+ if ( channel == 0 || channel == 4 ) { return color.r; }
+ if ( channel == 1 || channel == 5 ) { return color.g; }
+ if ( channel == 2 || channel == 6 ) { return color.b; }
+
+ return color.a;
+}
+
+/** 0..1 across a rotated world-space XZ rect.
+
+ Image row 0 lands on the rect's -Z edge, so the texture reads upright when
+ the map is looked at from above.
+*/
+vec2 heightMapShape_rectUV( vec2 worldXZ, vec2 center, vec2 axisX, vec2 axisZ, vec2 size )
+{
+ vec2 delta = worldXZ - center;
+ vec2 safeSize = max( size, vec2( 0.0001, 0.0001 ) );
+
+ return vec2( dot( delta, axisX ), dot( delta, axisZ ) ) / safeSize + 0.5;
+}
+
+vec2 heightMapShape_clampToEdge( sampler2D source, vec2 uv )
+{
+ vec2 halfTexel = 0.5 / vec2( textureSize( source, 0 ) );
+
+ return clamp( uv, halfTexel, 1.0 - halfTexel );
+}
+
+/** The layer's texture, sampled in the rotated rect's own space. */
+void heightMapShape_image( vec2 worldXZ, out float amount, out float coverage )
+{
+ amount = 0.0;
+ coverage = 0.0;
+
+ vec2 uv = heightMapShape_rectUV(
+ worldXZ, shape_center, shape_axis_x, shape_axis_z, shape_size
+ );
+
+ if ( ! shape_image_tiling )
{
- case 0: return heightMapShape_circle( worldXZ );
+ if ( uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 )
+ {
+ return;
+ }
+
+ // The sampler repeats for the tiling case, so clamp-to-edge has to be
+ // emulated here or a linear tap on the border would bleed the opposite edge
+ // of the texture in.
+ uv = heightMapShape_clampToEdge( shape_image, uv );
}
- return 0.0;
+ vec4 color = texture( shape_image, uv );
+
+ amount = heightMapShape_channel( color, shape_image_channel );
+ coverage = shape_image_channel >= 4 ? color.a : 1.0;
+}
+
+/** The mask's contribution to coverage. 1 everywhere when no mask is assigned,
+ because the stand-in bound in its place is white.
+*/
+float heightMapShape_mask( vec2 worldXZ )
+{
+ vec2 uv = heightMapShape_rectUV(
+ worldXZ, shape_mask_center, shape_mask_axis_x, shape_mask_axis_z, shape_mask_size
+ );
+
+ vec4 color = texture( shape_mask, heightMapShape_clampToEdge( shape_mask, uv ) );
+
+ float value = heightMapShape_channel( color, shape_mask_channel );
+
+ return shape_mask_channel >= 4 ? value * color.a : value;
}
void blit()
{
vec2 worldXZ = shape_rect_world_min + UV * shape_rect_world_size;
- float weight = heightMapShape_weight( worldXZ );
+ float amount = 0.0;
+ float coverage = 0.0;
+
+ switch ( shape_type )
+ {
+ case 0: heightMapShape_circle( worldXZ, amount, coverage ); break;
+ case 1: heightMapShape_image( worldXZ, amount, coverage ); break;
+ }
+
+ // Applied to coverage rather than to the height, so a masked-out region falls
+ // back to what is underneath under Replace instead of being flattened to the
+ // layer's base level.
+ coverage *= heightMapShape_mask( worldXZ );
if ( shape_blend_mode == 2 )
{
- // Replace: the height is flat and the falloff rides on alpha, so the Mix
- // blend crossfades from the layer's height back to what is underneath.
- COLOR0 = vec4( vec3( shape_value ), weight );
+ // Replace: the height goes in RGB and the coverage rides on alpha, so the
+ // Mix blend crossfades from the layer's height back to what is underneath.
+ COLOR0 = vec4( vec3( shape_value_base + shape_value_scale * amount ), coverage );
}
else
{
// Add / Subtract: alpha has to be 1, because the hardware blend multiplies
- // the source by its own alpha before summing. The falloff is applied here
+ // the source by its own alpha before summing. Coverage is applied here
// instead.
- COLOR0 = vec4( vec3( shape_value * weight ), 1.0 );
+ COLOR0 = vec4( vec3( shape_value_scale * amount * coverage ), 1.0 );
}
}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
index 9ad0d5e..9ad4f15 100644
--- a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
@@ -10,7 +10,8 @@ namespace Rokojori
*/
public enum HeightMapShapeType
{
- Circle = 0
+ Circle = 0,
+ Image = 1
}
public class HeightMapShapeShader
@@ -24,12 +25,26 @@ namespace Rokojori
public static readonly IntPropertyName shapeType = IntPropertyName.Create( "shape_type" );
public static readonly IntPropertyName blendMode = IntPropertyName.Create( "shape_blend_mode" );
- public static readonly FloatPropertyName value = FloatPropertyName.Create( "shape_value" );
+
+ public static readonly FloatPropertyName valueBase = FloatPropertyName.Create( "shape_value_base" );
+ public static readonly FloatPropertyName valueScale = FloatPropertyName.Create( "shape_value_scale" );
public static readonly Vector2PropertyName center = Vector2PropertyName.Create( "shape_center" );
public static readonly FloatPropertyName innerRadius = FloatPropertyName.Create( "shape_inner_radius" );
public static readonly FloatPropertyName outerRadius = FloatPropertyName.Create( "shape_outer_radius" );
public static readonly BoolPropertyName smoothFalloff = BoolPropertyName.Create( "shape_smooth_falloff" );
+
+ public static readonly Vector2PropertyName axisX = Vector2PropertyName.Create( "shape_axis_x" );
+ public static readonly Vector2PropertyName axisZ = Vector2PropertyName.Create( "shape_axis_z" );
+ public static readonly Vector2PropertyName imageSize = Vector2PropertyName.Create( "shape_size" );
+ public static readonly IntPropertyName imageChannel = IntPropertyName.Create( "shape_image_channel" );
+ public static readonly BoolPropertyName imageTiling = BoolPropertyName.Create( "shape_image_tiling" );
+
+ public static readonly IntPropertyName maskChannel = IntPropertyName.Create( "shape_mask_channel" );
+ public static readonly Vector2PropertyName maskCenter = Vector2PropertyName.Create( "shape_mask_center" );
+ public static readonly Vector2PropertyName maskAxisX = Vector2PropertyName.Create( "shape_mask_axis_x" );
+ public static readonly Vector2PropertyName maskAxisZ = Vector2PropertyName.Create( "shape_mask_axis_z" );
+ public static readonly Vector2PropertyName maskSize = Vector2PropertyName.Create( "shape_mask_size" );
}
/** Material for HeightMapShape.gdshader.
@@ -39,6 +54,9 @@ namespace Rokojori
between queued blits is safe - the earlier blits do not pick up the later
values - which is why every layer can share one material instead of
allocating its own.
+
+ The image texture is not a property here: it arrives as
+ the blit's source, which is what hint_blit_source0 binds to.
*/
[Tool]
public partial class HeightMapShapeMaterial:CustomMaterial
@@ -48,13 +66,27 @@ namespace Rokojori
public readonly CustomMaterialProperty shapeType;
public readonly CustomMaterialProperty blendMode;
- public readonly CustomMaterialProperty value;
+
+ public readonly CustomMaterialProperty valueBase;
+ public readonly CustomMaterialProperty valueScale;
public readonly CustomMaterialProperty center;
public readonly CustomMaterialProperty innerRadius;
public readonly CustomMaterialProperty outerRadius;
public readonly CustomMaterialProperty smoothFalloff;
+ public readonly CustomMaterialProperty axisX;
+ public readonly CustomMaterialProperty axisZ;
+ public readonly CustomMaterialProperty imageSize;
+ public readonly CustomMaterialProperty imageChannel;
+ public readonly CustomMaterialProperty imageTiling;
+
+ public readonly CustomMaterialProperty maskChannel;
+ public readonly CustomMaterialProperty maskCenter;
+ public readonly CustomMaterialProperty maskAxisX;
+ public readonly CustomMaterialProperty maskAxisZ;
+ public readonly CustomMaterialProperty maskSize;
+
public HeightMapShapeMaterial()
{
Shader = HeightMapShapeShader.shader.Get();
@@ -64,12 +96,38 @@ namespace Rokojori
shapeType = new CustomMaterialProperty( this, HeightMapShapeShader.shapeType );
blendMode = new CustomMaterialProperty( this, HeightMapShapeShader.blendMode );
- value = new CustomMaterialProperty( this, HeightMapShapeShader.value );
+
+ valueBase = new CustomMaterialProperty( this, HeightMapShapeShader.valueBase );
+ valueScale = new CustomMaterialProperty( this, HeightMapShapeShader.valueScale );
center = new CustomMaterialProperty( this, HeightMapShapeShader.center );
innerRadius = new CustomMaterialProperty( this, HeightMapShapeShader.innerRadius );
outerRadius = new CustomMaterialProperty( this, HeightMapShapeShader.outerRadius );
smoothFalloff = new CustomMaterialProperty( this, HeightMapShapeShader.smoothFalloff );
+
+ axisX = new CustomMaterialProperty( this, HeightMapShapeShader.axisX );
+ axisZ = new CustomMaterialProperty( this, HeightMapShapeShader.axisZ );
+ imageSize = new CustomMaterialProperty( this, HeightMapShapeShader.imageSize );
+ imageChannel = new CustomMaterialProperty( this, HeightMapShapeShader.imageChannel );
+ imageTiling = new CustomMaterialProperty( this, HeightMapShapeShader.imageTiling );
+
+ maskChannel = new CustomMaterialProperty( this, HeightMapShapeShader.maskChannel );
+ maskCenter = new CustomMaterialProperty( this, HeightMapShapeShader.maskCenter );
+ maskAxisX = new CustomMaterialProperty( this, HeightMapShapeShader.maskAxisX );
+ maskAxisZ = new CustomMaterialProperty( this, HeightMapShapeShader.maskAxisZ );
+ maskSize = new CustomMaterialProperty( this, HeightMapShapeShader.maskSize );
+ }
+
+ /** The mask rect, for a layer that has no opinion about it: anything the
+ mask's own rect does not cover takes its border value, so a 1x1 rect
+ centered on the shape is a flat multiplier over the whole shape.
+ */
+ public void SetMaskRect( Vector2 center, Vector2 axisX, Vector2 axisZ, Vector2 size )
+ {
+ maskCenter.Set( center );
+ maskAxisX.Set( axisX );
+ maskAxisZ.Set( axisZ );
+ maskSize.Set( size );
}
/** Back to a shape that contributes nothing, so a layer that only sets some
@@ -82,12 +140,23 @@ namespace Rokojori
{
shapeType.Set( (int) HeightMapShapeType.Circle );
blendMode.Set( (int) HeightMapBlendMode.Add );
- value.Set( 0 );
+
+ valueBase.Set( 0 );
+ valueScale.Set( 0 );
center.Set( Vector2.Zero );
innerRadius.Set( 0 );
outerRadius.Set( 0 );
smoothFalloff.Set( true );
+
+ axisX.Set( new Vector2( 1, 0 ) );
+ axisZ.Set( new Vector2( 0, 1 ) );
+ imageSize.Set( Vector2.One );
+ imageChannel.Set( (int) HeightMapChannel.R );
+ imageTiling.Set( false );
+
+ maskChannel.Set( (int) HeightMapChannel.A );
+ SetMaskRect( Vector2.Zero, new Vector2( 1, 0 ), new Vector2( 0, 1 ), Vector2.One );
}
}
}
diff --git a/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/GeometryTerrainOffset.cs b/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/GeometryTerrainOffset.cs
index b3e48aa..edb91ae 100644
--- a/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/GeometryTerrainOffset.cs
+++ b/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/GeometryTerrainOffset.cs
@@ -27,6 +27,7 @@ namespace Rokojori
@"
uniform sampler2D terrainHeightMap;
+ uniform sampler2D terrainNormalMap;
uniform vec2 terrainCenterXZ = vec2( 0.0, 0.0 );
uniform vec2 terrainSizeXZ = vec2( 100.0, 100.0 );
uniform float terrainMinHeight = 0.0;
diff --git a/Runtime/Shading/Library/Terrain.gdshaderinc b/Runtime/Shading/Library/Terrain.gdshaderinc
index cf765c5..128af62 100644
--- a/Runtime/Shading/Library/Terrain.gdshaderinc
+++ b/Runtime/Shading/Library/Terrain.gdshaderinc
@@ -34,9 +34,45 @@ void addTerrainOffset(
vec2 uv = ( worldVertex.xz - mapOffset ) / terrainSizeXZ;
float height = mix( terrainMinHeight, terrainMaxHeight, texture( terrainHeightMap, uv ).r );
-
+
worldVertex.y += height;
_VERTEX = worldToLocal( worldVertex, _MODEL_MATRIX );
+}
+
+/** The world-space normal HeightMapGenerator baked into terrainNormalMap.
+
+ Stored encoded, n * 0.5 + 0.5, so the map also works in an 8-bit format - see
+ Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader. World space, not
+ tangent space: a heightfield over world XZ has no tangent frame to be
+ relative to.
+
+ This replaces taking a central difference per fragment. Besides being
+ cheaper, it is the only way the smoothing can exist at all - a consumer
+ sampling its own two neighbours has nothing to widen the stencil with, and a
+ clipmap's triangles are far coarser than the map, so a vertex normal is not
+ an option either.
+*/
+vec3 getTerrainNormal(
+ sampler2D terrainNormalMap,
+ vec3 worldPosition,
+ vec2 terrainCenterXZ,
+ vec2 terrainSizeXZ
+)
+{
+ // An unassigned sampler is Godot's 1x1 default and reads black, and
+ // normalize( black * 2 - 1 ) is a diagonal rather than up - so a material
+ // whose generator has no normalMapTarget would get wrong lighting instead of
+ // flat lighting. A real normal map is never 1x1, so the size is a safe test.
+ if ( textureSize( terrainNormalMap, 0 ).x <= 1 )
+ {
+ return vec3( 0.0, 1.0, 0.0 );
+ }
+
+ vec2 mapOffset = terrainCenterXZ - terrainSizeXZ/2.0;
+
+ vec2 uv = ( worldPosition.xz - mapOffset ) / terrainSizeXZ;
+
+ return normalize( texture( terrainNormalMap, uv ).rgb * 2.0 - 1.0 );
}
\ No newline at end of file
diff --git a/Tools/GizmoDrawerPlugin.cs b/Tools/GizmoDrawerPlugin.cs
index 3d5dc02..ca8364d 100644
--- a/Tools/GizmoDrawerPlugin.cs
+++ b/Tools/GizmoDrawerPlugin.cs
@@ -20,6 +20,12 @@ namespace Rokojori
public GizmoDrawerPlugin()
{
CreateMaterial( "main", new Color( 1, 1, 1 , 1 ) );
+
+ // Handles need a material made by CreateHandleMaterial to render as the
+ // editor's billboarded, selectable dots - a plain line material draws
+ // them as single unclickable points. GetMaterial( "handles" ) is how a
+ // GizmoDrawerWithHandles reaches it.
+ CreateHandleMaterial( "handles" );
}
public override string _GetGizmoName()