From ae581c9a779655b097ea2493d970a0cc7547e78f Mon Sep 17 00:00:00 2001 From: Rokojori Date: Thu, 29 Oct 2026 14:31:38 +0100 Subject: [PATCH] Height maps: layer gizmos, image layer with mask/tiling, normal map pass Co-Authored-By: Claude Opus 5 --- .../Procedural/HeightMaps/HeightMapChannel.cs | 45 ++ .../HeightMaps/HeightMapChannel.cs.uid | 1 + .../HeightMaps/HeightMapGenerator.cs | 196 +++++++- .../Layers/CircleHeightMapGeneratorLayer.cs | 116 +++-- .../HeightMaps/Layers/HeightMapDrawLayer.cs | 10 + .../Layers/HeightMapGeneratorLayer.cs | 268 ++++++++++- .../Layers/ImageHeightMapGeneratorLayer.cs | 418 ++++++++++++++++++ .../ImageHeightMapGeneratorLayer.cs.uid | 1 + .../Shaders/HeightMapNormals.gdshader | 102 +++++ .../Shaders/HeightMapNormals.gdshader.uid | 1 + .../Shaders/HeightMapNormalsMaterial.cs | 52 +++ .../Shaders/HeightMapNormalsMaterial.cs.uid | 1 + .../Shaders/HeightMapShape.gdshader | 183 ++++++-- .../Shaders/HeightMapShapeMaterial.cs | 79 +++- .../TerrainOffset/GeometryTerrainOffset.cs | 1 + Runtime/Shading/Library/Terrain.gdshaderinc | 38 +- Tools/GizmoDrawerPlugin.cs | 6 + 17 files changed, 1434 insertions(+), 84 deletions(-) create mode 100644 Runtime/Procedural/HeightMaps/HeightMapChannel.cs create mode 100644 Runtime/Procedural/HeightMaps/HeightMapChannel.cs.uid create mode 100644 Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs create mode 100644 Runtime/Procedural/HeightMaps/Layers/ImageHeightMapGeneratorLayer.cs.uid create mode 100644 Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader create mode 100644 Runtime/Procedural/HeightMaps/Shaders/HeightMapNormals.gdshader.uid create mode 100644 Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs create mode 100644 Runtime/Procedural/HeightMaps/Shaders/HeightMapNormalsMaterial.cs.uid 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()