Height maps: layer gizmos, image layer with mask/tiling, normal map pass

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-10-29 14:31:38 +01:00
parent f86625e1fb
commit ae581c9a77
17 changed files with 1434 additions and 84 deletions

View File

@ -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 <marked>coverage</marked> 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;
}
}
}

View File

@ -0,0 +1 @@
uid://d3nx7qd3h2eim

View File

@ -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<Texture2D> _blitSources
= new Godot.Collections.Array<Texture2D>(){ 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;
}
}
}

View File

@ -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<Vector3> lines, List<Vector3> handles, List<int> 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
}
}

View File

@ -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 )

View File

@ -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 <marked>delta</marked>
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 <marked>Y position</marked> 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<Vector3> lines, List<Vector3> handles, List<int> 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<Vector3>();
var handles = new List<Vector3>();
var ids = new List<int>();
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
}
}

View File

@ -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.
<marked>Placement</marked> 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.
<marked>Height</marked> 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.
<marked>Coverage</marked> 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 <marked>whole map</marked> 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 <marked>coverage</marked>, 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 <marked>whole map</marked>
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<Vector3> lines, List<Vector3> handles, List<int> 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 <marked>unrotated</marked> 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
}
}

View File

@ -0,0 +1 @@
uid://msavssuoeirh

View File

@ -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 );
}

View File

@ -0,0 +1 @@
uid://bfajwkc0lw7ve

View File

@ -0,0 +1,52 @@
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
public class HeightMapNormalsShader
{
public static readonly CachedResource<Shader> shader = new CachedResource<Shader>(
"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<Vector2> sizeXZ;
public readonly CustomMaterialProperty<float> minHeight;
public readonly CustomMaterialProperty<float> maxHeight;
public readonly CustomMaterialProperty<float> radius;
public readonly CustomMaterialProperty<int> samples;
public readonly CustomMaterialProperty<float> strength;
public HeightMapNormalsMaterial()
{
Shader = HeightMapNormalsShader.shader.Get();
sizeXZ = new CustomMaterialProperty<Vector2>( this, HeightMapNormalsShader.sizeXZ );
minHeight = new CustomMaterialProperty<float>( this, HeightMapNormalsShader.minHeight );
maxHeight = new CustomMaterialProperty<float>( this, HeightMapNormalsShader.maxHeight );
radius = new CustomMaterialProperty<float>( this, HeightMapNormalsShader.radius );
samples = new CustomMaterialProperty<int>( this, HeightMapNormalsShader.samples );
strength = new CustomMaterialProperty<float>( this, HeightMapNormalsShader.strength );
}
}
}

View File

@ -0,0 +1 @@
uid://cg4dtty0p55f8

View File

@ -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 );
}
}

View File

@ -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 <marked>not</marked> 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<int> shapeType;
public readonly CustomMaterialProperty<int> blendMode;
public readonly CustomMaterialProperty<float> value;
public readonly CustomMaterialProperty<float> valueBase;
public readonly CustomMaterialProperty<float> valueScale;
public readonly CustomMaterialProperty<Vector2> center;
public readonly CustomMaterialProperty<float> innerRadius;
public readonly CustomMaterialProperty<float> outerRadius;
public readonly CustomMaterialProperty<bool> smoothFalloff;
public readonly CustomMaterialProperty<Vector2> axisX;
public readonly CustomMaterialProperty<Vector2> axisZ;
public readonly CustomMaterialProperty<Vector2> imageSize;
public readonly CustomMaterialProperty<int> imageChannel;
public readonly CustomMaterialProperty<bool> imageTiling;
public readonly CustomMaterialProperty<int> maskChannel;
public readonly CustomMaterialProperty<Vector2> maskCenter;
public readonly CustomMaterialProperty<Vector2> maskAxisX;
public readonly CustomMaterialProperty<Vector2> maskAxisZ;
public readonly CustomMaterialProperty<Vector2> maskSize;
public HeightMapShapeMaterial()
{
Shader = HeightMapShapeShader.shader.Get();
@ -64,12 +96,38 @@ namespace Rokojori
shapeType = new CustomMaterialProperty<int>( this, HeightMapShapeShader.shapeType );
blendMode = new CustomMaterialProperty<int>( this, HeightMapShapeShader.blendMode );
value = new CustomMaterialProperty<float>( this, HeightMapShapeShader.value );
valueBase = new CustomMaterialProperty<float>( this, HeightMapShapeShader.valueBase );
valueScale = new CustomMaterialProperty<float>( this, HeightMapShapeShader.valueScale );
center = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.center );
innerRadius = new CustomMaterialProperty<float>( this, HeightMapShapeShader.innerRadius );
outerRadius = new CustomMaterialProperty<float>( this, HeightMapShapeShader.outerRadius );
smoothFalloff = new CustomMaterialProperty<bool>( this, HeightMapShapeShader.smoothFalloff );
axisX = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.axisX );
axisZ = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.axisZ );
imageSize = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.imageSize );
imageChannel = new CustomMaterialProperty<int>( this, HeightMapShapeShader.imageChannel );
imageTiling = new CustomMaterialProperty<bool>( this, HeightMapShapeShader.imageTiling );
maskChannel = new CustomMaterialProperty<int>( this, HeightMapShapeShader.maskChannel );
maskCenter = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.maskCenter );
maskAxisX = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.maskAxisX );
maskAxisZ = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.maskAxisZ );
maskSize = new CustomMaterialProperty<Vector2>( 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 );
}
}
}

View File

@ -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;

View File

@ -40,3 +40,39 @@ void addTerrainOffset(
_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 );
}

View File

@ -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()