using System.Collections.Generic;
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
/** The one node that sets up control-map texture painting.
It owns the packed control map, builds the two Texture2DArrays and the
per-texture parameter strip out of the `textures` list, and pushes all of
it plus the bit layout onto the materials that declare ControlMapShader's
block - so a terrain material never references a texture directly and
never knows a bit offset.
Placement follows HeightMapGenerator exactly: the node's global XZ is the
map's center and `size` is how many meters it covers. A control map and a
height map authored over the same rect therefore line up texel for texel,
which is what will let the two merge later.
This is the authoring half of a painting system that does not exist
yet. There are no paint layers and no brush - the map is filled
from a debug pattern ( see ControlMapDebugPattern ) so that the rendering
path has something to render. The patterns are the testable output: each
one isolates one decode field, so a live test reports which part is wrong
rather than only that something is.
*/
[Tool]
[GlobalClass]
public partial class ControlMapTerrain:Node3D
{
[ExportToolButton( "Build" )]
public Callable BuildButton => Callable.From( () => Build() );
Vector2 _size = new Vector2( 1024, 1024 );
/** 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( 256, 256 );
/** The control map's texture size - one texel is one paintable unit.
This is the painting resolution, not the visual one: the textures it
indexes are sampled at their own tile density, so 4 m per control texel
still gives meter-scale ground detail. Terrain3D ships 1 texel per meter;
the default here is coarser so a debug pattern is legible at a glance.
*/
[Export]
public Vector2I resolution
{
get => _resolution;
set { _resolution = value; SetDirty(); }
}
ControlMapFormat _format;
/** How a texel's 32 bits are divided. Left empty, a default format is
created on the first build - 32 textures, 64 blend steps, 32 rotations,
16 scales.
*/
[Export]
public ControlMapFormat format
{
get => _format;
set { _format = value; SetDirty(); }
}
ControlMapTexture[] _textures = new ControlMapTexture[ 0 ];
/** The ground textures, in index order - an entry's
position in this array is the number a control texel stores. Reordering
repaints the terrain.
*/
[Export]
public ControlMapTexture[] textures
{
get => _textures;
set { _textures = value; SetDirty(); }
}
/** Materials to push the maps, the layout and the shading options onto after
every build. The whole wiring between this node and anything rendering
the ground, by uniform name - same role as HeightMapGenerator.materials.
*/
[Export]
public Material[] materials = new Material[ 0 ];
/** Off makes the node rebuild only on the Build button. The texture arrays
are a CPU resize of every ground texture, so this is not a per-frame
operation the way a height-map render is.
*/
[Export]
public bool autoBuild = true;
[ExportGroup( "Texture Arrays" )]
int _arrayResolution = 1024;
/** The size every layer of the two Texture2DArrays is resized to.
A texture array requires every layer to share one size and one format,
which is the real constraint of this technique on the art side: a 512
gravel and a 4k cliff cannot coexist, one of them is resampled. Picking
this is picking which.
*/
[Export( PropertyHint.Range, "64,4096" )]
public int arrayResolution
{
get => _arrayResolution;
set { _arrayResolution = Mathf.Clamp( value, 64, 4096 ); SetDirty(); }
}
bool _generateMipmaps = true;
/** Leave on. The shader samples with textureGrad, which selects a mip
explicitly - with no mip chain to select from there is nothing to select
and the ground aliases into noise at any distance.
*/
[Export]
public bool generateMipmaps
{
get => _generateMipmaps;
set { _generateMipmaps = value; SetDirty(); }
}
bool _albedoSrgbToLinear = false;
/** Converts the albedo layers to linear on the CPU during the build.
Whether a Texture2DArray built from Rgba8 images is treated as sRGB is
not something this node can see, so this is the escape hatch rather than
a setting with a known right value: if the ground renders washed out,
turn it on; if it renders muddy, turn it off. Costs precision in the
darks either way, which is why a shipping version would want the arrays
imported rather than built at tool time.
*/
[Export]
public bool albedoSrgbToLinear
{
get => _albedoSrgbToLinear;
set { _albedoSrgbToLinear = value; SetDirty(); }
}
[ExportGroup( "Shading" )]
float _blendSharpness = 4;
/** How sharply textures resolve against each other, using the albedo's alpha
as a height map. 1 is a plain weighted average - a dissolve. Higher lets
the taller texture win where it is tall, so a transition follows the
textures' own detail instead of fading.
*/
[Export( PropertyHint.Range, "1,16" )]
public float blendSharpness
{
get => _blendSharpness;
set { _blendSharpness = Mathf.Max( 1, value ); SetDirty(); }
}
bool _bilinear = true;
/** On reads the 4 control texels around a fragment and blends their shading;
off reads 1. Off is four times cheaper and shows the control map's texel
grid as hard edges, which is the quickest way to see what is actually
painted.
*/
[Export]
public bool bilinear
{
get => _bilinear;
set { _bilinear = value; SetDirty(); }
}
[ExportGroup( "Debug Pattern" )]
ControlMapDebugPattern _debugPattern = ControlMapDebugPattern.Showcase;
/** What to fill the map with, until there is something to paint with. */
[Export]
public ControlMapDebugPattern debugPattern
{
get => _debugPattern;
set { _debugPattern = value; SetDirty(); }
}
int _debugCellTexels = 16;
/** How many control texels one cell of the pattern covers. */
[Export( PropertyHint.Range, "1,128" )]
public int debugCellTexels
{
get => _debugCellTexels;
set { _debugCellTexels = Mathf.Max( 1, value ); SetDirty(); }
}
int _debugTextureCount = 0;
/** How many texture indices the pattern cycles through. 0 uses however many
entries `textures` has.
*/
[Export]
public int debugTextureCount
{
get => _debugTextureCount;
set { _debugTextureCount = Mathf.Max( 0, value ); SetDirty(); }
}
[ExportGroup( "Debug Info" )]
/** Bits the layout uses of its 32. Red is an overflowing layout. */
[Export]
public int usedBits;
[Export]
public int builtTextures;
/* THE DIRTY FLAG
------------------------------------------------------------------------
Same reasoning as HeightMapGenerator: an inspector slider emits a change
per pixel of mouse travel, so a build per notification would run several
to show one result. Unlike the height map this one is not cheap enough to
want per frame - the texture arrays are a CPU resize of every ground
texture - so the arrays are additionally only rebuilt when their own
signature changed.
*/
bool _dirty = true;
public void SetDirty()
{
_dirty = true;
if ( Engine.IsEditorHint() )
{
UpdateGizmos();
}
}
public bool isDirty => _dirty;
public override void _Ready()
{
SetDirty();
}
public override void _Process( double delta )
{
if ( ! _dirty || ! autoBuild )
{
return;
}
Build();
}
/* COORDINATES
------------------------------------------------------------------------
Identical to HeightMapGenerator's, deliberately: texel (0,0) is the map's
minimum XZ corner, image +Y is world +Z, and nothing has a texel-space
authoring concept.
*/
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 Vector2I WorldXZToTexel( Vector2 worldXZ )
{
var m = metersPerTexel;
var origin = minXZ;
return new Vector2I(
Mathf.FloorToInt( ( worldXZ.X - origin.X ) / m.X ),
Mathf.FloorToInt( ( worldXZ.Y - origin.Y ) / m.Y )
);
}
/* THE MAPS */
ImageTexture _controlMapTexture;
Image _controlImage;
Vector2I _controlImageSize = Vector2I.Zero;
/** The packed control map, R32F. A plain Texture2D to any sampler, but it
must only ever be read with texelFetch - see ControlMapFormat.ToStorage.
*/
public Texture2D controlMapTexture => _controlMapTexture;
Texture2DArray _albedoArray;
Texture2DArray _normalArray;
public Texture2DArray albedoArray => _albedoArray;
public Texture2DArray normalArray => _normalArray;
ImageTexture _paramsTexture;
Image _paramsImage;
/** The raw control values, kept CPU-side so a read does not need a GPU
readback. This is what a navmesh bake or a hole query will read.
*/
uint[] _controls = new uint[ 0 ];
bool _rebuildArrays = true;
string _arraySignature = "";
ControlMapFormat _resolvedFormat;
/** The format in use, creating the default one if none is assigned. */
public ControlMapFormat resolvedFormat
{
get
{
if ( format != null )
{
return format;
}
if ( _resolvedFormat == null )
{
_resolvedFormat = new ControlMapFormat();
}
return _resolvedFormat;
}
}
/* BUILDING */
bool _building = false;
public void Build()
{
if ( _building )
{
return;
}
_dirty = false;
_building = true;
try
{
var activeFormat = resolvedFormat;
usedBits = activeFormat.usedBits;
if ( activeFormat.isOverflowing )
{
this.LogError(
"ControlMapFormat needs " + usedBits + " bits and only has " + ControlMapFormat.TotalBits +
" - the highest fields are being truncated. Narrow a field."
);
}
UpdateChangeHooks();
BuildTextureArrays();
BuildTextureParams();
BuildControlMap();
PushToMaterials();
}
finally
{
_building = false;
}
}
/* CHANGE HOOKS
------------------------------------------------------------------------
The node's own exported values flag it through their setters, but a value
on an assigned Resource does not: editing metersPerTile on a
ControlMapTexture emits that resource's changed signal, which nothing was
listening to. So every assigned format and texture entry gets subscribed.
Connected with new Callable( this, MethodName.X ) rather than from a
lambda. A lambda's Callable is a ManagedCallable holding a GCHandle, and a
C# assembly reload releases every one of them - so after any build with
the editor open the connection errors with "delegate_handle.value is
null". That cost the TextureStack a session to find; it is not being
rediscovered here.
*/
readonly List _hooked = new List();
readonly List _wanted = new List();
public void OnSourceChanged()
{
SetDirty();
}
void UpdateChangeHooks()
{
_wanted.Clear();
if ( format != null )
{
_wanted.Add( format );
}
if ( textures != null )
{
for ( var i = 0; i < textures.Length; i++ )
{
if ( textures[ i ] != null )
{
_wanted.Add( textures[ i ] );
}
}
}
if ( SameAsHooked() )
{
return;
}
var callable = new Callable( this, MethodName.OnSourceChanged );
for ( var i = 0; i < _hooked.Count; i++ )
{
var source = _hooked[ i ];
if ( IsInstanceValid( source ) && source.IsConnected( Resource.SignalName.Changed, callable ) )
{
source.Disconnect( Resource.SignalName.Changed, callable );
}
}
_hooked.Clear();
for ( var i = 0; i < _wanted.Count; i++ )
{
var source = _wanted[ i ];
if ( ! source.IsConnected( Resource.SignalName.Changed, callable ) )
{
source.Connect( Resource.SignalName.Changed, callable );
}
_hooked.Add( source );
}
}
bool SameAsHooked()
{
if ( _hooked.Count != _wanted.Count )
{
return false;
}
for ( var i = 0; i < _hooked.Count; i++ )
{
if ( _hooked[ i ] != _wanted[ i ] )
{
return false;
}
}
return true;
}
/* THE CONTROL MAP
------------------------------------------------------------------------
Written as raw bytes rather than through Image.SetPixel, which is the one
non-obvious correctness point in this file.
A control value is a 32-bit integer reinterpreted as a float, and most of
them are not valid floats - the high bits of a texture index land in the
exponent, so a perfectly ordinary texel is a NaN or a denormal. Anything
that puts such a value through a float register is entitled to quiet the
NaN or flush the denormal to zero, and either one silently corrupts
several fields at once.
So the uint's bytes go into the buffer directly, little-endian, and the
GPU's floatBitsToUint reads them back. No float ever holds a control
value on the way through.
*/
void BuildControlMap()
{
var wanted = textureSize;
var count = wanted.X * wanted.Y;
if ( _controls.Length != count )
{
_controls = new uint[ count ];
}
FillDebugPattern( wanted );
var data = new byte[ count * 4 ];
for ( var i = 0; i < count; i++ )
{
var control = _controls[ i ];
var offset = i * 4;
data[ offset + 0 ] = (byte) ( control & 0xFF );
data[ offset + 1 ] = (byte) ( ( control >> 8 ) & 0xFF );
data[ offset + 2 ] = (byte) ( ( control >> 16 ) & 0xFF );
data[ offset + 3 ] = (byte) ( ( control >> 24 ) & 0xFF );
}
// No mipmaps, ever. A mip of a control map is an average of bit patterns.
_controlImage = Image.CreateFromData( wanted.X, wanted.Y, false, Image.Format.Rf, data );
if ( _controlMapTexture == null || _controlImageSize != wanted )
{
_controlMapTexture = ImageTexture.CreateFromImage( _controlImage );
_controlImageSize = wanted;
}
else
{
_controlMapTexture.Update( _controlImage );
}
}
/** The control value at a world position, or the format's neutral value
outside the map. CPU-side, no readback.
*/
public uint SampleControl( Vector3 worldPosition )
{
var texel = WorldXZToTexel( new Vector2( worldPosition.X, worldPosition.Z ) );
var wanted = textureSize;
if ( texel.X < 0 || texel.Y < 0 || texel.X >= wanted.X || texel.Y >= wanted.Y )
{
return resolvedFormat.packedDefault;
}
var index = texel.Y * wanted.X + texel.X;
return index < _controls.Length ? _controls[ index ] : resolvedFormat.packedDefault;
}
public bool IsHole( Vector3 worldPosition )
{
var activeFormat = resolvedFormat;
return activeFormat.Read( SampleControl( worldPosition ), activeFormat.holeField ) != 0;
}
public bool IsNavigable( Vector3 worldPosition )
{
var activeFormat = resolvedFormat;
return activeFormat.Read( SampleControl( worldPosition ), activeFormat.navigableField ) != 0;
}
/* THE DEBUG PATTERNS */
int patternTextureCount
{
get
{
if ( debugTextureCount > 0 )
{
return debugTextureCount;
}
return Mathf.Max( 1, textures == null ? 1 : textures.Length );
}
}
void FillDebugPattern( Vector2I wanted )
{
var activeFormat = resolvedFormat;
for ( var y = 0; y < wanted.Y; y++ )
{
for ( var x = 0; x < wanted.X; x++ )
{
_controls[ y * wanted.X + x ] = PatternControl( activeFormat, debugPattern, x, y, wanted );
}
}
}
uint PatternControl(
ControlMapFormat activeFormat, ControlMapDebugPattern pattern,
int x, int y, Vector2I wanted
)
{
var count = patternTextureCount;
var cell = debugCellTexels;
var cellX = x / cell;
var cellY = y / cell;
var cellIndex = cellX + cellY;
switch ( pattern )
{
case ControlMapDebugPattern.Flat:
return activeFormat.packedDefault;
case ControlMapDebugPattern.Checkerboard:
return activeFormat.PackRaw( cellIndex % count, 0, 0, 0, 0 );
case ControlMapDebugPattern.BlendGradient:
{
var blend = wanted.X <= 1 ? 0 : x / (float) ( wanted.X - 1 );
return activeFormat.Pack( 0, Mathf.Min( 1, count - 1 ), blend, 0, 1 );
}
case ControlMapDebugPattern.ScaleAndRotation:
{
var rotationSteps = activeFormat.rotationBitCount <= 0 ? 1 : 1 << activeFormat.rotationBitCount;
var scaleSteps = activeFormat.scaleBitCount <= 0 ? 1 : 1 << activeFormat.scaleBitCount;
return activeFormat.PackRaw(
0, 0, 0, cellX % rotationSteps, cellY % scaleSteps
);
}
case ControlMapDebugPattern.Holes:
{
var hole = cellX % 3 == 1 && cellY % 3 == 1;
return activeFormat.PackRaw( 0, 0, 0, 0, 0, hole, ! hole );
}
case ControlMapDebugPattern.Showcase:
{
// Quadrants, each evaluated in its own local coordinates so a
// gradient runs the full width of its quadrant rather than being a
// slice of a map-wide one.
var half = new Vector2I( Mathf.Max( 1, wanted.X / 2 ), Mathf.Max( 1, wanted.Y / 2 ) );
var localX = x % half.X;
var localY = y % half.Y;
var quadrant = ( x < half.X ? 0 : 1 ) + ( y < half.Y ? 0 : 2 );
switch ( quadrant )
{
case 0: return PatternControl( activeFormat, ControlMapDebugPattern.Checkerboard, localX, localY, half );
case 1: return PatternControl( activeFormat, ControlMapDebugPattern.BlendGradient, localX, localY, half );
case 2: return PatternControl( activeFormat, ControlMapDebugPattern.ScaleAndRotation, localX, localY, half );
}
return PatternControl( activeFormat, ControlMapDebugPattern.Holes, localX, localY, half );
}
}
return activeFormat.packedDefault;
}
/* THE TEXTURE ARRAYS
------------------------------------------------------------------------
Rebuilt only when their signature changed, because this is the expensive
part: every ground texture is pulled to the CPU, decompressed, resized and
mipmapped. An inspector edit to blendSharpness must not pay for that.
*/
string BuildArraySignature()
{
var signature = arrayResolution + "|" + generateMipmaps + "|" + albedoSrgbToLinear + "|";
if ( textures == null )
{
return signature;
}
for ( var i = 0; i < textures.Length; i++ )
{
var entry = textures[ i ];
if ( entry == null )
{
signature += "null,";
continue;
}
var albedoPath = entry.albedo == null ? "-" : entry.albedo.ResourcePath;
var normalPath = entry.normal == null ? "-" : entry.normal.ResourcePath;
// The instance ids cover a texture that has no path, like a generated
// NoiseTexture2D authored inline in the scene.
signature += albedoPath + "#" + ( entry.albedo == null ? 0 : entry.albedo.GetInstanceId() )
+ ":" + normalPath + "#" + ( entry.normal == null ? 0 : entry.normal.GetInstanceId() )
+ ",";
}
return signature;
}
void BuildTextureArrays()
{
var signature = BuildArraySignature();
if ( ! _rebuildArrays && signature == _arraySignature && _albedoArray != null && _normalArray != null )
{
return;
}
_arraySignature = signature;
_rebuildArrays = false;
var count = textures == null ? 0 : textures.Length;
builtTextures = count;
if ( count == 0 )
{
_albedoArray = null;
_normalArray = null;
return;
}
var albedoImages = new Godot.Collections.Array();
var normalImages = new Godot.Collections.Array();
for ( var i = 0; i < count; i++ )
{
var entry = textures[ i ];
var albedo = entry == null ? null : entry.albedo;
var normal = entry == null ? null : entry.normal;
// Magenta rather than something neutral: an unassigned slot should be
// impossible to mistake for a ground texture.
albedoImages.Add( PrepareImage( albedo, new Color( 1, 0, 1, 1 ), albedoSrgbToLinear ) );
// Flat tangent normal with full roughness in alpha.
normalImages.Add( PrepareImage( normal, new Color( 0.5f, 0.5f, 1, 1 ), false ) );
}
_albedoArray = new Texture2DArray();
_normalArray = new Texture2DArray();
var albedoResult = _albedoArray.CreateFromImages( albedoImages );
var normalResult = _normalArray.CreateFromImages( normalImages );
if ( albedoResult != Error.Ok || normalResult != Error.Ok )
{
this.LogError( "Could not create the texture arrays:", albedoResult, normalResult );
}
}
/** One array layer: decompressed, converted, resized to arrayResolution and
mipmapped. Every layer has to come out at the same size and format or
CreateFromImages refuses the whole array.
*/
Image PrepareImage( Texture2D texture, Color fallback, bool srgbToLinear )
{
var size = arrayResolution;
Image image = null;
if ( texture != null )
{
image = texture.GetImage();
}
if ( image == null )
{
image = Image.CreateEmpty( size, size, false, Image.Format.Rgba8 );
image.Fill( fallback );
}
else
{
/* DUPLICATE BEFORE TOUCHING ANYTHING
--------------------------------------------------------------------
Texture2D.GetImage() does not reliably hand back a copy. A
CompressedTexture2D decodes a fresh Image, but an ImageTexture, a
NoiseTexture2D and anything else holding its pixels in memory return
their own Image by reference - so converting,
resizing or color-converting it edits the asset in place.
That made the build destructive and, with albedoSrgbToLinear on,
cumulative: every rebuild applied the sRGB-to-linear curve to the
already-converted source, and the ground got darker every time
anything flagged the node. It read as a caching problem because the
damage survived turning the option back off - nothing was cached, the
source texture itself had been overwritten.
Duplicating costs one copy of an image that is about to be resized
anyway.
*/
image = (Image) image.Duplicate( true );
// GetImage() hands back whatever the importer produced, which for a
// normal project asset is a compressed format that cannot be resized.
if ( image.IsCompressed() )
{
image.Decompress();
}
image.Convert( Image.Format.Rgba8 );
// Before the resize, not after: an imported texture arrives with its
// mip chain already built, and resizing an image that has one is
// refused rather than handled.
image.ClearMipmaps();
if ( image.GetWidth() != size || image.GetHeight() != size )
{
image.Resize( size, size, Image.Interpolation.Lanczos );
}
if ( srgbToLinear )
{
image.SrgbToLinear();
}
}
if ( generateMipmaps )
{
image.GenerateMipmaps();
}
return image;
}
/* THE PARAMETER STRIP
------------------------------------------------------------------------
One Rgbaf texel per texture index, holding the settings that are per
texture rather than per texel.
A texture rather than a uniform array, because a uniform array has to be
declared with a fixed length - which would put back exactly the kind of
hard-coded ceiling the variable bit layout exists to remove. One
texelFetch per tap costs nothing next to the albedo and normal samples it
is feeding.
*/
void BuildTextureParams()
{
var count = textures == null ? 0 : textures.Length;
if ( count == 0 )
{
_paramsTexture = null;
_paramsImage = null;
return;
}
if ( _paramsImage == null || _paramsImage.GetWidth() != count )
{
_paramsImage = Image.CreateEmpty( count, 1, false, Image.Format.Rgbaf );
_paramsTexture = null;
}
for ( var i = 0; i < count; i++ )
{
var entry = textures[ i ];
var value = entry == null ? new Color( 4, 0, 1, 1 ) : entry.ToParams();
_paramsImage.SetPixel( i, 0, value );
}
if ( _paramsTexture == null )
{
_paramsTexture = ImageTexture.CreateFromImage( _paramsImage );
}
else
{
_paramsTexture.Update( _paramsImage );
}
}
/** Pushes the maps, the placement, the format and the shading options onto
every assigned material.
*/
public void PushToMaterials()
{
if ( materials == null )
{
return;
}
var activeFormat = resolvedFormat;
for ( var i = 0; i < materials.Length; i++ )
{
var material = materials[ i ];
if ( material == null )
{
continue;
}
ControlMapShader.controlMap.Set( material, _controlMapTexture );
ControlMapShader.albedoArray.Set( material, _albedoArray );
ControlMapShader.normalArray.Set( material, _normalArray );
ControlMapShader.textureParams.Set( material, _paramsTexture );
ControlMapShader.hasTextureParams.Set( material, _paramsTexture != null );
ControlMapShader.defaultParams.Set( material, new Vector4( 4, 0, 1, 1 ) );
ControlMapShader.centerXZ.Set( material, centerXZ );
ControlMapShader.sizeXZ.Set( material, size );
ControlMapShader.blendSharpness.Set( material, blendSharpness );
ControlMapShader.bilinear.Set( material, bilinear );
activeFormat.PushToMaterial( material );
}
}
/** Rough VRAM cost of what this node owns, in bytes. The texture arrays
dominate by orders of magnitude - which is the thing to know about this
technique: the control map is cheap and the textures it indexes are not.
*/
public long EstimateMemory()
{
var r = textureSize;
// R32F, no mipmaps.
long total = (long) r.X * r.Y * 4;
var count = textures == null ? 0 : textures.Length;
// Two arrays, Rgba8, plus a third of their size again for the mip chain.
var perLayer = (long) arrayResolution * arrayResolution * 4;
total += perLayer * count * 2 * ( generateMipmaps ? 4 : 3 ) / 3;
return total;
}
}
}