rokojori_action_library/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs

747 lines
22 KiB
C#

using System.Collections.Generic;
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
/** Renders a height map from the HeightMapDrawLayer nodes underneath it.
The node is the map's placement in the world: its global XZ is the map's
center, `size` is how many meters the map covers, and `resolution` is how
many texels that is. So a layer is positioned by <marked>moving it in the
3D viewport</marked> - the generator converts world XZ to texels, never the
other way round, and nothing in the system has a texel-space authoring
concept at all.
Height is stored normalized in the R channel and decoded by
addTerrainOffset ( Shading/Library/Terrain.gdshaderinc ) as
mix( minHeight, maxHeight, r ). minHeight/maxHeight are therefore part of
the contract between this node and the terrain material, which is what
PushToMaterials() exists to keep in sync.
Rendering is flag-driven, not immediate: a layer that moves or changes
calls SetDirty(), and the next _Process re-renders once. Several edits in
one frame - dragging a node, which moves it and re-emits every frame -
collapse into a single render. The render itself is GPU blits only, so at
the sizes this is meant for it is affordable per frame, which is what makes
dragging a layer around feel live in the editor.
Layers are collected by walking the children, so <marked>nesting is
grouping</marked>: put layers under a plain Node3D to move them together.
A nested HeightMapGenerator stops the walk - it owns its own map.
*/
[Tool]
[GlobalClass]
public partial class HeightMapGenerator:Node3D
{
static readonly Color White = new Color( 1, 1, 1, 1 );
static readonly Color Transparent = new Color( 0, 0, 0, 0 );
[ExportToolButton( "Render" )]
public Callable RenderButton => Callable.From( () => Render() );
Vector2 _size = new Vector2( 100, 100 );
/** The map's extent in world meters, on X and Z. */
[Export]
public Vector2 size
{
get => _size;
set { _size = value; SetDirty(); }
}
Vector2I _resolution = new Vector2I( 512, 512 );
/** The map's texture size. Combined with `size` this is the texel density -
512 over 100 m is roughly 0.2 m per texel.
*/
[Export]
public Vector2I resolution
{
get => _resolution;
set { _resolution = value; SetDirty(); }
}
float _minHeight = 0;
/** World height a stored 0 decodes to. Must match terrainMinHeight on the
material - PushToMaterials() does that.
*/
[Export]
public float minHeight
{
get => _minHeight;
set { _minHeight = value; SetDirty(); }
}
float _maxHeight = 100;
/** World height a stored 1 decodes to. Everything a layer writes is a
fraction of maxHeight - minHeight, so widening the range flattens the
existing layers rather than rescaling them.
*/
[Export]
public float maxHeight
{
get => _maxHeight;
set { _maxHeight = value; SetDirty(); }
}
float _baseHeight = 0;
/** The world height the map starts at before any layer draws. */
[Export]
public float baseHeight
{
get => _baseHeight;
set { _baseHeight = value; SetDirty(); }
}
TextureStackFormat _format = TextureStackFormat.RgbaHalf;
/** RgbaHalf by default, not Rgba8: 8 bits give 256 height steps over the
whole min-to-max range, which terraces visibly on any slope. Half is
smooth and still only 8 bytes per texel.
*/
[Export]
public TextureStackFormat format
{
get => _format;
set { _format = value; SetDirty(); }
}
/** The map itself. A DrawableTexture2D because that is what can be blitted
into; it is still a plain Texture2D to everything that samples it, so it
goes straight into a material's sampler slot.
Left empty, one is created on the first render - but then only this node
holds it, so the material has to be fed through `materials` below. Assign
a shared DrawableTexture2D here to reference the same map from a material
in the scene file instead.
*/
[Export]
public DrawableTexture2D heightMapTarget;
/** 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
terrain - GeometryTerrainOffset's uniform block, by name. Without it the
material would silently keep the size and height range it was authored
with while the generator used different ones.
*/
[Export]
public Material[] materials = new Material[ 0 ];
/** Off makes the map update only on the Render button, for a map too big to
recompute while dragging.
*/
[Export]
public bool autoRender = true;
[ExportGroup( "Debug Info" )]
[Export]
public int renderedLayers;
/* THE DIRTY FLAG
------------------------------------------------------------------------
One bool, checked in _Process, rather than rendering straight out of the
setter. A drag emits a transform notification every frame and an inspector
slider emits one per pixel of mouse travel, so rendering per notification
would run several full renders per frame to show one result.
*/
bool _dirty = true;
public void SetDirty()
{
_dirty = true;
}
public bool isDirty => _dirty;
public override void _Ready()
{
SetDirty();
}
public override void _Process( double delta )
{
if ( ! _dirty || ! autoRender )
{
return;
}
Render();
}
/* COORDINATES
------------------------------------------------------------------------
Texel (0,0) is the map's minimum XZ corner. addTerrainOffset computes its
UV as ( worldXZ - ( center - size/2 ) ) / size and samples with texture(),
whose (0,0) is the image's first texel - and BlitRect's Rect2I origin is
that same first texel, with no Y flip. So image +Y is world +Z throughout,
and nothing here needs to mirror anything.
*/
/** World XZ of the map's center - the node's own global position. */
public Vector2 centerXZ
{
get
{
var p = GlobalPosition;
return new Vector2( p.X, p.Z );
}
}
public Vector2 minXZ => centerXZ - size / 2;
public Vector2I textureSize => new Vector2I( Mathf.Max( 1, resolution.X ), Mathf.Max( 1, resolution.Y ) );
public Vector2 metersPerTexel
{
get
{
var r = textureSize;
return new Vector2( size.X / r.X, size.Y / r.Y );
}
}
public Rect2I fullRect => new Rect2I( 0, 0, textureSize.X, textureSize.Y );
public Vector2 TexelToWorldXZ( Vector2 texel )
{
var m = metersPerTexel;
var origin = minXZ;
return new Vector2( origin.X + texel.X * m.X, origin.Y + texel.Y * m.Y );
}
public Vector2 WorldXZToTexel( Vector2 worldXZ )
{
var m = metersPerTexel;
var origin = minXZ;
return new Vector2( ( worldXZ.X - origin.X ) / m.X, ( worldXZ.Y - origin.Y ) / m.Y );
}
/** The texel rect a world-space XZ box covers, grown to whole texels and
clipped to the map. Empty when the box misses the map entirely.
*/
public Rect2I WorldBoxToRect( Vector2 worldMin, Vector2 worldMax )
{
var min = WorldXZToTexel( worldMin );
var max = WorldXZToTexel( worldMax );
var x0 = Mathf.FloorToInt( Mathf.Min( min.X, max.X ) );
var y0 = Mathf.FloorToInt( Mathf.Min( min.Y, max.Y ) );
var x1 = Mathf.CeilToInt( Mathf.Max( min.X, max.X ) );
var y1 = Mathf.CeilToInt( Mathf.Max( min.Y, max.Y ) );
return new Rect2I( x0, y0, x1 - x0, y1 - y0 ).Intersection( fullRect );
}
/** Convenience for a radially symmetric layer. */
public Rect2I WorldCircleToRect( Vector3 worldCenter, float worldRadius )
{
var c = new Vector2( worldCenter.X, worldCenter.Z );
var r = new Vector2( worldRadius, worldRadius );
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
------------------------------------------------------------------------
Layers author in meters; the map stores 0..1. These two are the only
conversion, and which one a layer uses is decided by its blend mode -
HeightMapBlendMode.IsAbsolute().
*/
public float heightRange
{
get
{
var range = maxHeight - minHeight;
return Mathf.Abs( range ) < 0.0001f ? 1 : range;
}
}
/** A height difference in meters as a fraction of the range, for Add and
Subtract.
*/
public float NormalizeDelta( float worldHeightDelta )
{
return worldHeightDelta / heightRange;
}
/** An absolute world height as a stored 0..1 value, for Replace. */
public float NormalizeLevel( float worldHeight )
{
return ( worldHeight - minHeight ) / heightRange;
}
public float baseLevel => NormalizeLevel( baseHeight );
/* RENDERING */
HeightMapShapeMaterial _shapeMaterial;
/** The shared content-pass material. Layers configure it in
RenderToHeightMap() and then call DrawShape(), rather than allocating a
material each.
*/
public HeightMapShapeMaterial shapeMaterial
{
get
{
if ( _shapeMaterial == null )
{
_shapeMaterial = new HeightMapShapeMaterial();
}
return _shapeMaterial;
}
}
BlitMaterial _blendBlit;
BlitMaterial _copyBlit;
/** Straight overwrite, for clearing. */
BlitMaterial copyBlit
{
get
{
if ( _copyBlit == null )
{
_copyBlit = new BlitMaterial();
_copyBlit.BlendMode = BlitMaterial.BlendModeEnum.Disabled;
}
return _copyBlit;
}
}
readonly List<HeightMapDrawLayer> _layers = new List<HeightMapDrawLayer>();
bool _rendering = false;
/** Clears the map to baseHeight and lets every visible layer underneath draw
into it, in tree order - so the layer listed last wins, the same way the
3D viewport draws it.
*/
public void Render()
{
// A layer that calls SetDirty() from inside its own draw - a transform
// notification from a tool script, say - would otherwise recurse.
if ( _rendering )
{
return;
}
_dirty = false;
_rendering = true;
try
{
PrepareTarget();
_layers.Clear();
CollectLayers( this, _layers );
renderedLayers = 0;
for ( var i = 0; i < _layers.Count; i++ )
{
var layer = _layers[ i ];
if ( ! layer.IsVisibleInTree() )
{
continue;
}
layer.RenderToHeightMap( this );
renderedLayers++;
}
RenderNormalMap();
PushToMaterials();
}
finally
{
_rendering = false;
}
}
void CollectLayers( Node node, List<HeightMapDrawLayer> into )
{
foreach ( var child in node.GetChildren() )
{
// Its own map, its own layers.
if ( child is HeightMapGenerator )
{
continue;
}
if ( child is HeightMapDrawLayer layer )
{
into.Add( layer );
}
CollectLayers( child, into );
}
}
/** One blit per layer pair: the shape into a rect-sized scratch buffer with
blending disabled, then the scratch onto the map with the blend mode's
hardware blend.
The scratch buffer is rect-sized rather than full-size because BlitRect
stretches its whole source across the destination rect - a full-size
source would be squeezed into the rect.
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 )
{
return;
}
var material = shapeMaterial;
var meters = metersPerTexel;
material.rectWorldMin.Set( TexelToWorldXZ( new Vector2( rect.Position.X, rect.Position.Y ) ) );
material.rectWorldSize.Set( new Vector2( rect.Size.X * meters.X, rect.Size.Y * meters.Y ) );
material.blendMode.Set( (int) blendMode );
var scratch = TextureStackBufferPool.shared.Rent( rect.Size.X, rect.Size.Y, format, Transparent );
try
{
_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 )
{
_blendBlit = new BlitMaterial();
}
_blendBlit.BlendMode = blendMode.ToBlitBlendMode();
heightMapTarget.BlitRect( rect, scratch, White, 0, _blendBlit );
}
finally
{
TextureStackBufferPool.shared.Return( scratch );
}
}
/** 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
{
get
{
if ( _dummySource == null )
{
_dummySource = new DrawableTexture2D();
_dummySource.Setup( 1, 1, DrawableTexture2D.DrawableFormat.Rgba8, White, false );
}
return _dummySource;
}
}
/* PREPARING THE MAP
------------------------------------------------------------------------
Setup() reallocates, and reallocating makes the engine emit the texture's
changed signal - which rebuilds the inspector. So it runs only when the
allocation actually has to change, and every other render clears by
blitting a 1x1 base-height texture across the map with blending disabled.
Same pixels, no reallocation, no signal. This is also what takes the one
expensive fixed cost out of a re-render and makes rendering per frame
affordable - the same finding the TextureStack compositor landed on.
*/
DrawableTexture2D _setupTarget;
Vector2I _setupSize;
TextureStackFormat _setupFormat;
DrawableTexture2D _clearSource;
Color _clearSourceColor;
TextureStackFormat _clearSourceFormat;
void PrepareTarget()
{
if ( heightMapTarget == null )
{
heightMapTarget = new DrawableTexture2D();
}
var level = baseLevel;
var clearColor = new Color( level, level, level, 1 );
var wanted = textureSize;
if ( _setupTarget != heightMapTarget || _setupSize != wanted || _setupFormat != format )
{
heightMapTarget.Setup( wanted.X, wanted.Y, format.ToDrawableFormat(), clearColor, false );
_setupTarget = heightMapTarget;
_setupSize = wanted;
_setupFormat = format;
return;
}
ClearTarget( clearColor );
}
void ClearTarget( Color clearColor )
{
if ( _clearSource == null || _clearSourceColor != clearColor || _clearSourceFormat != format )
{
if ( _clearSource == null )
{
_clearSource = new DrawableTexture2D();
}
_clearSource.Setup( 1, 1, format.ToDrawableFormat(), clearColor, false );
_clearSourceColor = clearColor;
_clearSourceFormat = format;
}
heightMapTarget.BlitRect( fullRect, _clearSource, White, 0, copyBlit );
}
/* 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 )
{
return;
}
for ( var i = 0; i < materials.Length; i++ )
{
var material = materials[ i ];
if ( material == null )
{
continue;
}
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 );
TerrainOffsetShader.maxHeight.Set( material, maxHeight );
}
}
/** 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() * maps;
}
}
}