964 lines
28 KiB
C#
964 lines
28 KiB
C#
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.
|
|
|
|
<marked>This is the authoring half of a painting system that does not exist
|
|
yet.</marked> 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, <marked>in index order</marked> - 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<Resource> _hooked = new List<Resource>();
|
|
readonly List<Resource> _wanted = new List<Resource>();
|
|
|
|
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<Image>();
|
|
var normalImages = new Godot.Collections.Array<Image>();
|
|
|
|
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 <marked>own</marked> 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;
|
|
}
|
|
}
|
|
}
|