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 moving it in the 3D viewport - 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 nesting is grouping: 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( "Mesh Atlas" )] int _meshAtlasMaxSize = 4096; /** The cap on the shared mesh-pass buffer - see HeightMapMeshAtlas. A layer whose texel rect does not fit under this is skipped with a warning. */ [Export] public int meshAtlasMaxSize { get => _meshAtlasMaxSize; set { _meshAtlasMaxSize = value; SetDirty(); } } bool _meshAtlasHdr = true; /** Asks the mesh pass's viewport for a float render target. The pass writes UVs rather than colours, so an 8-bit target quantizes them to 256 steps across a texture - leave this on unless it turns out not to work. */ [Export] public bool meshAtlasHdr { get => _meshAtlasHdr; set { _meshAtlasHdr = value; SetDirty(); } } bool _meshAtlasSrgbDecode = false; /** The other half of the question above: a low dynamic range render target is also sRGB-encoded on the way out, which bends the values the mesh pass wrote. Turn this on if the ribbon's textures come out offset and stretched towards one edge with meshAtlasHdr off. */ [Export] public bool meshAtlasSrgbDecode { get => _meshAtlasSrgbDecode; set { _meshAtlasSrgbDecode = value; SetDirty(); } } [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 _layers = new List(); bool _rendering = false; /* THE MESH PASS ------------------------------------------------------------------------ Created on demand and only by a layer that needs it, as an internal child so it neither shows up in the scene tree nor gets saved with it - and so CollectLayers, which walks the normal children, never sees it. The first layer to ask for it in a given render starts the pass; the generator ends it in PrepareLayers once every layer has contributed. */ HeightMapMeshAtlas _meshAtlas; bool _meshAtlasUsed; bool _meshAtlasRedrawQueued; public HeightMapMeshAtlas meshAtlas { get { if ( _meshAtlas == null ) { _meshAtlas = new HeightMapMeshAtlas(); _meshAtlas.Name = "HeightMapMeshAtlas"; AddChild( _meshAtlas, false, InternalMode.Front ); } _meshAtlas.maxSize = Mathf.Clamp( meshAtlasMaxSize, 256, 16384 ); _meshAtlas.hdr = meshAtlasHdr; if ( ! _meshAtlasUsed ) { _meshAtlasUsed = true; _meshAtlas.Begin(); } return _meshAtlas; } } /** 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; PrepareLayers(); 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; } // Outside the guard on purpose: the mesh pass renders at the end of this // frame, so the blits above read the previous one. Flagging here makes the // next frame redo them against the finished atlas, and because the pass // reports no change the second time around, it settles after exactly one // extra render. if ( _meshAtlasRedrawQueued ) { _meshAtlasRedrawQueued = false; SetDirty(); } } /** The pre-pass: every visible layer gets to set up anything that cannot happen inside the ordered draw loop, then the shared mesh pass runs once. The atlas is only touched when a layer actually allocated a region, so a map made of circles and images never creates a viewport at all. */ void PrepareLayers() { _meshAtlasUsed = false; for ( var i = 0; i < _layers.Count; i++ ) { var layer = _layers[ i ]; if ( ! layer.IsVisibleInTree() ) { continue; } layer.PrepareRender( this ); } if ( ! _meshAtlasUsed || _meshAtlas == null ) { return; } _meshAtlasRedrawQueued = _meshAtlas.Flush(); } void CollectLayers( Node node, List 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. The four textures fill the shader's hint_blit_source0 to hint_blit_source3 - an image layer passes its texture and optionally a mask, a spline layer passes the mesh atlas, a mask, its texture atlas and its level table, a purely procedural shape passes nothing. Four is the exact ceiling the spike measured, so there is no slot left over. Every slot is always bound, to the shared white 1x1 stand-in when a layer has nothing for it: 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, null, null ); } public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source ) { DrawShape( rect, blendMode, source, null, null, null ); } public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source, Texture2D mask ) { DrawShape( rect, blendMode, source, mask, null, null ); } public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source, Texture2D mask, Texture2D source2, Texture2D source3 ) { 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; _blitSources[ 2 ] = source2 != null ? source2 : (Texture2D) dummySource; _blitSources[ 3 ] = source3 != null ? source3 : (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 _blitSources = new Godot.Collections.Array(){ null, null, 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. The mesh atlas is counted when one exists, since it is a full extra buffer and sized by the layers rather than by the map. */ public long EstimateMemory() { var r = textureSize; var maps = normalMapTarget != null ? 2 : 1; var total = (long) r.X * r.Y * format.BytesPerPixel() * maps; if ( _meshAtlas != null ) { total += _meshAtlas.EstimateMemory(); } return total; } } }