diff --git a/Runtime/Animation/Smoothing/Smoothing.cs b/Runtime/Animation/Smoothing/Smoothing.cs
index a9f47d4..7a5bcdc 100644
--- a/Runtime/Animation/Smoothing/Smoothing.cs
+++ b/Runtime/Animation/Smoothing/Smoothing.cs
@@ -10,14 +10,21 @@ namespace Rokojori
public partial class Smoothing: Resource
{
float _currentFloat = 0;
+ public float currentFloat => _currentFloat;
+
public void SetCurrent( float value )
{
_currentFloat = value;
}
Vector2 _currentVector2 = Vector2.Zero;
+ public Vector2 currentVector2 => _currentVector2;
+
Vector3 _currentVector3 = Vector3.Zero;
+ public Vector3 currentVector3 => _currentVector3;
+
Vector4 _currentVector4 = Vector4.Zero;
+ public Vector4 currentVector4 => _currentVector4;
public void SetCurrent( Vector2 value )
{
@@ -34,8 +41,8 @@ namespace Rokojori
_currentVector4 = value;
}
-
Quaternion _currentQuaternion = Quaternion.Identity;
+ public Quaternion currentQuaternion => _currentQuaternion;
public void SetCurrent( Quaternion quaternion )
{
@@ -43,6 +50,7 @@ namespace Rokojori
}
Color _currentColor = Colors.Black;
+ public Color currentColor => _currentColor;
public void SetCurrent( Color color )
{
@@ -115,12 +123,16 @@ namespace Rokojori
return _currentVector3;
}
+
+
public static Vector3 Apply( Smoothing sm, Vector3 value, float delta )
{
if ( sm == null ){ return value; }
return sm.Smooth( value, delta );
}
+
+
public Vector4 Smooth( Vector4 nextValue, float delta )
{
_currentVector4 = _currentVector4.Lerp( nextValue, _ComputeInterpolationAmount( delta ) );
diff --git a/Runtime/Interactions/CharacterController/CharacterMovement.cs b/Runtime/Interactions/CharacterController/CharacterMovement.cs
index 46d15e9..d92deab 100644
--- a/Runtime/Interactions/CharacterController/CharacterMovement.cs
+++ b/Runtime/Interactions/CharacterController/CharacterMovement.cs
@@ -48,6 +48,27 @@ namespace Rokojori
[Export]
public CharacterMovementType currentMovementType;
+ [Export]
+ public Smoothing onFloorMovementSmoothing = new FrameSmoothing();
+
+ [Export]
+ public Smoothing inAirMovementSmoothing = new FrameSmoothing();
+
+ [ExportGroup("Deceleration")]
+ [Export]
+ public float absoluteDecelerationSpeedMinimum = 1;
+
+ [Export(PropertyHint.Range, "0,100,0.1,suffix:%")]
+ public float relativeDecelerationSpeedOffset = 0;
+
+ [Export]
+ public Smoothing onFloorDecelerationSmoothing = new FrameSmoothing();
+
+ [Export]
+ public Smoothing inAirDecelerationSmoothing = new FrameSmoothing();
+
+
+
[Export]
public CharacterMovementType[] deviceMovementTypes = [];
@@ -58,11 +79,7 @@ namespace Rokojori
[Export]
public CharacterMovementType switchedMovementType;
- [Export]
- public Smoothing onFloorMovementSmoothing = new FrameSmoothing();
- [Export]
- public Smoothing inAirMovementSmoothing = new FrameSmoothing();
bool _onFloor = false;
bool _moving = false;
@@ -186,15 +203,16 @@ namespace Rokojori
// var smoothedMovement = Vector3.Zero;
var movement = characterMovementData.movement;
+
var delta = controller.delta;
- if ( onFloor )
- {
- _smoothedMovement = onFloorMovementSmoothing.Smooth( movement, delta );
+ if ( onFloor || inAirMovementSmoothing == null )
+ {
+ _smoothedMovement = ComputeMovement( movement, onFloorMovementSmoothing, onFloorDecelerationSmoothing, delta );
}
else
{
- _smoothedMovement = inAirMovementSmoothing.Smooth( movement, delta );
+ _smoothedMovement = ComputeMovement(movement, inAirMovementSmoothing, inAirDecelerationSmoothing, delta );
}
var wasMoving = _moving;
@@ -222,6 +240,40 @@ namespace Rokojori
Velocity( _smoothedMovement, onFloor );
}
+
+
+ bool IsDecelerating( float newSpeed )
+ {
+ if ( newSpeed > currentSpeed )
+ {
+ return false;
+ }
+
+ var treshold = absoluteDecelerationSpeedMinimum + currentSpeed * relativeDecelerationSpeedOffset;
+
+ return newSpeed < treshold;
+ }
+
+ Vector3 ComputeMovement( Vector3 nextMovement, Smoothing normal, Smoothing decelerating, float delta )
+ {
+ if ( decelerating == null )
+ {
+ return normal.Smooth( nextMovement, delta );
+ }
+
+ var nextSpeed = nextMovement.Length();
+
+ if ( ! IsDecelerating( nextSpeed ))
+ {
+ return normal.Smooth( nextMovement, delta );
+ }
+
+ decelerating.SetCurrent( normal.currentVector3 );
+ var value = decelerating.Smooth( nextMovement, delta );
+ normal.SetCurrent( decelerating.currentVector3 );
+ return value;
+ }
+
}
}
\ No newline at end of file
diff --git a/Runtime/Procedural/HeightMaps/HeightMapBlendMode.cs b/Runtime/Procedural/HeightMaps/HeightMapBlendMode.cs
new file mode 100644
index 0000000..9f0ca32
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/HeightMapBlendMode.cs
@@ -0,0 +1,58 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** How a HeightMapGeneratorLayer combines with what is already in the map.
+
+ All three are native BlitMaterial blend modes, so a layer
+ costs one content blit into a scratch buffer plus one hardware-blended blit
+ onto the map - no ping-pong buffer and no accumulation read anywhere.
+
+ The int values are passed to HeightMapShape.gdshader as shape_blend_mode,
+ which decides whether the falloff goes into RGB (Add/Subtract, premultiplied
+ so the hardware just sums it) or into alpha (Replace, so Mix crossfades to
+ the layer's height across the falloff). Do not renumber without updating the
+ shader.
+ */
+ public enum HeightMapBlendMode
+ {
+ /** Adds the layer's height on top of what is underneath. The default, and
+ the one that makes a stack of layers read as constructive.
+ */
+ Add = 0,
+
+ /** Subtracts it - craters, trenches, cut-outs. */
+ Subtract = 1,
+
+ /** Overwrites with the layer's height, fading back to what is underneath
+ across the falloff. The layer's height value is an absolute
+ world height here, not a delta - plateaus and flattened build sites.
+ */
+ Replace = 2
+ }
+
+ public static class HeightMapBlendModeX
+ {
+ public static BlitMaterial.BlendModeEnum ToBlitBlendMode( this HeightMapBlendMode mode )
+ {
+ switch ( mode )
+ {
+ case HeightMapBlendMode.Add: return BlitMaterial.BlendModeEnum.Add;
+ case HeightMapBlendMode.Subtract: return BlitMaterial.BlendModeEnum.Sub;
+ case HeightMapBlendMode.Replace: return BlitMaterial.BlendModeEnum.Mix;
+ }
+
+ return BlitMaterial.BlendModeEnum.Add;
+ }
+
+ /** True when the layer's height value means "this world height", false when
+ it means "this much height on top of what is there".
+ */
+ public static bool IsAbsolute( this HeightMapBlendMode mode )
+ {
+ return mode == HeightMapBlendMode.Replace;
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/HeightMapBlendMode.cs.uid b/Runtime/Procedural/HeightMaps/HeightMapBlendMode.cs.uid
new file mode 100644
index 0000000..76837dc
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/HeightMapBlendMode.cs.uid
@@ -0,0 +1 @@
+uid://4rxkgyiwl0jh
diff --git a/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
new file mode 100644
index 0000000..b9e6a9d
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
@@ -0,0 +1,570 @@
+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;
+
+ /** Materials to push terrainHeightMap / 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 );
+ }
+
+
+ /* 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;
+
+ /** 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++;
+ }
+
+ PushToMaterials();
+ }
+ finally
+ {
+ _rendering = false;
+ }
+ }
+
+ 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.
+ */
+ public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode )
+ {
+ 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
+ {
+ // A blit needs a source even when the shader samples none, so a shared
+ // 1x1 stands in. The shape is entirely procedural from world XZ.
+ scratch.BlitRect(
+ new Rect2I( 0, 0, rect.Size.X, rect.Size.Y ), dummySource, White, 0, material
+ );
+
+ if ( _blendBlit == null )
+ {
+ _blendBlit = new BlitMaterial();
+ }
+
+ _blendBlit.BlendMode = blendMode.ToBlitBlendMode();
+
+ heightMapTarget.BlitRect( rect, scratch, White, 0, _blendBlit );
+ }
+ finally
+ {
+ TextureStackBufferPool.shared.Return( scratch );
+ }
+ }
+
+ 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 );
+ }
+
+
+ /** Pushes the map and its 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.centerXZ.Set( material, centerXZ );
+ TerrainOffsetShader.sizeXZ.Set( material, size );
+ TerrainOffsetShader.minHeight.Set( material, minHeight );
+ TerrainOffsetShader.maxHeight.Set( material, maxHeight );
+ }
+ }
+
+
+ /** Rough VRAM cost of the map itself, in bytes. The scratch buffer is
+ rect-sized and pooled, so it is not counted - a layer covering the whole
+ map would add one more of these.
+ */
+ public long EstimateMemory()
+ {
+ var r = textureSize;
+
+ return (long) r.X * r.Y * format.BytesPerPixel();
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs.uid b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs.uid
new file mode 100644
index 0000000..d90af7b
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs.uid
@@ -0,0 +1 @@
+uid://bgllq0x0hjmep
diff --git a/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs
new file mode 100644
index 0000000..0a4b561
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs
@@ -0,0 +1,104 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** A circular hill, crater or plateau, centered on the node's own position.
+
+ Full `height` inside innerRadius, fading to nothing at outerRadius. The
+ first primitive, and the template for the ones that follow: it computes the
+ texel rect it touches, configures the shared shape material and hands the
+ blit back to the generator.
+
+ Only XZ position is read, not rotation or scale - a circle has no
+ orientation, and scaling a radius through the node transform would make the
+ inspector's radius values lie. Later non-radial primitives will read the
+ basis.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class CircleHeightMapGeneratorLayer:HeightMapGeneratorLayer
+ {
+ float _height = 10;
+
+ /** In meters. A height to add or subtract for Add/Subtract, the absolute
+ world height to flatten to for Replace.
+ */
+ [Export]
+ public float height
+ {
+ get => _height;
+ set { _height = value; SetDirty(); }
+ }
+
+ float _innerRadius = 10;
+
+ /** Radius in meters that gets the full height. */
+ [Export]
+ public float innerRadius
+ {
+ get => _innerRadius;
+ set { _innerRadius = value; SetDirty(); }
+ }
+
+ float _outerRadius = 20;
+
+ /** Radius in meters the height has faded to nothing at. */
+ [Export]
+ public float outerRadius
+ {
+ get => _outerRadius;
+ set { _outerRadius = value; SetDirty(); }
+ }
+
+ bool _smoothFalloff = true;
+
+ /** Smoothstep instead of a linear ramp, so the hill meets the ground without
+ a crease at the outer radius and without a ridge at the inner one.
+ */
+ [Export]
+ public bool smoothFalloff
+ {
+ get => _smoothFalloff;
+ set { _smoothFalloff = value; SetDirty(); }
+ }
+
+
+ public override void RenderToHeightMap( HeightMapGenerator generator )
+ {
+ var inner = Mathf.Min( innerRadius, outerRadius );
+ var outer = Mathf.Max( innerRadius, outerRadius );
+
+ if ( outer <= 0 )
+ {
+ return;
+ }
+
+ var center = GlobalPosition;
+
+ // Clipped to the map, and the generator derives the shader's world rect
+ // from the clipped texels - so a circle half off the map draws its visible
+ // half at the right size instead of being squeezed into it.
+ var rect = generator.WorldCircleToRect( center, outer );
+
+ if ( rect.Size.X <= 0 || rect.Size.Y <= 0 )
+ {
+ return;
+ }
+
+ var material = generator.shapeMaterial;
+
+ material.Reset();
+ material.shapeType.Set( (int) HeightMapShapeType.Circle );
+ material.center.Set( new Vector2( center.X, center.Z ) );
+ material.innerRadius.Set( inner );
+ material.outerRadius.Set( outer );
+ material.smoothFalloff.Set( smoothFalloff );
+ material.value.Set( NormalizeHeight( generator, height ) );
+
+ generator.DrawShape( rect, blendMode );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs.uid b/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs.uid
new file mode 100644
index 0000000..377a46b
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/CircleHeightMapGeneratorLayer.cs.uid
@@ -0,0 +1 @@
+uid://47vr2c471q1l
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
new file mode 100644
index 0000000..65cce63
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
@@ -0,0 +1,139 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Base class for anything that draws into a HeightMapGenerator's map.
+
+ A Node3D, so a layer is placed, rotated and scaled in the 3D viewport
+ rather than in texel coordinates - that is the whole point of the design.
+ The generator converts the layer's world position into texels when it
+ draws.
+
+ Subclasses only implement RenderToHeightMap(). Everything about when
+ that happens is handled here: this class watches its own transform
+ and visibility and flags the nearest HeightMapGenerator above it, which
+ re-renders once on the next frame.
+
+ Two kinds sit under this: HeightMapGeneratorLayer for procedural shapes with
+ a blend mode, and - later - layers that are not shapes at all, like an
+ image or a nested map, which is why the blend mode is not here.
+ */
+ [Tool]
+ public abstract partial class HeightMapDrawLayer:Node3D
+ {
+ /** Draws this layer into `generator`'s map.
+
+ Called in tree order during a render, with the map already allocated and
+ cleared. Implementations are expected to go through the generator for
+ coordinate conversion and for the blit itself - see
+ CircleHeightMapGeneratorLayer for the shape case.
+ */
+ public abstract void RenderToHeightMap( HeightMapGenerator generator );
+
+
+ /* FINDING THE GENERATOR
+ ------------------------------------------------------------------------
+ Resolved lazily rather than in _Ready, because _Ready order between a
+ layer and its generator is not something a layer should depend on, and
+ because reparenting a layer in the editor has to keep working.
+ */
+
+ HeightMapGenerator _generator;
+
+ public HeightMapGenerator generator
+ {
+ get
+ {
+ if ( IsStillOurGenerator() )
+ {
+ return _generator;
+ }
+
+ _generator = NodesWalker.Get().GetParentWithType( this );
+
+ return _generator;
+ }
+ }
+
+ /** The cached reference survives only while it is still a live node and
+ still above us - a freed generator or a reparented layer has to walk
+ again, and IsInstanceValid is what keeps the freed case from throwing.
+ */
+ bool IsStillOurGenerator()
+ {
+ return _generator != null
+ && GodotObject.IsInstanceValid( _generator )
+ && _generator.IsInsideTree()
+ && IsInsideTree()
+ && _generator.IsAncestorOf( this );
+ }
+
+ /** Flags the generator above this layer for a re-render. Safe to call as
+ often as the editor emits changes - the generator collapses them into one
+ render per frame.
+ */
+ protected void SetDirty()
+ {
+ var g = generator;
+
+ if ( g == null )
+ {
+ return;
+ }
+
+ g.SetDirty();
+ }
+
+
+ /* CHANGE NOTIFICATION
+ ------------------------------------------------------------------------
+ Transform changes need SetNotifyTransform( true ) - Godot does not send
+ NotificationTransformChanged otherwise, and without it dragging a layer in
+ the viewport would not update the map at all.
+
+ Visibility rides on the eye icon in the scene tree, so the layer has no
+ "enabled" export of its own.
+
+ Exported values on a subclass are the subclass's business: a plain [Export]
+ field edited in the inspector notifies nothing, so every one of them has to
+ be a property whose setter calls SetDirty(). That is a convention, not
+ something this class can enforce.
+ */
+
+ public override void _Ready()
+ {
+ SetNotifyTransform( true );
+ SetDirty();
+ }
+
+ public override void _EnterTree()
+ {
+ // A fresh walk next time - the layer may have been reparented under a
+ // different generator.
+ _generator = null;
+ SetDirty();
+ }
+
+ public override void _ExitTree()
+ {
+ // Flag the generator it is leaving, so the map loses the layer's
+ // contribution. Taken before the cached reference is dropped.
+ SetDirty();
+
+ _generator = null;
+ }
+
+ public override void _Notification( int what )
+ {
+ base._Notification( what );
+
+ if ( what == NotificationTransformChanged || what == NotificationVisibilityChanged )
+ {
+ SetDirty();
+ }
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs.uid b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs.uid
new file mode 100644
index 0000000..522503a
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs.uid
@@ -0,0 +1 @@
+uid://kdfvmlxdb1kq
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
new file mode 100644
index 0000000..86c039c
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** A HeightMapDrawLayer that generates its content procedurally and blends it
+ onto the map.
+
+ Everything these layers have in common is the blend mode, which is also
+ what decides how a subclass reads its own height value: a delta for Add and
+ Subtract, an absolute world height for Replace - see
+ HeightMapBlendMode.IsAbsolute().
+
+ Subclasses are expected to configure generator.shapeMaterial and then call
+ generator.DrawShape( rect, blendMode ), which keeps the world-to-texel
+ mapping and the blit itself in one place.
+ */
+ [Tool]
+ public abstract partial class HeightMapGeneratorLayer:HeightMapDrawLayer
+ {
+ HeightMapBlendMode _blendMode = HeightMapBlendMode.Add;
+
+ [Export]
+ public HeightMapBlendMode blendMode
+ {
+ get => _blendMode;
+ set { _blendMode = value; SetDirty(); }
+ }
+
+ /** The layer's height in meters, turned into the normalized value the shape
+ shader wants - which conversion applies depends on the blend mode.
+ */
+ protected float NormalizeHeight( HeightMapGenerator generator, float worldHeight )
+ {
+ return blendMode.IsAbsolute()
+ ? generator.NormalizeLevel( worldHeight )
+ : generator.NormalizeDelta( worldHeight );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs.uid b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs.uid
new file mode 100644
index 0000000..52fb6e5
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs.uid
@@ -0,0 +1 @@
+uid://cupj0jf3oelma
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
new file mode 100644
index 0000000..0ab3f0b
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
@@ -0,0 +1,98 @@
+// Content pass of a HeightMapGeneratorLayer's shape.
+//
+// Writes one procedural shape's height contribution into the compositor's
+// scratch buffer with blending disabled. The scratch buffer is then blended onto
+// the height map by a plain BlitMaterial, which is where Add / Subtract / Replace
+// actually happen - so this shader never reads the map and no ping-pong buffer is
+// involved.
+//
+// Height lives in the R channel (G and B carry the same value so the map is
+// readable as a greyscale preview), matching addTerrainOffset in
+// Shading/Library/Terrain.gdshaderinc, which samples .r and maps 0..1 onto
+// terrainMinHeight..terrainMaxHeight.
+//
+// Everything is evaluated in world XZ, not UV: HeightMapGenerator hands in the
+// world-space rect this blit covers, so a shape that the map's edge clipped is
+// still drawn at its true position and size. ( The TextureStack had a bug here
+// once - clipping the rect while keeping rect-local UV silently rescaled the
+// content. Deriving world space from the clipped rect cannot have that problem. )
+
+shader_type texture_blit;
+render_mode blend_disabled;
+
+// World-space XZ rect this blit covers, from the (already clipped) pixel rect.
+uniform vec2 shape_rect_world_min = vec2( 0.0, 0.0 );
+uniform vec2 shape_rect_world_size = vec2( 1.0, 1.0 );
+
+// HeightMapShapeType. Only Circle exists so far; the switch is the extension
+// point for the primitives that follow.
+uniform int shape_type = 0;
+
+// HeightMapBlendMode - 0 Add, 1 Subtract, 2 Replace.
+uniform int shape_blend_mode = 0;
+
+// Normalized 0..1 height, already converted by the generator: a delta of the
+// min-to-max range for Add/Subtract, an absolute level for Replace.
+uniform float shape_value = 0.0;
+
+uniform vec2 shape_center = vec2( 0.0, 0.0 );
+uniform float shape_inner_radius = 0.0;
+uniform float shape_outer_radius = 1.0;
+uniform bool shape_smooth_falloff = true;
+
+
+/** 1 inside the inner radius, falling to 0 at the outer radius. */
+float heightMapShape_circle( vec2 worldXZ )
+{
+ float distance = length( worldXZ - shape_center );
+
+ float inner = min( shape_inner_radius, shape_outer_radius );
+ float outer = max( shape_inner_radius, shape_outer_radius );
+
+ // A zero-width band is a hard edge, not a division by zero.
+ if ( outer <= inner )
+ {
+ return distance <= outer ? 1.0 : 0.0;
+ }
+
+ float t = clamp( ( distance - inner ) / ( outer - inner ), 0.0, 1.0 );
+ float weight = 1.0 - t;
+
+ if ( shape_smooth_falloff )
+ {
+ weight = weight * weight * ( 3.0 - 2.0 * weight );
+ }
+
+ return weight;
+}
+
+float heightMapShape_weight( vec2 worldXZ )
+{
+ switch ( shape_type )
+ {
+ case 0: return heightMapShape_circle( worldXZ );
+ }
+
+ return 0.0;
+}
+
+void blit()
+{
+ vec2 worldXZ = shape_rect_world_min + UV * shape_rect_world_size;
+
+ float weight = heightMapShape_weight( worldXZ );
+
+ if ( shape_blend_mode == 2 )
+ {
+ // Replace: the height is flat and the falloff rides on alpha, so the Mix
+ // blend crossfades from the layer's height back to what is underneath.
+ COLOR0 = vec4( vec3( shape_value ), weight );
+ }
+ else
+ {
+ // Add / Subtract: alpha has to be 1, because the hardware blend multiplies
+ // the source by its own alpha before summing. The falloff is applied here
+ // instead.
+ COLOR0 = vec4( vec3( shape_value * weight ), 1.0 );
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader.uid b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader.uid
new file mode 100644
index 0000000..f7d0464
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader.uid
@@ -0,0 +1 @@
+uid://w4gb1lh7afw
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
new file mode 100644
index 0000000..9ad0d5e
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
@@ -0,0 +1,93 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Which primitive HeightMapShape.gdshader draws.
+
+ Int values are switched on in the shader - do not renumber.
+ */
+ public enum HeightMapShapeType
+ {
+ Circle = 0
+ }
+
+ public class HeightMapShapeShader
+ {
+ public static readonly CachedResource shader = new CachedResource(
+ "res://addons/rokojori_action_library/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader"
+ );
+
+ public static readonly Vector2PropertyName rectWorldMin = Vector2PropertyName.Create( "shape_rect_world_min" );
+ public static readonly Vector2PropertyName rectWorldSize = Vector2PropertyName.Create( "shape_rect_world_size" );
+
+ public static readonly IntPropertyName shapeType = IntPropertyName.Create( "shape_type" );
+ public static readonly IntPropertyName blendMode = IntPropertyName.Create( "shape_blend_mode" );
+ public static readonly FloatPropertyName value = FloatPropertyName.Create( "shape_value" );
+
+ public static readonly Vector2PropertyName center = Vector2PropertyName.Create( "shape_center" );
+ public static readonly FloatPropertyName innerRadius = FloatPropertyName.Create( "shape_inner_radius" );
+ public static readonly FloatPropertyName outerRadius = FloatPropertyName.Create( "shape_outer_radius" );
+ public static readonly BoolPropertyName smoothFalloff = BoolPropertyName.Create( "shape_smooth_falloff" );
+ }
+
+ /** Material for HeightMapShape.gdshader.
+
+ One instance per HeightMapGenerator, re-set per layer. The TextureStack's
+ session-2 live test already proved that re-setting a shared blit material
+ between queued blits is safe - the earlier blits do not pick up the later
+ values - which is why every layer can share one material instead of
+ allocating its own.
+ */
+ [Tool]
+ public partial class HeightMapShapeMaterial:CustomMaterial
+ {
+ public readonly CustomMaterialProperty rectWorldMin;
+ public readonly CustomMaterialProperty rectWorldSize;
+
+ public readonly CustomMaterialProperty shapeType;
+ public readonly CustomMaterialProperty blendMode;
+ public readonly CustomMaterialProperty value;
+
+ public readonly CustomMaterialProperty center;
+ public readonly CustomMaterialProperty innerRadius;
+ public readonly CustomMaterialProperty outerRadius;
+ public readonly CustomMaterialProperty smoothFalloff;
+
+ public HeightMapShapeMaterial()
+ {
+ Shader = HeightMapShapeShader.shader.Get();
+
+ rectWorldMin = new CustomMaterialProperty( this, HeightMapShapeShader.rectWorldMin );
+ rectWorldSize = new CustomMaterialProperty( this, HeightMapShapeShader.rectWorldSize );
+
+ shapeType = new CustomMaterialProperty( this, HeightMapShapeShader.shapeType );
+ blendMode = new CustomMaterialProperty( this, HeightMapShapeShader.blendMode );
+ value = new CustomMaterialProperty( this, HeightMapShapeShader.value );
+
+ center = new CustomMaterialProperty( this, HeightMapShapeShader.center );
+ innerRadius = new CustomMaterialProperty( this, HeightMapShapeShader.innerRadius );
+ outerRadius = new CustomMaterialProperty( this, HeightMapShapeShader.outerRadius );
+ smoothFalloff = new CustomMaterialProperty( this, HeightMapShapeShader.smoothFalloff );
+ }
+
+ /** Back to a shape that contributes nothing, so a layer that only sets some
+ of the uniforms does not inherit the previous layer's.
+
+ The rect uniforms are deliberately not reset - HeightMapGenerator.DrawShape
+ owns those and sets them after the layer has configured the shape.
+ */
+ public void Reset()
+ {
+ shapeType.Set( (int) HeightMapShapeType.Circle );
+ blendMode.Set( (int) HeightMapBlendMode.Add );
+ value.Set( 0 );
+
+ center.Set( Vector2.Zero );
+ innerRadius.Set( 0 );
+ outerRadius.Set( 0 );
+ smoothFalloff.Set( true );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs.uid b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs.uid
new file mode 100644
index 0000000..301ae90
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs.uid
@@ -0,0 +1 @@
+uid://bq4wwxf5ixnjg
diff --git a/Runtime/Procedural/Textures/TextureCombiner/TextureCombinerLayer.cs b/Runtime/Procedural/Textures/TextureCombiner/TextureCombinerLayer.cs
index fb82285..083a6c5 100644
--- a/Runtime/Procedural/Textures/TextureCombiner/TextureCombinerLayer.cs
+++ b/Runtime/Procedural/Textures/TextureCombiner/TextureCombinerLayer.cs
@@ -12,7 +12,7 @@ namespace Rokojori
{
[Tool]
[GlobalClass]
- public partial class TextureCombinerLayer:Resource
+ public abstract partial class TextureCombinerLayer:Resource
{
[Export]
public TextureCombinerBlendMode blendMode;
diff --git a/Runtime/Procedural/Textures/TextureSettings.cs b/Runtime/Procedural/Textures/TextureSettings.cs
new file mode 100644
index 0000000..ecaae1f
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureSettings.cs
@@ -0,0 +1,121 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** How a texture is filtered when it is sampled at a different resolution
+ than its own.
+
+ Int values are read by the TextureStack blit shaders - do not renumber.
+ */
+ public enum TextureSampling
+ {
+ /** One texel, no blending. Correct for index/id maps and for pixel art,
+ wrong for a low-resolution gradient blown up to 4k, which comes out
+ visibly banded.
+ */
+ Nearest = 0,
+
+ /** Bilinear blend of the four surrounding texels. The default, because a
+ mask or gradient authored small and used large is the common case.
+ */
+ Linear = 1
+ }
+
+ /** Colour space conversion applied to a texture right after it is sampled.
+
+ Godot does not convert between colour spaces for you here: a texture
+ sampled in a shader arrives in whatever space its own import settings
+ produced, and whether that is right depends on the space the output is
+ written in. For a TextureStack that means the target's format - an
+ Rgba8Srgb target encodes on write, a plain Rgba8 target does not.
+
+ So this is the explicit knob for "my source and my output disagree".
+ Alpha is never touched, only RGB.
+
+ Int values are read by the TextureStack blit shaders - do not renumber.
+ */
+ public enum TextureColorSpaceConversion
+ {
+ /** Take the sampled value as-is. The default - correct whenever source and
+ output are already in the same space.
+ */
+ None = 0,
+
+ /** Decode sRGB-encoded values to linear. Use when an sRGB source feeds a
+ linear target, or when a lighting-style calculation has to happen on
+ linear values.
+ */
+ SRGBToLinear = 1,
+
+ /** Encode linear values as sRGB. Use when a linear source feeds a target
+ that is sampled as sRGB later on.
+ */
+ LinearToSRGB = 2
+ }
+
+ /** How one texture slot is sampled: filtering and colour space conversion.
+
+ Shared, reusable settings resource rather than two fields repeated on
+ every slot, so the same settings object can be assigned to a layer's
+ content and to its masks at once.
+
+ Leaving the slot empty means Linear sampling and no
+ conversion, which is what the static helpers below return for
+ null. Nothing in the library requires a TextureSettings to exist - it is
+ only needed when the default is wrong.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureSettings:Resource
+ {
+ TextureSampling _sampling = TextureSampling.Linear;
+
+ /** Properties rather than fields, because Godot does not emit a scripted
+ Resource's changed signal for a plain exported field - and a
+ TextureStack recomposites off that signal. See
+ TextureStackChangeSource.
+ */
+ [Export]
+ public TextureSampling sampling
+ {
+ get => _sampling;
+ set { _sampling = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureColorSpaceConversion _colorSpaceConversion = TextureColorSpaceConversion.None;
+
+ [Export]
+ public TextureColorSpaceConversion colorSpaceConversion
+ {
+ get => _colorSpaceConversion;
+ set { _colorSpaceConversion = value; TextureStackNotify.Changed( this ); }
+ }
+
+
+ /** The defaults, for a null settings slot. Static rather than a shared
+ instance, so nothing has to allocate a Resource to ask.
+ */
+ public static TextureSampling SamplingOf( TextureSettings settings )
+ {
+ return settings == null ? TextureSampling.Linear : settings.sampling;
+ }
+
+ public static TextureColorSpaceConversion ColorSpaceConversionOf( TextureSettings settings )
+ {
+ return settings == null ? TextureColorSpaceConversion.None : settings.colorSpaceConversion;
+ }
+
+ /** The two as the ints the shaders expect. */
+ public static int SamplingValueOf( TextureSettings settings )
+ {
+ return (int) SamplingOf( settings );
+ }
+
+ public static int ColorSpaceConversionValueOf( TextureSettings settings )
+ {
+ return (int) ColorSpaceConversionOf( settings );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureSettings.cs.uid b/Runtime/Procedural/Textures/TextureSettings.cs.uid
new file mode 100644
index 0000000..03f7002
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureSettings.cs.uid
@@ -0,0 +1 @@
+uid://b1i3cv4rrjdxa
diff --git a/Runtime/Procedural/Textures/TextureStack/Actions/BakeTextureStack.cs b/Runtime/Procedural/Textures/TextureStack/Actions/BakeTextureStack.cs
new file mode 100644
index 0000000..d20543a
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Actions/BakeTextureStack.cs
@@ -0,0 +1,41 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Writes BakedTexture2Ds to disk on trigger.
+
+ The scripted counterpart to the Bake buttons, for the cases a button
+ cannot cover: baking a whole set in one go from an editor Action chain, or
+ regenerating a baked texture at runtime after something changed its stack.
+
+ Each texture keeps its own output path, so this only says "now".
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class BakeTextureStack:Action
+ {
+ /** Rebaked and rewritten to each one's own path. */
+ [Export]
+ public BakedTexture2D[] bakedTextures = new BakedTexture2D[ 0 ];
+
+ protected override void _OnTrigger()
+ {
+ if ( bakedTextures == null )
+ {
+ return;
+ }
+
+ for ( var i = 0; i < bakedTextures.Length; i++ )
+ {
+ if ( bakedTextures[ i ] == null )
+ {
+ continue;
+ }
+
+ bakedTextures[ i ].Bake();
+ }
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Actions/BakeTextureStack.cs.uid b/Runtime/Procedural/Textures/TextureStack/Actions/BakeTextureStack.cs.uid
new file mode 100644
index 0000000..0439aa2
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Actions/BakeTextureStack.cs.uid
@@ -0,0 +1 @@
+uid://gq6m51ydiqc6
diff --git a/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs b/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs
new file mode 100644
index 0000000..01a61d0
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs
@@ -0,0 +1,378 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** A TextureStack that lives on disk.
+
+ The user's answer to open question 4, built as asked: it behaves like a
+ TextureStackTexture2D, but it defines a file as its target and
+ always writes its result there.
+
+ The difference from TextureStackTexture2D is where the pixels are, and it
+ changes what each one is for:
+
+ - TextureStackTexture2D is a DrawableTexture2D. Its pixels only exist on
+ the GPU, so they have to be recomposited every single time the resource
+ loads - in the editor and in a shipped game alike. That is the price of
+ being live: edit a layer and it recomposites.
+ - BakedTexture2D is an ImageTexture. It composites once, when something
+ asks it to, and from then on it loads a file like any other texture.
+ No compositing at load, no GPU buffers held, no stack cost in a
+ shipped build - and the stack is still there as the editable source.
+
+ So: author with a TextureStackTexture2D, ship with a BakedTexture2D, or
+ use one directly for anything heavy enough that recompositing on load is
+ not wanted. bakeOnChange makes it behave like the live one
+ while authoring, at the cost of a disk write per edit.
+
+ The file is a normal project asset, so Godot imports, compresses and
+ mipmaps it the way it does any other texture. That is the main practical
+ reason to bake: a composited DrawableTexture2D is always uncompressed
+ RGBA8 in VRAM.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class BakedTexture2D:ImageTexture
+ {
+ TextureStack _stack;
+
+ [Export]
+ public TextureStack stack
+ {
+ get => _stack;
+ set { _stack = value; OnChanged(); }
+ }
+
+ string _path = "";
+
+ /** Where the result is written, and read back from afterwards. A project
+ path with an image extension - .png, .exr for HDR, .webp or .jpg.
+ Textures.Save picks the encoder off the extension.
+ */
+ [Export( PropertyHint.SaveFile, "*.png,*.exr,*.webp,*.jpg" )]
+ public string path
+ {
+ get => _path;
+ set { _path = value; OnChanged(); }
+ }
+
+ int _width = 1024;
+
+ [Export]
+ public int width
+ {
+ get => _width;
+ set { _width = value; OnChanged(); }
+ }
+
+ int _height = 1024;
+
+ [Export]
+ public int height
+ {
+ get => _height;
+ set { _height = value; OnChanged(); }
+ }
+
+ /** Rgba8 by default, same as TextureStackTexture2D and for the same reason
+ - see the note on its format property.
+ */
+ TextureStackFormat _format = TextureStackFormat.Rgba8;
+
+ [Export]
+ public TextureStackFormat format
+ {
+ get => _format;
+ set { _format = value; OnChanged(); }
+ }
+
+ Color _clearColor = new Color( 0, 0, 0, 0 );
+
+ [Export]
+ public Color clearColor
+ {
+ get => _clearColor;
+ set { _clearColor = value; OnChanged(); }
+ }
+
+ bool _mipmaps = true;
+
+ /** Mipmaps on the baked image. The imported texture's own mipmap setting
+ takes over once the file exists, so this only matters for the image this
+ writes.
+ */
+ [Export]
+ public bool mipmaps
+ {
+ get => _mipmaps;
+ set { _mipmaps = value; OnChanged(); }
+ }
+
+ [ExportToolButton( "Bake" )]
+ public Callable BakeButton => Callable.From( () => Bake() );
+
+ [ExportToolButton( "Load Baked" )]
+ public Callable LoadButton => Callable.From( () => LoadBaked() );
+
+ [ExportGroup( "Baking" )]
+
+ bool _bakeOnChange = false;
+
+ /** On: every edit to the stack rebakes and rewrites the file, so it behaves
+ like a live TextureStackTexture2D. Off - the default - only the Bake
+ button and the BakeTextureStack Action write anything, and a change just
+ reloads whatever is on disk.
+
+ Off by default because a bake is a disk write and a reimport, which is
+ not something to do per slider pixel.
+ */
+ [Export]
+ public bool bakeOnChange
+ {
+ get => _bakeOnChange;
+ set { _bakeOnChange = value; OnChanged(); }
+ }
+
+ float _quality = 1;
+
+ /** For the lossy formats only - jpg and webp. png and exr ignore it; 1
+ makes webp lossless.
+ */
+ [Export( PropertyHint.Range, "0,1" )]
+ public float quality
+ {
+ get => _quality;
+ set { _quality = value; }
+ }
+
+ [ExportGroup( "Info" )]
+
+ [Export]
+ public string info = "";
+
+
+ public Vector2I size => new Vector2I( Mathf.Max( 1, _width ), Mathf.Max( 1, _height ) );
+
+ bool _loaded = false;
+
+ /** True once an image is in here, baked or loaded. */
+ public bool isLoaded => _loaded;
+
+
+ /** Composites the stack, writes it to path, and shows the result. */
+ public bool Bake()
+ {
+ if ( _stack == null || _stack.numLayers == 0 )
+ {
+ info = "Nothing to bake - no stack.";
+ return false;
+ }
+
+ var image = TextureStackBaker.Render( _stack, size, _format, _clearColor, _mipmaps );
+
+ if ( image == null )
+ {
+ info = "Composite failed.";
+ return false;
+ }
+
+ if ( _mipmaps )
+ {
+ image.GenerateMipmaps();
+ }
+
+ Apply( image );
+
+ if ( string.IsNullOrEmpty( _path ) )
+ {
+ info = size.X + "x" + size.Y + " " + _format + ", baked in memory - no path set.";
+ return true;
+ }
+
+ TextureStackBaker.Save( image, _path, _quality );
+
+ info = size.X + "x" + size.Y + " " + _format + ", baked to " + _path;
+
+ return true;
+ }
+
+ /** Reads the file back without compositing anything. What a load does. */
+ public bool LoadBaked()
+ {
+ var image = TextureStackBaker.Load( _path );
+
+ if ( image == null )
+ {
+ return false;
+ }
+
+ Apply( image );
+
+ info = image.GetWidth() + "x" + image.GetHeight() + ", loaded from " + _path;
+
+ return true;
+ }
+
+ /** SetImage handles both cases - it creates the texture when there is none
+ yet and replaces the RID when there is. It also emits changed, which
+ collapses the inspector; unavoidable here and not worth avoiding, since
+ the pixels really are a new texture and a bake is always explicit.
+ */
+ void Apply( Image image )
+ {
+ SetImage( image );
+
+ _loaded = true;
+ }
+
+
+ /* SCHEDULING
+ --------------------------------------------------------------------------
+ Same deferred-setter mechanism as TextureStackTexture2D, and for the same
+ reason: there is no resource-load callback to hook, so every property
+ setter schedules and a flag coalesces the burst of writes a .tres load
+ produces.
+
+ What runs at the end of it differs. A TextureStackTexture2D has to
+ composite, because its pixels do not survive being saved. This one only
+ has to load its file, which is the whole point - unless
+ bakeOnChange asks for the live behaviour.
+ */
+
+ bool _scheduled = false;
+
+ void OnChanged()
+ {
+ if ( _scheduled )
+ {
+ return;
+ }
+
+ _scheduled = true;
+
+ // By name, not from a lambda - a delegate Callable does not survive a C#
+ // assembly reload. See the change-hooks note below.
+ CallDeferred( MethodName.Flush );
+ }
+
+ /** Public and named because it is called by name through CallDeferred. */
+ public void Flush()
+ {
+ _scheduled = false;
+
+ if ( ! GodotObject.IsInstanceValid( this ) )
+ {
+ return;
+ }
+
+ if ( _bakeOnChange )
+ {
+ Bake();
+ return;
+ }
+
+ LoadBaked();
+
+ EnsureChangeHooks();
+ }
+
+
+ /* CHANGE HOOKS
+ --------------------------------------------------------------------------
+ Only needed for bakeOnChange, and only then - without it an edit to a
+ layer changes nothing about this texture until someone bakes. Same
+ mechanism and the same reasons as TextureStackTexture2D's, including
+ connecting by method name rather than from a lambda, because a delegate
+ Callable's GCHandle is released by a C# assembly reload. See the note
+ there and on TextureStackChangeSource.
+
+ No draw index is bound here: a bake is a full composite into a throwaway
+ target, so there is no accumulation to replay from and nothing an
+ incremental index could do.
+ */
+
+ bool _hooked = false;
+ readonly List _hookedResources = new List();
+ readonly List _hookedCallables = new List();
+ readonly List _changeSources
+ = new List();
+
+ void EnsureChangeHooks()
+ {
+ if ( _hooked || _stack == null )
+ {
+ return;
+ }
+
+ ClearChangeHooks();
+
+ _hooked = true;
+
+ Hook( _stack );
+
+ _changeSources.Clear();
+ _stack.CollectChangeSources( _changeSources );
+
+ for ( var i = 0; i < _changeSources.Count; i++ )
+ {
+ Hook( _changeSources[ i ].resource );
+ }
+
+ _changeSources.Clear();
+ }
+
+ void Hook( Resource resource )
+ {
+ if ( resource == null || _hookedResources.Contains( resource ) )
+ {
+ return;
+ }
+
+ var callable = new Callable( this, MethodName.OnChangeSourceChanged );
+
+ if ( ! resource.IsConnected( Resource.SignalName.Changed, callable ) )
+ {
+ resource.Connect( Resource.SignalName.Changed, callable );
+ }
+
+ _hookedResources.Add( resource );
+ _hookedCallables.Add( callable );
+ }
+
+ /** Called by name from the `changed` signal of everything in the stack.
+ Public because Godot calls it; the name is part of the connection.
+ */
+ public void OnChangeSourceChanged()
+ {
+ _hooked = false;
+
+ OnChanged();
+ }
+
+ void ClearChangeHooks()
+ {
+ for ( var i = 0; i < _hookedResources.Count; i++ )
+ {
+ var resource = _hookedResources[ i ];
+
+ if ( resource == null || ! GodotObject.IsInstanceValid( resource ) )
+ {
+ continue;
+ }
+
+ var callable = _hookedCallables[ i ];
+
+ if ( resource.IsConnected( Resource.SignalName.Changed, callable ) )
+ {
+ resource.Disconnect( Resource.SignalName.Changed, callable );
+ }
+ }
+
+ _hookedResources.Clear();
+ _hookedCallables.Clear();
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs.uid b/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs.uid
new file mode 100644
index 0000000..0e2e066
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/BakedTexture2D.cs.uid
@@ -0,0 +1 @@
+uid://dyi4x53ie6gcc
diff --git a/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackEffectLayer.cs b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackEffectLayer.cs
new file mode 100644
index 0000000..2103aea
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackEffectLayer.cs
@@ -0,0 +1,413 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** What a TextureStackEffectLayer does to the accumulation it reads. */
+ public enum TextureStackEffect
+ {
+ /** Separable gaussian - a horizontal pass then a vertical one. Two blits,
+ one extra buffer.
+ */
+ Blur = 0,
+
+ /** One gaussian pass along direction. One blit, no extra buffer. The
+ motion-blur half of the fake-motion-blur example.
+ */
+ DirectionalBlur = 1,
+
+ /** Bright pass, then a separable blur of what survived it. Three blits, two
+ extra buffers. Set blendMode to Add - the layer produces
+ the glow, the blend adds it back over what it came from.
+ */
+ Glow = 2,
+
+ /** Reads the accumulation and writes it back unchanged, apart from tint and
+ intensity. Not an effect as such; it is how the accumulation is used as a
+ layer at all - a copy under a mask, or tinted through a blend mode.
+ */
+ Copy = 3
+ }
+
+ /** A layer that transforms what is already underneath it instead of adding
+ content of its own.
+
+ This is the third kind of layer the design doc asked for, and it rides on
+ the two-pass model rather than extending it: the effect runs in the
+ content half, reading the accumulation as a blit source
+ (gate G7) and writing the result into the scratch buffer. From there the
+ ordinary composite pass takes over, so an effect layer gets blend mode,
+ opacity, masks and clip-to-below for free and the compositor needed no
+ special case at all.
+
+ Which means the combinations work the way they read:
+
+ - Blur with Mix at opacity 1 - blurs everything below it.
+ - Blur with a mask - blurs only where the mask lets it, which is the
+ usual authoring move.
+ - Glow with Add - the ported RenderGraph glow.
+ - DirectionalBlur with Add and a low opacity - a motion-blurred smear
+ over the sharp original.
+
+ The effects themselves are ported from Rendering/RenderGraph's compute
+ processors. Not reused - a compute shader and a texture_blit shader are
+ different shader types - so the same named cost as the blend formulas
+ applies: a fix to the maths belongs in both.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureStackEffectLayer:TextureStackLayer
+ {
+ // A fresh group, because the base class leaves the inspector inside its
+ // own "Placement" group.
+ [ExportGroup( "Effect" )]
+
+ TextureStackEffect _effect = TextureStackEffect.Blur;
+
+ [Export]
+ public TextureStackEffect effect
+ {
+ get => _effect;
+ set { _effect = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _radius = 0.01f;
+
+ /** Blur reach as a fraction of the output's width, so the
+ same layer blurs the same amount at 512 and at 4096. 0.01 of a 1024
+ output is about 10 pixels.
+ */
+ [Export( PropertyHint.Range, "0,0.5" )]
+ public float radius
+ {
+ get => _radius;
+ set { _radius = value; TextureStackNotify.Changed( this ); }
+ }
+
+ int _samples = 9;
+
+ /** Taps per blur pass. Quality, not reach - radius sets the reach and the
+ taps are spread across it, so a large radius with few samples shows as
+ banding. Capped at the shader's loop bound.
+ */
+ [Export( PropertyHint.Range, "1,65" )]
+ public int samples
+ {
+ get => _samples;
+ set { _samples = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _directionDegrees = 0;
+
+ /** DirectionalBlur only. 0 is +X, measured towards +Y, which is *down* in
+ image space - Rect2I's origin is the top-left and UV.y runs with image
+ rows (gate G6), and nothing in this system flips Y.
+ */
+ [Export]
+ public float directionDegrees
+ {
+ get => _directionDegrees;
+ set { _directionDegrees = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _tileable = false;
+
+ /** On: the blur wraps around the output's edges, so a texture meant to tile
+ stays seamless. Off: it clamps to the edge, which is right for a decal or
+ a standalone texture.
+ */
+ [Export]
+ public bool tileable
+ {
+ get => _tileable;
+ set { _tileable = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Color _tint = new Color( 1, 1, 1, 1 );
+
+ /** Multiplied onto the effect's result, through the blit's MODULATE. */
+ [Export]
+ public Color tint
+ {
+ get => _tint;
+ set { _tint = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _intensity = 1;
+
+ /** Scales the result's RGB, not its alpha - a glow at intensity 3 is three
+ times as bright where it is, rather than three times as opaque.
+ */
+ [Export( PropertyHint.Range, "0,8" )]
+ public float intensity
+ {
+ get => _intensity;
+ set { _intensity = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportSubgroup( "Glow" )]
+
+ float _glowThreshold = 0.8f;
+
+ /** Luminance above which a pixel glows. */
+ [Export( PropertyHint.Range, "0,1" )]
+ public float glowThreshold
+ {
+ get => _glowThreshold;
+ set { _glowThreshold = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _glowKnee = 0.1f;
+
+ /** How far above the threshold it takes to glow fully, so the glow fades in
+ instead of snapping on at one luminance value.
+ */
+ [Export( PropertyHint.Range, "0.001,1" )]
+ public float glowKnee
+ {
+ get => _glowKnee;
+ set { _glowKnee = value; TextureStackNotify.Changed( this ); }
+ }
+
+
+ static readonly Color White = new Color( 1, 1, 1, 1 );
+ static readonly Color Transparent = new Color( 0, 0, 0, 0 );
+
+ /** Glow chains three passes through two intermediates, Blur two through
+ one. Both are rect-sized, so a masked blur over a small region costs
+ very little of what the full-size estimate suggests.
+ */
+ public override int extraBuffers
+ {
+ get
+ {
+ if ( radius <= 0 || samples <= 1 )
+ {
+ return effect == TextureStackEffect.Glow ? 1 : 0;
+ }
+
+ if ( effect == TextureStackEffect.Glow )
+ {
+ return 2;
+ }
+
+ return effect == TextureStackEffect.Blur ? 1 : 0;
+ }
+ }
+
+
+ public override void Draw( TextureStackCompositorContext context )
+ {
+ var target = context.target;
+
+ if ( target == null )
+ {
+ return;
+ }
+
+ var rect = context.layerRect;
+
+ if ( rect.Size.X <= 0 || rect.Size.Y <= 0 )
+ {
+ return;
+ }
+
+ // Tint and intensity are applied once, on the chain's last pass only -
+ // through MODULATE, so they cost no shader branch. Alpha is left alone;
+ // the layer's own opacity is the composite pass's job.
+ var modulate = new Color(
+ tint.R * intensity, tint.G * intensity, tint.B * intensity, tint.A
+ );
+
+ var radiusPixels = Mathf.Max( 0, radius ) * context.size.X;
+ var blurs = radiusPixels > 0 && samples > 1;
+
+ switch ( effect )
+ {
+ case TextureStackEffect.DirectionalBlur:
+ {
+ DrawDirectional( context, rect, target, radiusPixels, blurs, modulate );
+ }
+ break;
+
+ case TextureStackEffect.Glow:
+ {
+ DrawGlow( context, rect, target, radiusPixels, blurs, modulate );
+ }
+ break;
+
+ case TextureStackEffect.Copy:
+ {
+ BeginPass( context, TextureStackEffectPass.Copy );
+ SetSourceIsTarget( context, rect );
+
+ Blit( context, context.scratch, target, rect.Size, modulate );
+ }
+ break;
+
+ default:
+ {
+ DrawBlur( context, rect, target, radiusPixels, blurs, modulate );
+ }
+ break;
+ }
+ }
+
+ /** Separable: horizontal into a rect-sized intermediate, vertical out of it
+ into the scratch buffer. Two passes beat one 2D kernel as soon as the
+ radius is more than a couple of pixels - 2n taps instead of n squared.
+ */
+ void DrawBlur( TextureStackCompositorContext context, Rect2I rect, DrawableTexture2D target,
+ float radiusPixels, bool blurs, Color modulate )
+ {
+ if ( ! blurs )
+ {
+ BeginPass( context, TextureStackEffectPass.Copy );
+ SetSourceIsTarget( context, rect );
+
+ Blit( context, context.scratch, target, rect.Size, modulate );
+
+ return;
+ }
+
+ var pool = context.compositor.pool;
+ var horizontal = pool.Rent( rect.Size.X, rect.Size.Y, context.format, Transparent );
+
+ try
+ {
+ BeginPass( context, TextureStackEffectPass.Blur );
+ SetSourceIsTarget( context, rect );
+ SetBlur( context, new Vector2( 1, 0 ), radiusPixels );
+
+ Blit( context, horizontal, target, rect.Size, White );
+
+ BeginPass( context, TextureStackEffectPass.Blur );
+ context.compositor.effectMaterial.SetSourceIsRectSized();
+ SetBlur( context, new Vector2( 0, 1 ), radiusPixels );
+
+ Blit( context, context.scratch, horizontal, rect.Size, modulate );
+ }
+ finally
+ {
+ pool.Return( horizontal );
+ }
+ }
+
+ void DrawDirectional( TextureStackCompositorContext context, Rect2I rect,
+ DrawableTexture2D target, float radiusPixels, bool blurs, Color modulate )
+ {
+ BeginPass( context,
+ blurs ? TextureStackEffectPass.Blur : TextureStackEffectPass.Copy
+ );
+
+ SetSourceIsTarget( context, rect );
+
+ if ( blurs )
+ {
+ var radians = Mathf.DegToRad( directionDegrees );
+
+ SetBlur( context, new Vector2( Mathf.Cos( radians ), Mathf.Sin( radians ) ), radiusPixels );
+ }
+
+ Blit( context, context.scratch, target, rect.Size, modulate );
+ }
+
+ /** Bright pass first, then the same separable blur. The bright pass has to
+ come first and on its own buffer: blurring before thresholding would
+ smear everything and then threshold the smear, which is a different
+ effect and the wrong one.
+ */
+ void DrawGlow( TextureStackCompositorContext context, Rect2I rect, DrawableTexture2D target,
+ float radiusPixels, bool blurs, Color modulate )
+ {
+ var pool = context.compositor.pool;
+ var bright = pool.Rent( rect.Size.X, rect.Size.Y, context.format, Transparent );
+
+ try
+ {
+ var material = context.compositor.effectMaterial;
+
+ BeginPass( context, TextureStackEffectPass.BrightPass );
+ SetSourceIsTarget( context, rect );
+
+ material.brightThreshold.Set( glowThreshold );
+ material.brightKnee.Set( Mathf.Max( 0.001f, glowKnee ) );
+
+ Blit( context, bright, target, rect.Size, White );
+
+ if ( ! blurs )
+ {
+ BeginPass( context, TextureStackEffectPass.Copy );
+ material.SetSourceIsRectSized();
+
+ Blit( context, context.scratch, bright, rect.Size, modulate );
+
+ return;
+ }
+
+ var horizontal = pool.Rent( rect.Size.X, rect.Size.Y, context.format, Transparent );
+
+ try
+ {
+ BeginPass( context, TextureStackEffectPass.Blur );
+ material.SetSourceIsRectSized();
+ SetBlur( context, new Vector2( 1, 0 ), radiusPixels );
+
+ Blit( context, horizontal, bright, rect.Size, White );
+
+ BeginPass( context, TextureStackEffectPass.Blur );
+ material.SetSourceIsRectSized();
+ SetBlur( context, new Vector2( 0, 1 ), radiusPixels );
+
+ Blit( context, context.scratch, horizontal, rect.Size, modulate );
+ }
+ finally
+ {
+ pool.Return( horizontal );
+ }
+ }
+ finally
+ {
+ pool.Return( bright );
+ }
+ }
+
+
+ void BeginPass( TextureStackCompositorContext context, TextureStackEffectPass pass )
+ {
+ var material = context.compositor.effectMaterial;
+
+ material.Reset();
+ material.passMode.Set( (int) pass );
+ material.sourceRepeat.Set( tileable );
+ }
+
+ void SetSourceIsTarget( TextureStackCompositorContext context, Rect2I rect )
+ {
+ context.compositor.effectMaterial.SetSourceIsTarget( rect, context.sizeF );
+ }
+
+ void SetBlur( TextureStackCompositorContext context, Vector2 direction, float radiusPixels )
+ {
+ var material = context.compositor.effectMaterial;
+
+ material.blurDirection.Set( direction );
+ material.blurRadiusPixels.Set( radiusPixels );
+ material.blurSamples.Set( Mathf.Clamp( samples, 1, 65 ) );
+ }
+
+ void Blit( TextureStackCompositorContext context, DrawableTexture2D destination,
+ Texture2D source, Vector2I size, Color modulate )
+ {
+ var sources = new Godot.Collections.Array(){ source };
+
+ destination.BlitRectMulti(
+ new Rect2I( 0, 0, size.X, size.Y ), sources,
+ TextureStackCompositor.noExtraTargets, modulate, 0,
+ context.compositor.effectMaterial
+ );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackEffectLayer.cs.uid b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackEffectLayer.cs.uid
new file mode 100644
index 0000000..6e7028c
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackEffectLayer.cs.uid
@@ -0,0 +1 @@
+uid://djjpt5nrk5gb1
diff --git a/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackStackLayer.cs b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackStackLayer.cs
new file mode 100644
index 0000000..49b8053
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackStackLayer.cs
@@ -0,0 +1,245 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** A layer whose content is another TextureStack, composited inline.
+
+ Two ways to nest exist, and they are for different things:
+
+ - Put a TextureStackTexture2D into a TextureStackTextureLayer's texture
+ slot. The inner stack is a resource of its own with
+ its own size and format, composited once and reusable by several
+ layers and several stacks. Ordering is handled by the compositor's
+ dependency pass.
+
+ - This layer. The inner stack has no texture resource at
+ all - it is composited into a throwaway pooled buffer at
+ exactly this layer's placement rect, every time the outer stack
+ composites. Nothing to author, nothing to keep in sync, no extra file,
+ and it follows the outer resolution automatically.
+
+ Use this one for grouping - a set of layers that share a mask, a blend
+ mode or an opacity, the way a Photoshop group does. Use the other when the
+ same composited texture is needed in more than one place.
+
+ The inner stack gets its own compositor instance rather than borrowing the
+ outer one, because the content and composite materials are shared per
+ compositor and the inner composite runs in the middle of the outer one.
+ Separate instances mean no ordering assumptions about when a blit reads a
+ material's uniforms.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureStackStackLayer:TextureStackLayer
+ {
+ // A fresh group, because the base class leaves the inspector inside its
+ // own "Placement" group.
+ [ExportGroup( "Content" )]
+
+ TextureStack _stack;
+
+ /** Properties rather than fields, so an edit here emits changed and the
+ consuming texture recomposites - see TextureStackChangeSource.
+ */
+ [Export]
+ public TextureStack stack
+ {
+ get => _stack;
+ set { _stack = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Color _clearColor = new Color( 0, 0, 0, 0 );
+
+ /** Where the group starts before its own layers draw. Transparent so the
+ group composites onto the outer accumulation rather than punching a hole
+ in it; a normal-map group would want ( 0.5, 0.5, 1, 1 ).
+ */
+ [Export]
+ public Color clearColor
+ {
+ get => _clearColor;
+ set { _clearColor = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _useOwnFormat = false;
+
+ /** On: the inner buffer uses innerFormat. Off: it matches the outer
+ target's format, which is almost always what you want - a group in a
+ different space than the texture it composites into needs a conversion
+ nobody asked for.
+ */
+ [Export]
+ public bool useOwnFormat
+ {
+ get => _useOwnFormat;
+ set { _useOwnFormat = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureStackFormat _innerFormat = TextureStackFormat.Rgba8;
+
+ [Export]
+ public TextureStackFormat innerFormat
+ {
+ get => _innerFormat;
+ set { _innerFormat = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureSettings _textureSettings;
+
+ [Export]
+ public TextureSettings textureSettings
+ {
+ get => _textureSettings;
+ set { _textureSettings = value; TextureStackNotify.Changed( this ); }
+ }
+
+
+ TextureStackCompositor _innerCompositor;
+ TextureStackCompositorContext _innerContext;
+
+ /** Guards the one way this layer can hang the editor: a group whose inner
+ stack contains the group. Cheap enough to check on both entry points.
+ */
+ bool _busy = false;
+
+
+ public override void Draw( TextureStackCompositorContext context )
+ {
+ if ( stack == null || stack.numLayers == 0 )
+ {
+ // Scratch was rented transparent, so an empty group contributes nothing.
+ return;
+ }
+
+ if ( _busy )
+ {
+ RJLog.Error( "TextureStackStackLayer:", layerName,
+ "is nested inside its own stack - skipping to avoid infinite recursion."
+ );
+
+ return;
+ }
+
+ _busy = true;
+
+ try
+ {
+ DrawInner( context );
+ }
+ finally
+ {
+ _busy = false;
+ }
+ }
+
+ void DrawInner( TextureStackCompositorContext context )
+ {
+
+ // The *unclipped* placement size, not the visible rect: the group's own
+ // layers place themselves inside the whole placement, so compositing at
+ // the clipped size would squeeze a group hanging over the output's edge
+ // into the part that fits. The uvRect slice is taken on the way out.
+ var size = context.placement.unclippedRect.Size;
+
+ if ( size.X <= 0 || size.Y <= 0 )
+ {
+ return;
+ }
+
+ var format = useOwnFormat ? innerFormat : context.format;
+ var pool = context.compositor.pool;
+
+ if ( _innerCompositor == null )
+ {
+ _innerCompositor = new TextureStackCompositor( pool );
+ }
+
+ if ( _innerContext == null )
+ {
+ _innerContext = new TextureStackCompositorContext();
+ }
+
+ var inner = pool.Rent( size.X, size.Y, format, clearColor );
+
+ try
+ {
+ _innerContext.stack = stack;
+ _innerContext.size = size;
+ _innerContext.format = format;
+ _innerContext.clearColor = clearColor;
+
+ // No mipmaps on an intermediate: it is sampled 1:1 by the blit below.
+ _innerContext.mipmaps = false;
+
+ // Always a full inner composite. An inner checkpoint would be keyed to
+ // a buffer the pool hands back after every composite, so it could never
+ // be reused.
+ _innerContext.fromLayer = 0;
+ _innerContext.incrementalRedraw = false;
+
+ // The pool already Setup() the buffer at this size with this clear
+ // colour, so the inner compositor must not allocate it a second time.
+ _innerContext.targetPrepared = true;
+
+ _innerContext.targets.Clear();
+ _innerContext.targets.Add( inner );
+
+ _innerCompositor.Composite( _innerContext );
+
+ // Into the scratch buffer with no transform of its own, so the group
+ // behaves exactly like a texture layer from here on. placementUVRect is
+ // identity unless the output's edge clipped this group, in which case it
+ // selects the visible slice of the buffer composited above.
+ var material = context.compositor.contentMaterial;
+
+ material.Reset();
+ material.contentHasTexture.Set( true );
+ material.SetTextureSettings( textureSettings );
+ material.placementUVRect.Set( context.placement.uvRect );
+
+ var sources = new Godot.Collections.Array(){ inner };
+
+ context.scratch.BlitRectMulti(
+ context.scratchRect, sources, TextureStackCompositor.noExtraTargets,
+ new Color( 1, 1, 1, 1 ), 0, material
+ );
+ }
+ finally
+ {
+ _innerContext.targets.Clear();
+ pool.Return( inner );
+ }
+ }
+
+
+ public override void CollectChangeSources( List into, int drawIndex )
+ {
+ base.CollectChangeSources( into, drawIndex );
+
+ TextureStackChangeSource.Add( into, _textureSettings, drawIndex );
+ TextureStackChangeSource.Add( into, _stack, drawIndex );
+
+ if ( _stack == null || _busy )
+ {
+ return;
+ }
+
+ _busy = true;
+
+ try
+ {
+ // The inner layers report *this* layer's draw index, not their own:
+ // the group composites as one layer of the outer stack, so that is
+ // where an incremental redraw has to start.
+ _stack.CollectChangeSources( into, drawIndex );
+ }
+ finally
+ {
+ _busy = false;
+ }
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackStackLayer.cs.uid b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackStackLayer.cs.uid
new file mode 100644
index 0000000..6853f9c
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackStackLayer.cs.uid
@@ -0,0 +1 @@
+uid://dfa7mpf2mi2hg
diff --git a/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackTextureLayer.cs b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackTextureLayer.cs
new file mode 100644
index 0000000..e4169b5
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackTextureLayer.cs
@@ -0,0 +1,162 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Blits a Texture2D as a layer.
+
+ Covers both of TextureCombiner's layers at once: with repeat on and a
+ scale below 1 it is the Tiler, with repeat off it is the plain Texture
+ layer.
+
+ texture takes any Texture2D, so a TextureStackTexture2D goes in directly
+ and this doubles as the nested-stack layer until session 3 adds proper
+ dependency ordering ( nesting it here works, but nothing guarantees the
+ inner stack has been composited first ).
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureStackTextureLayer:TextureStackLayer
+ {
+ // A fresh group, because the base class leaves the inspector inside its
+ // own "Placement" group.
+ [ExportGroup( "Content" )]
+
+ Texture2D _texture;
+
+ /** Properties rather than fields, so an edit here emits changed and the
+ consuming texture recomposites - see TextureStackChangeSource.
+ */
+ [Export]
+ public Texture2D texture
+ {
+ get => _texture;
+ set { _texture = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureSettings _textureSettings;
+
+ /** Sampling and colour space for the texture. Empty means Linear sampling
+ and no conversion - see TextureSettings.
+ */
+ [Export]
+ public TextureSettings textureSettings
+ {
+ get => _textureSettings;
+ set { _textureSettings = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Color _tint = new Color( 1, 1, 1, 1 );
+
+ /** Multiplied onto the sampled colour. Alpha here is a second opacity,
+ applied before blending rather than during it.
+ */
+ [Export]
+ public Color tint
+ {
+ get => _tint;
+ set { _tint = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportSubgroup( "UV Transform" )]
+
+ Vector2 _offset = Vector2.Zero;
+
+ [Export]
+ public Vector2 offset
+ {
+ get => _offset;
+ set { _offset = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Vector2 _scale = Vector2.One;
+
+ /** Above 1 magnifies the texture, below 1 shrinks it. With repeat off,
+ whatever falls outside 0-1 is transparent rather than edge-clamped.
+ */
+ [Export]
+ public Vector2 scale
+ {
+ get => _scale;
+ set { _scale = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _rotationDegrees = 0;
+
+ [Export]
+ public float rotationDegrees
+ {
+ get => _rotationDegrees;
+ set { _rotationDegrees = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _repeat = false;
+
+ [Export]
+ public bool repeat
+ {
+ get => _repeat;
+ set { _repeat = value; TextureStackNotify.Changed( this ); }
+ }
+
+
+ public override void Draw( TextureStackCompositorContext context )
+ {
+ if ( texture == null )
+ {
+ // Nothing drawn: the scratch buffer was rented transparent, so this
+ // layer simply contributes nothing.
+ return;
+ }
+
+ var material = context.compositor.contentMaterial;
+
+ material.Reset();
+ material.contentHasTexture.Set( true );
+ material.contentTint.Set( tint );
+ material.contentOffset.Set( offset );
+ material.contentScale.Set(
+ new Vector2(
+ Mathf.IsZeroApprox( scale.X ) ? 0.0001f : scale.X,
+ Mathf.IsZeroApprox( scale.Y ) ? 0.0001f : scale.Y
+ )
+ );
+
+ // A scattered stamp's own rotation adds to the layer's, so one layer can
+ // be angled *and* have per-stamp variation.
+ material.contentRotation.Set( Mathf.DegToRad( rotationDegrees ) + context.placement.rotation );
+ material.contentRepeat.Set( repeat );
+ material.SetTextureSettings( textureSettings );
+
+ // Non-identity only when the output's edge clipped this placement - then
+ // it tells the shader which slice of the placement this blit is.
+ material.placementUVRect.Set( context.placement.uvRect );
+
+ var sources = new Godot.Collections.Array(){ texture };
+
+ // BlitRectMulti rather than BlitRect, because the explicit sources array
+ // is the documented way to fill hint_blit_source0.
+ context.scratch.BlitRectMulti(
+ context.scratchRect, sources, TextureStackCompositor.noExtraTargets,
+ new Color( 1, 1, 1, 1 ), 0, material
+ );
+ }
+
+
+ public override void CollectChangeSources(
+ System.Collections.Generic.List into, int drawIndex
+ )
+ {
+ base.CollectChangeSources( into, drawIndex );
+
+ TextureStackChangeSource.Add( into, _textureSettings, drawIndex );
+
+ // A built-in texture - a GradientTexture2D, a NoiseTexture2D - does emit
+ // changed when it is edited, so editing one in place recomposites. A
+ // TextureStackTexture2D is deliberately skipped: it schedules its own
+ // dependents instead.
+ AddTextureChangeSource( into, _texture, drawIndex );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackTextureLayer.cs.uid b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackTextureLayer.cs.uid
new file mode 100644
index 0000000..3c860bf
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Layers/TextureStackTextureLayer.cs.uid
@@ -0,0 +1 @@
+uid://qbwnefncnum1
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc
new file mode 100644
index 0000000..569ba86
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc
@@ -0,0 +1,325 @@
+// #include "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc"
+//
+// Shared helpers for the TextureStack blit shaders: UV transforms, mask
+// sampling and the blend-mode dispatch.
+//
+// Blit-shader conventions this file assumes (all verified by the session 1
+// spike, see workspace/notes/Texture-Stack-Compositor):
+//
+// - UV is local to the blit rect. To sample a full-size buffer, use
+// FRAGCOORD.xy / target_size instead.
+// - Rect2I's origin is the image top-left and UV.y runs with image rows, so
+// nothing here flips Y.
+// - hint_blit_sourceN cannot be combined with hint_default_white/black, and
+// an unbound source arrives as *black*. Optional sources therefore need an
+// explicit "enabled" bool uniform, never a hint default.
+// - Hardware blending also applies to alpha, so these shaders run with
+// render_mode blend_disabled and write alpha deliberately.
+// - Every blit source is declared `filter_linear, repeat_enable`. Nearest
+// sampling and clamp-to-edge are both emulated in UV space instead, by
+// textureStack_samplingUV below, because a sampler's filter and repeat are
+// compile-time state and TextureSettings is runtime data.
+
+#include "res://addons/rokojori_action_library/Runtime/Shading/Library/Colors.gdshaderinc"
+
+
+/* SAMPLING
+ ----------------------------------------------------------------------------
+ Mirrors TextureSettings ( Procedural/Textures/TextureSettings.cs ):
+ sampling 0 = Nearest, 1 = Linear; conversion 0 = None, 1 = SRGBToLinear,
+ 2 = LinearToSRGB. Do not renumber on either side.
+*/
+
+/** Bends UV so that a single `filter_linear` tap reproduces the requested
+ sampling exactly, which is what makes the filter runtime-switchable from a
+ resource at all.
+
+ Nearest: land dead on a texel centre, where a linear tap returns that one
+ texel with no blend - exact, not an approximation, and one tap rather than
+ the four a hand-written bilinear would cost.
+
+ Linear on a non-repeating source: inset by half a texel. The samplers are
+ declared repeat_enable so that a *tiling* source filters across its wrap
+ and stays seamless; without this inset a non-tiling source would blend its
+ opposite edge into its outermost half texel, which on a gradient reads as a
+ hard line at the border.
+*/
+vec2 textureStack_samplingUV( sampler2D source, vec2 uv, int sampling, bool repeatUV )
+{
+ vec2 size = vec2( textureSize( source, 0 ) );
+
+ if ( sampling == 0 )
+ {
+ // min() keeps uv == 1.0 from landing on texel `size`, which would wrap.
+ return ( min( floor( uv * size ), size - vec2( 1.0 ) ) + vec2( 0.5 ) ) / size;
+ }
+
+ if ( ! repeatUV )
+ {
+ vec2 halfTexel = vec2( 0.5 ) / size;
+ return clamp( uv, halfTexel, vec2( 1.0 ) - halfTexel );
+ }
+
+ return uv;
+}
+
+/** Counterpart to SRGBtoLINEAR in Colors.gdshaderinc, which has no inverse. */
+vec3 textureStack_linearToSRGB( vec3 linearColor )
+{
+ vec3 c = max( linearColor, vec3( 0.0 ) );
+
+ return mix(
+ 1.055 * pow( c, vec3( 1.0 / 2.4 ) ) - vec3( 0.055 ),
+ c * 12.92,
+ lessThan( c, vec3( 0.0031308 ) )
+ );
+}
+
+vec4 textureStack_convertColorSpace( vec4 color, int conversion )
+{
+ if ( conversion == 1 )
+ {
+ return vec4( SRGBtoLINEAR( color.rgb ), color.a );
+ }
+
+ if ( conversion == 2 )
+ {
+ return vec4( textureStack_linearToSRGB( color.rgb ), color.a );
+ }
+
+ return color;
+}
+
+/** The one place any TextureStack shader reads a texture. */
+vec4 textureStack_sample( sampler2D source, vec2 uv, int sampling, bool repeatUV,
+ int conversion
+)
+{
+ vec4 color = texture( source, textureStack_samplingUV( source, uv, sampling, repeatUV ) );
+
+ return textureStack_convertColorSpace( color, conversion );
+}
+
+
+/** Maps a blit's rect-local UV back into the whole placement's own 0-1 space.
+
+ The two differ whenever a placement is clipped by the output's edge - a
+ decal half off the left side, or a stamp that wraps around it. The blit then
+ covers only part of the placement, but the layer's content and its masks
+ still have to line up with the placement as a whole, or the full texture
+ gets squeezed into the visible sliver.
+
+ rect = offset.xy, scale.zw, computed by TextureStackCompositor from the
+ clipped rect against the unclipped one.
+*/
+vec2 textureStack_placementUV( vec2 uv, vec4 rect )
+{
+ return rect.xy + uv * rect.zw;
+}
+
+
+/** Applies offset / scale / rotation around the centre of the unit square.
+
+ scale > 1 magnifies the sampled texture, offset shifts it in UV direction,
+ rotation is in radians. When repeatUV is off and the transformed UV leaves
+ 0-1, 'inside' comes back false and the caller should treat the sample as
+ fully transparent rather than clamping to the edge pixel.
+*/
+vec2 textureStack_transformUV( vec2 uv, vec2 offset, vec2 scale, float rotation,
+ bool repeatUV, out bool inside
+)
+{
+ vec2 t = uv - vec2( 0.5 );
+
+ float c = cos( rotation );
+ float s = sin( rotation );
+
+ t = vec2( t.x * c - t.y * s, t.x * s + t.y * c );
+ t = t / scale;
+ t = t + vec2( 0.5 ) - offset;
+
+ inside = true;
+
+ if ( repeatUV )
+ {
+ t = fract( t );
+ }
+ else if ( t.x < 0.0 || t.x > 1.0 || t.y < 0.0 || t.y > 1.0 )
+ {
+ inside = false;
+ }
+
+ return t;
+}
+
+/** channel: 0 R, 1 G, 2 B, 3 A, anything else luminance.
+ Must stay in sync with TextureStackMaskChannel.
+*/
+float textureStack_channel( vec4 color, int channel )
+{
+ if ( channel == 0 )
+ {
+ return color.r;
+ }
+
+ if ( channel == 1 )
+ {
+ return color.g;
+ }
+
+ if ( channel == 2 )
+ {
+ return color.b;
+ }
+
+ if ( channel == 3 )
+ {
+ return color.a;
+ }
+
+ return dot( color.rgb, vec3( 0.2126, 0.7152, 0.0722 ) );
+}
+
+/** One mask's contribution to a layer's opacity.
+
+ transform = offset.xy, scale.xy ( TextureStackMask.packedTransform )
+ parameters = rotation, repeat, levelsMin, levelsMax
+ ( TextureStackMask.packedParameters )
+
+ uv is placement-local, so a mask follows its layer's placement rect and a
+ stamp carries its mask with it.
+
+ sampling / conversion come from the mask's TextureSettings.
+*/
+float textureStack_sampleMask( sampler2D maskTexture, vec2 uv, vec4 transform,
+ vec4 parameters, int channel, bool invertMask, int sampling, int conversion
+)
+{
+ bool inside = true;
+
+ bool repeatUV = parameters.y > 0.5;
+
+ vec2 maskUV = textureStack_transformUV(
+ uv, transform.xy, transform.zw, parameters.x, repeatUV, inside
+ );
+
+ float value = inside
+ ? textureStack_channel(
+ textureStack_sample( maskTexture, maskUV, sampling, repeatUV, conversion ), channel
+ )
+ : 0.0;
+
+ value = clamp( ( value - parameters.z ) / ( parameters.w - parameters.z ), 0.0, 1.0 );
+
+ return invertMask ? 1.0 - value : value;
+}
+
+
+/* BLEND MODES
+ ----------------------------------------------------------------------------
+ Most of these come straight from Shading/Library/Colors.gdshaderinc, which
+ already carries the whole blendMode_* family with correct alpha compositing.
+ Only the two it is missing are defined here.
+
+ The same formulas also exist on the CPU in
+ Procedural/Textures/TextureCombiner ( ColorX.Blend* ) and as compute
+ processors in Rendering/RenderGraph. Three copies is a deliberate, named
+ cost - the shader types differ - so a fix to one belongs in the others.
+*/
+
+vec3 textureStack_blendFunction_sub( vec3 bottom, vec3 top )
+{
+ return bottom - top;
+}
+
+vec4 textureStack_blendSub( vec4 bottom, vec4 top )
+{
+ if ( bottom.a <= 0.0 )
+ {
+ return top;
+ }
+
+ return blendMode_alpha( bottom, vec4( textureStack_blendFunction_sub( bottom.rgb, top.rgb ), top.a ) );
+}
+
+/** The real W3C/Photoshop soft light.
+
+ Note ColorX.BlendSoftLight on the CPU side is currently a copy of
+ BlendHardLight, so the two systems will *not* match on this one mode until
+ that is fixed. Called out rather than reproduced.
+*/
+vec3 textureStack_blendFunction_softLight( vec3 bottom, vec3 top )
+{
+ vec3 d = mix(
+ ( ( 16.0 * bottom - 12.0 ) * bottom + 4.0 ) * bottom,
+ sqrt( bottom ),
+ step( vec3( 0.25 ), bottom )
+ );
+
+ return mix(
+ bottom - ( 1.0 - 2.0 * top ) * bottom * ( 1.0 - bottom ),
+ bottom + ( 2.0 * top - 1.0 ) * ( d - bottom ),
+ step( vec3( 0.5 ), top )
+ );
+}
+
+vec4 textureStack_blendSoftLight( vec4 bottom, vec4 top )
+{
+ if ( bottom.a <= 0.0 )
+ {
+ return top;
+ }
+
+ return blendMode_alpha( bottom, vec4( textureStack_blendFunction_softLight( bottom.rgb, top.rgb ), top.a ) );
+}
+
+/** mode must match the int values of TextureStackBlendMode. */
+vec4 textureStack_blend( int mode, vec4 bottom, vec4 top )
+{
+ if ( mode == 1 )
+ {
+ return blendMode_add( bottom, top );
+ }
+
+ if ( mode == 2 )
+ {
+ return textureStack_blendSub( bottom, top );
+ }
+
+ if ( mode == 3 )
+ {
+ return blendMode_multiply( bottom, top );
+ }
+
+ if ( mode == 4 )
+ {
+ return blendMode_screen( bottom, top );
+ }
+
+ if ( mode == 5 )
+ {
+ return blendMode_overlay( bottom, top );
+ }
+
+ if ( mode == 6 )
+ {
+ return blendMode_hardLight( bottom, top );
+ }
+
+ if ( mode == 7 )
+ {
+ return textureStack_blendSoftLight( bottom, top );
+ }
+
+ if ( mode == 8 )
+ {
+ return blendMode_colorHSL( bottom, top );
+ }
+
+ if ( mode == 9 )
+ {
+ return blendMode_hue( bottom, top );
+ }
+
+ return blendMode_alpha( bottom, top );
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc.uid
new file mode 100644
index 0000000..e9698f4
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://rn8ao07hwquw
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader
new file mode 100644
index 0000000..151aee1
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader
@@ -0,0 +1,110 @@
+// Composite pass of a TextureStack layer.
+//
+// Takes the layer's content out of the scratch buffer, the accumulation so far
+// as a second source, and up to two masks, and writes the blended result into
+// the ping-pong buffer. The compositor copies that back over the accumulation.
+//
+// Buffer geometry: scratch and ping-pong are both exactly the layer's rect in
+// size, and this pass runs over their whole extent, so content and the masks
+// are sampled with plain rect-local UV. The accumulation is the full output,
+// so it needs ( FRAGCOORD + rect_offset ) / target_size instead.
+//
+// This is the path for every blend mode the hardware cannot do, and for every
+// layer that has a mask or clips to the layer below - all three need the
+// accumulation as a *readable source*, because a blit shader cannot read its
+// own destination. Spike gate G7 confirmed a DrawableTexture2D is accepted as
+// a plain Texture2D source, which is what makes this work.
+//
+// Source budget is exactly 4 (gate G4): content, accumulation, mask0, mask1.
+// A third mask needs a second pass, which is not built.
+
+shader_type texture_blit;
+render_mode blend_disabled;
+
+#include "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc"
+
+// filter_linear / repeat_enable on all four, with Nearest and clamp-to-edge
+// emulated in UV space - see the note at the top of the include. content and
+// accumulation are read 1:1 at their own resolution, so their filter never
+// actually kicks in; the masks are the slots where it matters.
+uniform sampler2D content : hint_blit_source0, filter_linear, repeat_enable;
+uniform sampler2D accumulation : hint_blit_source1, filter_linear, repeat_enable;
+uniform sampler2D mask0 : hint_blit_source2, filter_linear, repeat_enable;
+uniform sampler2D mask1 : hint_blit_source3, filter_linear, repeat_enable;
+
+// The accumulation is the full output while this pass runs over one layer rect,
+// so it is sampled with the target-global uv instead of UV.
+uniform vec2 target_size = vec2( 1.0, 1.0 );
+uniform vec2 rect_offset = vec2( 0.0, 0.0 );
+
+// Which slice of the placement this blit covers, for a placement the output's
+// edge clipped. The masks follow the whole placement, not the visible slice.
+uniform vec4 placement_uv_rect = vec4( 0.0, 0.0, 1.0, 1.0 );
+
+// Must match the int values of TextureStackBlendMode.
+uniform int blend_mode = 0;
+
+uniform float opacity : hint_range( 0.0, 1.0 ) = 1.0;
+
+// Photoshop-style clip: multiply by the alpha already underneath.
+uniform bool clip_to_below = false;
+
+uniform bool mask0_enabled = false;
+uniform vec4 mask0_transform = vec4( 0.0, 0.0, 1.0, 1.0 );
+uniform vec4 mask0_parameters = vec4( 0.0, 0.0, 0.0, 1.0 );
+uniform int mask0_channel = 0;
+uniform bool mask0_invert = false;
+uniform int mask0_sampling = 1;
+uniform int mask0_color_space = 0;
+
+uniform bool mask1_enabled = false;
+uniform vec4 mask1_transform = vec4( 0.0, 0.0, 1.0, 1.0 );
+uniform vec4 mask1_parameters = vec4( 0.0, 0.0, 0.0, 1.0 );
+uniform int mask1_channel = 0;
+uniform bool mask1_invert = false;
+uniform int mask1_sampling = 1;
+uniform int mask1_color_space = 0;
+
+void blit()
+{
+ vec2 globalUV = ( FRAGCOORD.xy + rect_offset ) / target_size;
+
+ vec2 placementUV = textureStack_placementUV( UV, placement_uv_rect );
+
+ // content and accumulation are sampled 1:1 - the scratch buffer is exactly
+ // this blit's size, and globalUV hits whole output texels - so neither needs
+ // the TextureSettings path.
+ vec4 top = texture( content, UV );
+ vec4 bottom = texture( accumulation, globalUV );
+
+ float amount = opacity;
+
+ if ( mask0_enabled )
+ {
+ amount *= textureStack_sampleMask(
+ mask0, placementUV, mask0_transform, mask0_parameters, mask0_channel, mask0_invert,
+ mask0_sampling, mask0_color_space
+ );
+ }
+
+ if ( mask1_enabled )
+ {
+ amount *= textureStack_sampleMask(
+ mask1, placementUV, mask1_transform, mask1_parameters, mask1_channel, mask1_invert,
+ mask1_sampling, mask1_color_space
+ );
+ }
+
+ if ( clip_to_below )
+ {
+ amount *= bottom.a;
+ }
+
+ // Folding opacity and the masks into the layer's alpha before blending gives
+ // the same result as TextureCombiner's "blend at full strength, then alpha-
+ // composite the result with opacity * mask", because every blendMode_* here
+ // composites its formula over the bottom using top.a.
+ top.a *= amount;
+
+ COLOR0 = textureStack_blend( blend_mode, bottom, top );
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader.uid
new file mode 100644
index 0000000..0332982
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader.uid
@@ -0,0 +1 @@
+uid://0f1jqpr7f0xx
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackCompositeMaterial.cs b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackCompositeMaterial.cs
new file mode 100644
index 0000000..264dad8
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackCompositeMaterial.cs
@@ -0,0 +1,152 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ public class TextureStackCompositeShader
+ {
+ public static readonly CachedResource shader = new CachedResource(
+ "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackComposite.gdshader"
+ );
+
+ public static readonly Vector2PropertyName targetSize = Vector2PropertyName.Create( "target_size" );
+ public static readonly Vector2PropertyName rectOffset = Vector2PropertyName.Create( "rect_offset" );
+ public static readonly Vector4PropertyName placementUVRect = Vector4PropertyName.Create( "placement_uv_rect" );
+ public static readonly IntPropertyName blendMode = IntPropertyName.Create( "blend_mode" );
+ public static readonly FloatPropertyName opacity = FloatPropertyName.Create( "opacity" );
+ public static readonly BoolPropertyName clipToBelow = BoolPropertyName.Create( "clip_to_below" );
+
+ public static readonly BoolPropertyName mask0Enabled = BoolPropertyName.Create( "mask0_enabled" );
+ public static readonly Vector4PropertyName mask0Transform = Vector4PropertyName.Create( "mask0_transform" );
+ public static readonly Vector4PropertyName mask0Parameters = Vector4PropertyName.Create( "mask0_parameters" );
+ public static readonly IntPropertyName mask0Channel = IntPropertyName.Create( "mask0_channel" );
+ public static readonly BoolPropertyName mask0Invert = BoolPropertyName.Create( "mask0_invert" );
+ public static readonly IntPropertyName mask0Sampling = IntPropertyName.Create( "mask0_sampling" );
+ public static readonly IntPropertyName mask0ColorSpace = IntPropertyName.Create( "mask0_color_space" );
+
+ public static readonly BoolPropertyName mask1Enabled = BoolPropertyName.Create( "mask1_enabled" );
+ public static readonly Vector4PropertyName mask1Transform = Vector4PropertyName.Create( "mask1_transform" );
+ public static readonly Vector4PropertyName mask1Parameters = Vector4PropertyName.Create( "mask1_parameters" );
+ public static readonly IntPropertyName mask1Channel = IntPropertyName.Create( "mask1_channel" );
+ public static readonly BoolPropertyName mask1Invert = BoolPropertyName.Create( "mask1_invert" );
+ public static readonly IntPropertyName mask1Sampling = IntPropertyName.Create( "mask1_sampling" );
+ public static readonly IntPropertyName mask1ColorSpace = IntPropertyName.Create( "mask1_color_space" );
+ }
+
+ /** Material for TextureStackComposite.gdshader - the generic composite pass.
+
+ One instance is kept by the compositor and re-set per layer. The two mask
+ slots are addressed by index through SetMask(), so the compositor's loop
+ does not care how many masks a layer has.
+ */
+ [Tool]
+ public partial class TextureStackCompositeMaterial:CustomMaterial
+ {
+ public readonly CustomMaterialProperty targetSize;
+ public readonly CustomMaterialProperty rectOffset;
+ public readonly CustomMaterialProperty placementUVRect;
+ public readonly CustomMaterialProperty blendMode;
+ public readonly CustomMaterialProperty opacity;
+ public readonly CustomMaterialProperty clipToBelow;
+
+ readonly CustomMaterialProperty[] _maskEnabled;
+ readonly CustomMaterialProperty[] _maskTransform;
+ readonly CustomMaterialProperty[] _maskParameters;
+ readonly CustomMaterialProperty[] _maskChannel;
+ readonly CustomMaterialProperty[] _maskInvert;
+ readonly CustomMaterialProperty[] _maskSampling;
+ readonly CustomMaterialProperty[] _maskColorSpace;
+
+ public int maskSlots => _maskEnabled.Length;
+
+ public TextureStackCompositeMaterial()
+ {
+ Shader = TextureStackCompositeShader.shader.Get();
+
+ targetSize = new CustomMaterialProperty( this, TextureStackCompositeShader.targetSize );
+ rectOffset = new CustomMaterialProperty( this, TextureStackCompositeShader.rectOffset );
+ placementUVRect = new CustomMaterialProperty( this, TextureStackCompositeShader.placementUVRect );
+ blendMode = new CustomMaterialProperty( this, TextureStackCompositeShader.blendMode );
+ opacity = new CustomMaterialProperty( this, TextureStackCompositeShader.opacity );
+ clipToBelow = new CustomMaterialProperty( this, TextureStackCompositeShader.clipToBelow );
+
+ _maskEnabled = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0Enabled ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1Enabled )
+ };
+
+ _maskTransform = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0Transform ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1Transform )
+ };
+
+ _maskParameters = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0Parameters ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1Parameters )
+ };
+
+ _maskChannel = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0Channel ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1Channel )
+ };
+
+ _maskInvert = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0Invert ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1Invert )
+ };
+
+ _maskSampling = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0Sampling ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1Sampling )
+ };
+
+ _maskColorSpace = new CustomMaterialProperty[]
+ {
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask0ColorSpace ),
+ new CustomMaterialProperty( this, TextureStackCompositeShader.mask1ColorSpace )
+ };
+ }
+
+ /** Disables both mask slots. Mandatory between layers - a stale enabled
+ flag would make the next layer sample an unbound (black) source and
+ vanish.
+ */
+ public void ResetMasks()
+ {
+ for ( var i = 0; i < _maskEnabled.Length; i++ )
+ {
+ _maskEnabled[ i ].Set( false );
+ }
+ }
+
+ public void SetMask( int slot, TextureStackMask mask )
+ {
+ if ( slot < 0 || slot >= _maskEnabled.Length )
+ {
+ return;
+ }
+
+ if ( mask == null || ! mask.isUsable )
+ {
+ _maskEnabled[ slot ].Set( false );
+ return;
+ }
+
+ _maskEnabled[ slot ].Set( true );
+ _maskTransform[ slot ].Set( mask.packedTransform );
+ _maskParameters[ slot ].Set( mask.packedParameters );
+ _maskChannel[ slot ].Set( (int) mask.channel );
+ _maskInvert[ slot ].Set( mask.invert );
+ _maskSampling[ slot ].Set( TextureSettings.SamplingValueOf( mask.textureSettings ) );
+ _maskColorSpace[ slot ].Set( TextureSettings.ColorSpaceConversionValueOf( mask.textureSettings ) );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackCompositeMaterial.cs.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackCompositeMaterial.cs.uid
new file mode 100644
index 0000000..7caa363
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackCompositeMaterial.cs.uid
@@ -0,0 +1 @@
+uid://d227a7ijp6nso
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader
new file mode 100644
index 0000000..94f1db9
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader
@@ -0,0 +1,60 @@
+// Content pass of a TextureStack layer.
+//
+// Writes a layer's own pixels into the compositor's scratch buffer with
+// blending disabled. Knows nothing about blend modes, opacity or masks - the
+// composite pass owns all of that. This is the "two-pass via scratch buffer"
+// half of the design, and it is why a Texture layer in Overlay mode needs no
+// shader of its own.
+
+shader_type texture_blit;
+render_mode blend_disabled;
+
+#include "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc"
+
+// filter_linear / repeat_enable, with Nearest and clamp-to-edge emulated in UV
+// space by textureStack_samplingUV - see the note at the top of the include.
+uniform sampler2D content : hint_blit_source0, filter_linear, repeat_enable;
+
+// An unbound blit source arrives as black, not white, so presence has to be an
+// explicit flag - hint_default_white cannot be combined with hint_blit_source0.
+uniform bool content_has_texture = false;
+
+uniform vec2 content_offset = vec2( 0.0, 0.0 );
+uniform vec2 content_scale = vec2( 1.0, 1.0 );
+uniform float content_rotation = 0.0;
+uniform bool content_repeat = false;
+
+// TextureSettings: 0 Nearest / 1 Linear, and 0 None / 1 sRGB-to-linear /
+// 2 linear-to-sRGB.
+uniform int content_sampling = 1;
+uniform int content_color_space = 0;
+
+// Which slice of the placement this blit actually covers, for a placement the
+// output's edge clipped. ( 0, 0, 1, 1 ) for everything else.
+uniform vec4 placement_uv_rect = vec4( 0.0, 0.0, 1.0, 1.0 );
+
+uniform vec4 content_tint : source_color = vec4( 1.0, 1.0, 1.0, 1.0 );
+
+void blit()
+{
+ if ( ! content_has_texture )
+ {
+ COLOR0 = content_tint;
+ }
+ else
+ {
+ bool inside = true;
+
+ vec2 placementUV = textureStack_placementUV( UV, placement_uv_rect );
+
+ vec2 uv = textureStack_transformUV(
+ placementUV, content_offset, content_scale, content_rotation, content_repeat, inside
+ );
+
+ vec4 color = inside
+ ? textureStack_sample( content, uv, content_sampling, content_repeat, content_color_space )
+ : vec4( 0.0, 0.0, 0.0, 0.0 );
+
+ COLOR0 = color * content_tint;
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader.uid
new file mode 100644
index 0000000..fc9b596
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader.uid
@@ -0,0 +1 @@
+uid://cg27yqywuaegx
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContentMaterial.cs b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContentMaterial.cs
new file mode 100644
index 0000000..d873e58
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContentMaterial.cs
@@ -0,0 +1,90 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ public class TextureStackContentShader
+ {
+ public static readonly CachedResource shader = new CachedResource(
+ "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContent.gdshader"
+ );
+
+ public static readonly BoolPropertyName contentHasTexture = BoolPropertyName.Create( "content_has_texture" );
+ public static readonly Vector2PropertyName contentOffset = Vector2PropertyName.Create( "content_offset" );
+ public static readonly Vector2PropertyName contentScale = Vector2PropertyName.Create( "content_scale" );
+ public static readonly FloatPropertyName contentRotation = FloatPropertyName.Create( "content_rotation" );
+ public static readonly BoolPropertyName contentRepeat = BoolPropertyName.Create( "content_repeat" );
+ public static readonly ColorPropertyName contentTint = ColorPropertyName.Create( "content_tint" );
+
+ public static readonly IntPropertyName contentSampling = IntPropertyName.Create( "content_sampling" );
+ public static readonly IntPropertyName contentColorSpace = IntPropertyName.Create( "content_color_space" );
+
+ public static readonly Vector4PropertyName placementUVRect = Vector4PropertyName.Create( "placement_uv_rect" );
+ }
+
+ /** Material for TextureStackContent.gdshader - the layer content pass.
+
+ The compositor keeps one of these and re-sets its uniforms per layer, so
+ nothing allocates per blit.
+ */
+ [Tool]
+ public partial class TextureStackContentMaterial:CustomMaterial
+ {
+ public readonly CustomMaterialProperty contentHasTexture;
+ public readonly CustomMaterialProperty contentOffset;
+ public readonly CustomMaterialProperty contentScale;
+ public readonly CustomMaterialProperty contentRotation;
+ public readonly CustomMaterialProperty contentRepeat;
+ public readonly CustomMaterialProperty contentTint;
+
+ public readonly CustomMaterialProperty contentSampling;
+ public readonly CustomMaterialProperty contentColorSpace;
+
+ public readonly CustomMaterialProperty placementUVRect;
+
+ public TextureStackContentMaterial()
+ {
+ Shader = TextureStackContentShader.shader.Get();
+
+ contentHasTexture = new CustomMaterialProperty( this, TextureStackContentShader.contentHasTexture );
+ contentOffset = new CustomMaterialProperty( this, TextureStackContentShader.contentOffset );
+ contentScale = new CustomMaterialProperty( this, TextureStackContentShader.contentScale );
+ contentRotation = new CustomMaterialProperty( this, TextureStackContentShader.contentRotation );
+ contentRepeat = new CustomMaterialProperty( this, TextureStackContentShader.contentRepeat );
+ contentTint = new CustomMaterialProperty( this, TextureStackContentShader.contentTint );
+
+ contentSampling = new CustomMaterialProperty( this, TextureStackContentShader.contentSampling );
+ contentColorSpace = new CustomMaterialProperty( this, TextureStackContentShader.contentColorSpace );
+
+ placementUVRect = new CustomMaterialProperty( this, TextureStackContentShader.placementUVRect );
+ }
+
+ /** Resets everything to the identity transform, so a layer that only sets
+ some of the uniforms does not inherit the previous layer's values.
+ */
+ public void Reset()
+ {
+ contentHasTexture.Set( false );
+ contentOffset.Set( Vector2.Zero );
+ contentScale.Set( Vector2.One );
+ contentRotation.Set( 0 );
+ contentRepeat.Set( false );
+ contentTint.Set( new Color( 1, 1, 1, 1 ) );
+
+ contentSampling.Set( (int) TextureSampling.Linear );
+ contentColorSpace.Set( (int) TextureColorSpaceConversion.None );
+
+ placementUVRect.Set( TextureStackPlacement.fullUVRect );
+ }
+
+ /** Copies a slot's TextureSettings in, falling back to Linear/None when
+ there is none - see TextureSettings.
+ */
+ public void SetTextureSettings( TextureSettings settings )
+ {
+ contentSampling.Set( TextureSettings.SamplingValueOf( settings ) );
+ contentColorSpace.Set( TextureSettings.ColorSpaceConversionValueOf( settings ) );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContentMaterial.cs.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContentMaterial.cs.uid
new file mode 100644
index 0000000..5a42cf1
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackContentMaterial.cs.uid
@@ -0,0 +1 @@
+uid://v68c1dde2d3g
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader
new file mode 100644
index 0000000..f3331dc
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader
@@ -0,0 +1,175 @@
+// Effect pass of a TextureStack adjustment layer.
+//
+// An effect layer does not generate content - it reads the accumulation so far
+// and transforms it. That reading is the same mechanism as the composite pass
+// (gate G7: a DrawableTexture2D is accepted as a plain Texture2D source), but it
+// happens in the *content* half of the two-pass model: the result lands in the
+// scratch buffer and is then blended by the normal composite pass, so an effect
+// layer still gets blend modes, opacity and masks for free.
+//
+// One shader, three pass kinds, because every effect here is a chain of them:
+//
+// Blur = blur( x ) + blur( y )
+// Directional blur = blur( direction )
+// Glow = bright pass + blur( x ) + blur( y ), blended with Add
+//
+// The source is either the full-size accumulation read over one layer rect, or a
+// rect-sized intermediate from a previous pass. source_uv_offset/scale maps this
+// blit's rect-local UV into whichever it is, so the passes are interchangeable
+// and nothing in here needs to know which one it got.
+//
+// Blur distance is in *output pixels* and converted with textureSize( source ),
+// so it comes out the same whether the source is the full output or a rect-sized
+// buffer - different texel sizes, same pixel distance.
+//
+// Known edge case, documented rather than solved: the second pass of a separable
+// blur can only read the rect-sized buffer the first pass wrote, so on a layer
+// that is *not* full-texture the blur clamps at the placement rect's border
+// instead of reaching into the output outside it. Full-texture effect layers -
+// the normal case - are unaffected, because there the rect is the output.
+
+shader_type texture_blit;
+render_mode blend_disabled;
+
+#include "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStack.gdshaderinc"
+
+uniform sampler2D source : hint_blit_source0, filter_linear, repeat_enable;
+
+// 0 = copy, 1 = blur along blur_direction, 2 = bright pass.
+uniform int pass_mode = 0;
+
+// This blit's UV -> the source's own UV. Identity for a rect-sized
+// intermediate; the layer rect's slice for the full-size accumulation.
+uniform vec2 source_uv_offset = vec2( 0.0, 0.0 );
+uniform vec2 source_uv_scale = vec2( 1.0, 1.0 );
+
+// On: the source wraps, so a blur stays seamless on a tileable texture. Off:
+// clamp to edge.
+uniform bool source_repeat = false;
+
+uniform vec2 blur_direction = vec2( 1.0, 0.0 );
+
+// Blur reach in output pixels, measured from the centre tap outwards.
+uniform float blur_radius_pixels = 0.0;
+
+// Taps per blur pass, centre included.
+uniform int blur_samples = 9;
+
+uniform float bright_threshold = 0.8;
+uniform float bright_knee = 0.1;
+
+// A loop bound has to be a constant; blur_samples breaks out of it early.
+const int TEXTURE_STACK_EFFECT_MAX_SAMPLES = 65;
+
+
+/** This blit's rect-local UV in the source's own UV space. */
+vec2 textureStack_effectSourceUV( vec2 uv )
+{
+ return source_uv_offset + uv * source_uv_scale;
+}
+
+/** Samples in the source's own UV space, so a blur offset can be added to the
+ result of textureStack_effectSourceUV without being scaled twice.
+*/
+vec4 textureStack_effectSampleAt( vec2 sourceUV )
+{
+ vec2 t = source_repeat ? fract( sourceUV ) : clamp( sourceUV, vec2( 0.0 ), vec2( 1.0 ) );
+
+ return texture( source, t );
+}
+
+/** Gaussian along blur_direction, in premultiplied alpha.
+
+ Premultiplying matters: averaging straight alpha pulls the colour of fully
+ transparent texels into the result, so blurring a decal's edge against
+ cleared background drags it towards whatever RGB those transparent pixels
+ happen to carry. Weighting by alpha and dividing it back out is the standard
+ fix and costs two multiplies.
+*/
+vec4 textureStack_effectBlur( vec2 uv )
+{
+ vec2 base = textureStack_effectSourceUV( uv );
+
+ vec2 texel = vec2( 1.0 ) / vec2( textureSize( source, 0 ) );
+
+ int halfSamples = max( 1, blur_samples / 2 );
+ int taps = halfSamples * 2 + 1;
+
+ // Puts the outermost tap at about 2 sigma, where a gaussian has effectively
+ // fallen off, so the tap count controls quality and the radius controls reach.
+ float sigma = max( 0.0001, float( halfSamples ) * 0.5 );
+
+ vec4 sum = vec4( 0.0 );
+ float weightSum = 0.0;
+
+ for ( int i = 0; i < TEXTURE_STACK_EFFECT_MAX_SAMPLES; i++ )
+ {
+ if ( i >= taps )
+ {
+ break;
+ }
+
+ float tap = float( i - halfSamples );
+
+ float weight = exp( -0.5 * tap * tap / ( sigma * sigma ) );
+
+ vec2 delta = blur_direction * texel
+ * ( blur_radius_pixels * tap / float( halfSamples ) );
+
+ vec4 tapColor = textureStack_effectSampleAt( base + delta );
+
+ sum += vec4( tapColor.rgb * tapColor.a, tapColor.a ) * weight;
+ weightSum += weight;
+ }
+
+ vec4 blurred = sum / max( weightSum, 0.0001 );
+
+ if ( blurred.a <= 0.0001 )
+ {
+ return vec4( 0.0, 0.0, 0.0, 0.0 );
+ }
+
+ return vec4( blurred.rgb / blurred.a, blurred.a );
+}
+
+/** Keeps only what is brighter than the threshold, with a soft knee so a glow
+ does not snap on at one luminance. Alpha is scaled by the same factor, so
+ the bright pass is transparent where nothing glows and the glow layer's Add
+ blend has nothing to add there.
+*/
+vec4 textureStack_effectBrightPass( vec2 uv )
+{
+ vec4 color = textureStack_effectSampleAt( textureStack_effectSourceUV( uv ) );
+
+ float luminance = dot( color.rgb, vec3( 0.2126, 0.7152, 0.0722 ) );
+
+ float contribution = clamp(
+ ( luminance - bright_threshold ) / max( bright_knee, 0.0001 ), 0.0, 1.0
+ );
+
+ return vec4( color.rgb * contribution, color.a * contribution );
+}
+
+
+void blit()
+{
+ vec4 result = vec4( 0.0, 0.0, 0.0, 0.0 );
+
+ if ( pass_mode == 1 )
+ {
+ result = textureStack_effectBlur( UV );
+ }
+ else if ( pass_mode == 2 )
+ {
+ result = textureStack_effectBrightPass( UV );
+ }
+ else
+ {
+ result = textureStack_effectSampleAt( textureStack_effectSourceUV( UV ) );
+ }
+
+ // Tint and intensity ride on MODULATE, which the compositor only sets on the
+ // last pass of a chain - otherwise a three-pass glow would apply them three
+ // times. Spike gate G6 confirmed MODULATE reaches a custom blit shader intact.
+ COLOR0 = result * MODULATE;
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader.uid
new file mode 100644
index 0000000..accdf1d
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader.uid
@@ -0,0 +1 @@
+uid://cn67f7ce24xs2
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffectMaterial.cs b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffectMaterial.cs
new file mode 100644
index 0000000..07152a1
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffectMaterial.cs
@@ -0,0 +1,117 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ public class TextureStackEffectShader
+ {
+ public static readonly CachedResource shader = new CachedResource(
+ "res://addons/rokojori_action_library/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffect.gdshader"
+ );
+
+ public static readonly IntPropertyName passMode = IntPropertyName.Create( "pass_mode" );
+
+ public static readonly Vector2PropertyName sourceUVOffset = Vector2PropertyName.Create( "source_uv_offset" );
+ public static readonly Vector2PropertyName sourceUVScale = Vector2PropertyName.Create( "source_uv_scale" );
+ public static readonly BoolPropertyName sourceRepeat = BoolPropertyName.Create( "source_repeat" );
+
+ public static readonly Vector2PropertyName blurDirection = Vector2PropertyName.Create( "blur_direction" );
+ public static readonly FloatPropertyName blurRadiusPixels = FloatPropertyName.Create( "blur_radius_pixels" );
+ public static readonly IntPropertyName blurSamples = IntPropertyName.Create( "blur_samples" );
+
+ public static readonly FloatPropertyName brightThreshold = FloatPropertyName.Create( "bright_threshold" );
+ public static readonly FloatPropertyName brightKnee = FloatPropertyName.Create( "bright_knee" );
+ }
+
+ /** Which of the three pass kinds TextureStackEffect.gdshader runs.
+
+ Int values are switched on in the shader - do not renumber.
+ */
+ public enum TextureStackEffectPass
+ {
+ Copy = 0,
+ Blur = 1,
+ BrightPass = 2
+ }
+
+ /** Material for TextureStackEffect.gdshader - the effect/adjustment layer
+ pass.
+
+ One instance per compositor, re-set per pass. An effect chain runs two or
+ three blits through the same material, which the session 2 live test
+ proved safe: re-setting uniforms between queued blits does not make the
+ earlier blits read the later values.
+ */
+ [Tool]
+ public partial class TextureStackEffectMaterial:CustomMaterial
+ {
+ public readonly CustomMaterialProperty passMode;
+
+ public readonly CustomMaterialProperty sourceUVOffset;
+ public readonly CustomMaterialProperty sourceUVScale;
+ public readonly CustomMaterialProperty sourceRepeat;
+
+ public readonly CustomMaterialProperty blurDirection;
+ public readonly CustomMaterialProperty blurRadiusPixels;
+ public readonly CustomMaterialProperty blurSamples;
+
+ public readonly CustomMaterialProperty brightThreshold;
+ public readonly CustomMaterialProperty brightKnee;
+
+ public TextureStackEffectMaterial()
+ {
+ Shader = TextureStackEffectShader.shader.Get();
+
+ passMode = new CustomMaterialProperty( this, TextureStackEffectShader.passMode );
+
+ sourceUVOffset = new CustomMaterialProperty( this, TextureStackEffectShader.sourceUVOffset );
+ sourceUVScale = new CustomMaterialProperty( this, TextureStackEffectShader.sourceUVScale );
+ sourceRepeat = new CustomMaterialProperty( this, TextureStackEffectShader.sourceRepeat );
+
+ blurDirection = new CustomMaterialProperty( this, TextureStackEffectShader.blurDirection );
+ blurRadiusPixels = new CustomMaterialProperty( this, TextureStackEffectShader.blurRadiusPixels );
+ blurSamples = new CustomMaterialProperty( this, TextureStackEffectShader.blurSamples );
+
+ brightThreshold = new CustomMaterialProperty( this, TextureStackEffectShader.brightThreshold );
+ brightKnee = new CustomMaterialProperty( this, TextureStackEffectShader.brightKnee );
+ }
+
+ /** Back to "copy the whole source", so a pass that only sets some of the
+ uniforms does not inherit the previous pass's.
+ */
+ public void Reset()
+ {
+ passMode.Set( (int) TextureStackEffectPass.Copy );
+
+ sourceUVOffset.Set( Vector2.Zero );
+ sourceUVScale.Set( Vector2.One );
+ sourceRepeat.Set( false );
+
+ blurDirection.Set( new Vector2( 1, 0 ) );
+ blurRadiusPixels.Set( 0 );
+ blurSamples.Set( 9 );
+
+ brightThreshold.Set( 0.8f );
+ brightKnee.Set( 0.1f );
+ }
+
+ /** Identity mapping - the source is a rect-sized buffer this blit covers
+ exactly.
+ */
+ public void SetSourceIsRectSized()
+ {
+ sourceUVOffset.Set( Vector2.Zero );
+ sourceUVScale.Set( Vector2.One );
+ }
+
+ /** The source is the full-size accumulation and this blit covers only
+ `rect` of it.
+ */
+ public void SetSourceIsTarget( Rect2I rect, Vector2 targetSize )
+ {
+ sourceUVOffset.Set( new Vector2( rect.Position.X / targetSize.X, rect.Position.Y / targetSize.Y ) );
+ sourceUVScale.Set( new Vector2( rect.Size.X / targetSize.X, rect.Size.Y / targetSize.Y ) );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffectMaterial.cs.uid b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffectMaterial.cs.uid
new file mode 100644
index 0000000..394b657
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/Shaders/TextureStackEffectMaterial.cs.uid
@@ -0,0 +1 @@
+uid://dh6akknoyhn7w
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStack.cs b/Runtime/Procedural/Textures/TextureStack/TextureStack.cs
new file mode 100644
index 0000000..b2b4da6
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStack.cs
@@ -0,0 +1,171 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** The layer list, and nothing else.
+
+ TextureStack is deliberately pure data: no size, no format, no buffers,
+ no cached output. All of that lives on the consuming texture
+ (TextureStackTexture2D), so one stack can be composited at 256 for a
+ preview and at 4096 for the final without the two fighting over a cache.
+
+ Layer order: layers[ 0 ] is the top layer - the one that
+ composites last, over everything else. Same reading as
+ TextureCombinerStack, Photoshop and every other layer list.
+
+ Session 2 had this the other way around, on the grounds that the
+ compositor is a draw-order system. That was the implementation's
+ convenience beating the authoring view, which is the wrong trade for a
+ resource edited by hand. The compositor still works in draw order
+ internally and goes through DrawLayerAt()/ToDrawIndex() to get there, so
+ the flip lives in exactly one place and the incremental-redraw indices -
+ which are draw indices - never had to change.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureStack:Resource
+ {
+ TextureStackLayer[] _layers = new TextureStackLayer[ 0 ];
+
+ /** Top first. Use DrawLayerAt() to walk them bottom-up.
+
+ The setter emits changed, which is what a consuming
+ TextureStackTexture2D listens to - Godot does not emit it for a plain
+ exported field on a scripted Resource, only for one with a setter that
+ does it by hand. See the change-hooks block in TextureStackTexture2D.
+ */
+ [Export]
+ public TextureStackLayer[] layers
+ {
+ get => _layers;
+ set { _layers = value; TextureStackNotify.Changed( this ); }
+ }
+
+ public int numLayers => _layers == null ? 0 : _layers.Length;
+
+
+ /* DRAW ORDER
+ --------------------------------------------------------------------------
+ The array is top-first, the compositor is bottom-first. These two are the
+ only place that knows it, and they are inverses of each other - the same
+ mapping in both directions, because reversing an index is its own inverse.
+ */
+
+ /** The layer at a draw index - 0 is the bottom layer, drawn first. */
+ public TextureStackLayer DrawLayerAt( int drawIndex )
+ {
+ if ( _layers == null || drawIndex < 0 || drawIndex >= _layers.Length )
+ {
+ return null;
+ }
+
+ return _layers[ _layers.Length - 1 - drawIndex ];
+ }
+
+ /** Array index -> draw index, for turning "this layer changed" into "redraw
+ from here up". Also draw index -> array index, being its own inverse.
+ */
+ public int ToDrawIndex( int arrayIndex )
+ {
+ return numLayers - 1 - arrayIndex;
+ }
+
+
+ /** True if any layer needs the accumulation handed in as a source, which
+ is what makes the compositor allocate a ping-pong buffer.
+ */
+ public bool needsPingPong
+ {
+ get
+ {
+ if ( _layers == null )
+ {
+ return false;
+ }
+
+ foreach ( var layer in _layers )
+ {
+ if ( layer == null || ! layer.visible )
+ {
+ continue;
+ }
+
+ if ( TextureStackCompositor.NeedsCompositeShader( layer ) )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+
+ /** The most intermediate buffers any single layer rents while it draws -
+ an effect layer chains passes through them. Only the maximum matters,
+ because they are rented and returned one layer at a time.
+ */
+ public int maxExtraLayerBuffers
+ {
+ get
+ {
+ if ( _layers == null )
+ {
+ return 0;
+ }
+
+ var most = 0;
+
+ foreach ( var layer in _layers )
+ {
+ if ( layer == null || ! layer.visible )
+ {
+ continue;
+ }
+
+ most = Mathf.Max( most, layer.extraBuffers );
+ }
+
+ return most;
+ }
+ }
+
+
+ /** Every Resource a consumer has to listen to for this stack to stay in
+ sync - the layers themselves and, recursively, everything they read.
+
+ Collected as ( resource, draw index ) pairs so an edit three levels down
+ still becomes an incremental redraw from the right layer. Layers that
+ belong to a nested stack report their *owner's* draw index, because that
+ is the layer the outer composite has to replay from.
+ */
+ public void CollectChangeSources( List into, int drawIndex = -1 )
+ {
+ if ( _layers == null )
+ {
+ return;
+ }
+
+ for ( var i = 0; i < _layers.Length; i++ )
+ {
+ var layer = _layers[ i ];
+
+ if ( layer == null )
+ {
+ continue;
+ }
+
+ // -1 means "this is the top-level stack, use each layer's own index".
+ // A nested stack passes its owner's index down instead.
+ var index = drawIndex < 0 ? ToDrawIndex( i ) : drawIndex;
+
+ TextureStackChangeSource.Add( into, layer, index );
+
+ layer.CollectChangeSources( into, index );
+ }
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStack.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStack.cs.uid
new file mode 100644
index 0000000..014d2d4
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStack.cs.uid
@@ -0,0 +1 @@
+uid://daqj0w2xw168d
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackBaker.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackBaker.cs
new file mode 100644
index 0000000..0b300eb
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackBaker.cs
@@ -0,0 +1,128 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Reads a composited TextureStack back off the GPU and writes it to disk.
+
+ Thin on purpose - the two halves it needs already existed before this
+ system did:
+
+ - Readback. Spike gate G3 found GetImage() works directly on
+ a DrawableTexture2D, so none of the fallbacks the plan budgeted for
+ (RenderingServer.Texture2DGet, a viewport quad) are needed.
+ - Writing. Procedural/Baking/Textures.Save already handles
+ png / jpg / webp / exr by extension.
+
+ So baking really is the one-liner the research addendum predicted; what is
+ left here is the editor bookkeeping (telling the filesystem a file
+ appeared) and compositing into a throwaway target so that baking a stack
+ does not need a TextureStackTexture2D to exist first.
+ */
+ public static class TextureStackBaker
+ {
+ /** Composites a stack into a temporary DrawableTexture2D and reads it back.
+
+ The target is local to this call, so nothing is cached and nothing holds
+ VRAM afterwards - which is the point for a one-off 4k bake. Incremental
+ redraw is off for the same reason: a checkpoint buffer here would be
+ allocated and dropped immediately.
+ */
+ public static Image Render( TextureStack stack, Vector2I size, TextureStackFormat format,
+ Color clearColor, bool mipmaps )
+ {
+ if ( stack == null || size.X <= 0 || size.Y <= 0 )
+ {
+ return null;
+ }
+
+ var target = new DrawableTexture2D();
+
+ var context = new TextureStackCompositorContext();
+
+ context.stack = stack;
+ context.size = size;
+ context.format = format;
+ context.clearColor = clearColor;
+ context.mipmaps = mipmaps;
+ context.fromLayer = 0;
+ context.incrementalRedraw = false;
+
+ context.targets.Add( target );
+
+ new TextureStackCompositor().Composite( context );
+
+ context.targets.Clear();
+
+ return target.GetImage();
+ }
+
+ /** Writes an image to a project path and makes the editor notice it.
+
+ Without the rescan the file exists but has no .import entry, so nothing
+ can reference it until the editor is refocused - which looks exactly like
+ the bake having failed.
+ */
+ public static bool Save( Image image, string path, float quality = 1 )
+ {
+ if ( image == null || string.IsNullOrEmpty( path ) )
+ {
+ return false;
+ }
+
+ Textures.Save( image, path, quality );
+
+ Rescan();
+
+ return true;
+ }
+
+ public static void Rescan()
+ {
+ #if TOOLS
+
+ if ( ! Engine.IsEditorHint() )
+ {
+ return;
+ }
+
+ EditorInterface.Singleton.GetResourceFilesystem().Scan();
+
+ #endif
+ }
+
+ /** The baked file as an Image, or null when there is none yet.
+
+ Two paths, because the file is a different thing in the editor than in an
+ export: in the editor it is the png on disk, in an export it is the
+ imported .ctex the png produced and the png itself may not be shipped at
+ all. ResourceLoader finds the imported one; LoadFromFile is the editor
+ and user:// case.
+ */
+ public static Image Load( string path )
+ {
+ if ( string.IsNullOrEmpty( path ) )
+ {
+ return null;
+ }
+
+ if ( ResourceLoader.Exists( path ) )
+ {
+ var texture = ResourceLoader.Load( path );
+
+ if ( texture != null )
+ {
+ return texture.GetImage();
+ }
+ }
+
+ if ( ! FileAccess.FileExists( path ) )
+ {
+ return null;
+ }
+
+ return Image.LoadFromFile( path );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackBaker.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackBaker.cs.uid
new file mode 100644
index 0000000..3524d8c
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackBaker.cs.uid
@@ -0,0 +1 @@
+uid://dbkcat1h845do
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackBlendMode.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackBlendMode.cs
new file mode 100644
index 0000000..547dfb0
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackBlendMode.cs
@@ -0,0 +1,80 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Blend modes for a TextureStackLayer.
+
+ The first four are *native* modes: BlitMaterial carries them as a runtime
+ blend_mode, so they need no shader at all and no ping-pong buffer - the
+ hardware blends the layer straight onto the accumulation. The remaining
+ six have no hardware equivalent and are computed in
+ TextureStackComposite.gdshader, which needs the accumulation handed in as
+ a source texture (see TextureStackCompositor.NeedsCompositeShader).
+
+ The formulas mirror the CPU predecessor in
+ Procedural/Textures/TextureCombiner ( TextureCombinerBlendMode /
+ TextureCombinerBlendModeAlgorithm / ColorX.Blend* ). Two copies of the
+ same math exist on purpose - see the note in TextureStack.gdshaderinc -
+ so a fix to one belongs in the other as well.
+
+ Order matches the int values the composite shader switches on. Do not
+ renumber without updating TextureStack.gdshaderinc.
+ */
+ public enum TextureStackBlendMode
+ {
+ // Native - BlitMaterial.BlendModeEnum, no shader needed.
+ Mix = 0,
+ Add = 1,
+ Sub = 2,
+ Multiply = 3,
+
+ // In-shader only, require the accumulation as a source.
+ Screen = 4,
+ Overlay = 5,
+ HardLight = 6,
+ SoftLight = 7,
+ Color = 8,
+ Hue = 9
+ }
+
+ public static class TextureStackBlendModeX
+ {
+ /** True for the four modes BlitMaterial can do in hardware.
+
+ Note this says nothing about whether the *layer* can take the native
+ path - a mask, clip-to-below or a non-trivial alpha requirement still
+ forces the composite shader. TextureStackCompositor decides that.
+ */
+ public static bool IsNative( this TextureStackBlendMode mode )
+ {
+ return mode == TextureStackBlendMode.Mix
+ || mode == TextureStackBlendMode.Add
+ || mode == TextureStackBlendMode.Sub
+ || mode == TextureStackBlendMode.Multiply;
+ }
+
+ /** Spike finding G5/constraint 2: the hardware blend applies to alpha too,
+ so Add and Sub drive the target's alpha somewhere unintended even with a
+ fully opaque source. Only safe where the target's alpha does not matter.
+ */
+ public static bool NativePreservesAlpha( this TextureStackBlendMode mode )
+ {
+ return mode == TextureStackBlendMode.Mix;
+ }
+
+ public static BlitMaterial.BlendModeEnum ToBlitBlendMode( this TextureStackBlendMode mode )
+ {
+ switch ( mode )
+ {
+ case TextureStackBlendMode.Mix: return BlitMaterial.BlendModeEnum.Mix;
+ case TextureStackBlendMode.Add: return BlitMaterial.BlendModeEnum.Add;
+ case TextureStackBlendMode.Sub: return BlitMaterial.BlendModeEnum.Sub;
+ case TextureStackBlendMode.Multiply: return BlitMaterial.BlendModeEnum.Mul;
+ }
+
+ return BlitMaterial.BlendModeEnum.Mix;
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackBlendMode.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackBlendMode.cs.uid
new file mode 100644
index 0000000..3386ea4
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackBlendMode.cs.uid
@@ -0,0 +1 @@
+uid://c0r4tu6bmabi
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackBufferPool.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackBufferPool.cs
new file mode 100644
index 0000000..def9140
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackBufferPool.cs
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Shared pool of full-size DrawableTexture2D buffers, keyed by size and
+ format.
+
+ Compositing one texture holds up to three full-size buffers at once (the
+ target, the scratch buffer and the ping-pong buffer). At 4096 Rgbah that
+ is roughly 128 MB each, so they are rented for the duration of a
+ composite and returned immediately afterwards - allocating them per
+ composite would thrash VRAM, and keeping them alive per texture would
+ multiply by the number of stacks in a scene.
+
+ Buffers are not cleared on rent. Every consumer either Setup()s them or
+ fully overwrites the rect it reads, so clearing would be wasted work.
+ Session 3's incremental redraw will keep buffers instead of returning
+ them, which is why renting goes through a pool rather than through new().
+ */
+ public class TextureStackBufferPool
+ {
+ public static readonly TextureStackBufferPool shared = new TextureStackBufferPool();
+
+ class Key
+ {
+ public int width;
+ public int height;
+ public TextureStackFormat format;
+
+ public override int GetHashCode()
+ {
+ return ( width * 397 ) ^ ( height * 31 ) ^ (int) format;
+ }
+
+ public override bool Equals( object other )
+ {
+ var key = other as Key;
+
+ return key != null
+ && key.width == width
+ && key.height == height
+ && key.format == format;
+ }
+ }
+
+ Dictionary> _free = new Dictionary>();
+
+ /** The key a rented buffer was created under. Tracked here rather than
+ read back off the buffer, because DrawableTexture2D exposes SetFormat
+ without a matching getter.
+ */
+ Dictionary _keys = new Dictionary();
+
+ public int rented => _keys.Count;
+
+ public DrawableTexture2D Rent( int width, int height, TextureStackFormat format, Color clearColor )
+ {
+ var key = new Key(){ width = width, height = height, format = format };
+
+ List buffers;
+
+ var buffer = (DrawableTexture2D) null;
+
+ if ( _free.TryGetValue( key, out buffers ) && buffers.Count > 0 )
+ {
+ buffer = buffers[ buffers.Count - 1 ];
+ buffers.RemoveAt( buffers.Count - 1 );
+ }
+ else
+ {
+ buffer = new DrawableTexture2D();
+ }
+
+ // Setup() re-allocates and clears, which is what makes a reused buffer safe.
+ buffer.Setup( width, height, format.ToDrawableFormat(), clearColor, false );
+
+ _keys[ buffer ] = key;
+
+ return buffer;
+ }
+
+ public void Return( DrawableTexture2D buffer )
+ {
+ if ( buffer == null )
+ {
+ return;
+ }
+
+ Key key;
+
+ if ( ! _keys.TryGetValue( buffer, out key ) )
+ {
+ // Not ours - the caller allocated it, so it is theirs to keep.
+ return;
+ }
+
+ _keys.Remove( buffer );
+
+ List buffers;
+
+ if ( ! _free.TryGetValue( key, out buffers ) )
+ {
+ buffers = new List();
+ _free[ key ] = buffers;
+ }
+
+ if ( ! buffers.Contains( buffer ) )
+ {
+ buffers.Add( buffer );
+ }
+ }
+
+ /** Drops every pooled buffer. Nothing calls this automatically - it is the
+ escape hatch for freeing VRAM after a one-off 4k bake.
+ */
+ public void Clear()
+ {
+ _free.Clear();
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackBufferPool.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackBufferPool.cs.uid
new file mode 100644
index 0000000..b0ddaf3
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackBufferPool.cs.uid
@@ -0,0 +1 @@
+uid://ifil1yg3eyou
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackChangeSource.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackChangeSource.cs
new file mode 100644
index 0000000..ad90368
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackChangeSource.cs
@@ -0,0 +1,63 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** One Resource a TextureStackTexture2D has to listen to, and which layer an
+ edit to it should redraw from.
+
+ Exists because the change tree is deeper than the compositing tree. A
+ composite walks layers; staying in sync means also watching every mask,
+ every TextureSettings, every stamp set, every nested stack and every plain
+ Texture2D sitting in a slot. All of those report the draw index of the
+ layer that owns them, so an edit three levels down still becomes an
+ incremental redraw from the right place rather than a full recomposite.
+
+ Why this is needed at all: Godot does not emit `changed` on a
+ scripted Resource when the inspector edits a plain [Export] field.
+ Built-in resources emit it by hand from their setters - that is why
+ Gradient, Curve and BaseMaterial3D all call emit_changed() in every
+ setter. So every resource in this system does the same, and this is how
+ they are found and subscribed.
+ */
+ public struct TextureStackChangeSource
+ {
+ public Resource resource;
+
+ /** Draw index of the owning layer - 0 is the bottom layer. */
+ public int drawIndex;
+
+ /** Adds unless it is null or already in the list. The same resource
+ reachable from two layers keeps the lower draw index, because a redraw
+ has to start from the lowest layer the edit can affect.
+ */
+ public static void Add( List into, Resource resource, int drawIndex )
+ {
+ if ( resource == null || into == null )
+ {
+ return;
+ }
+
+ for ( var i = 0; i < into.Count; i++ )
+ {
+ if ( into[ i ].resource != resource )
+ {
+ continue;
+ }
+
+ if ( drawIndex < into[ i ].drawIndex )
+ {
+ var existing = into[ i ];
+ existing.drawIndex = drawIndex;
+ into[ i ] = existing;
+ }
+
+ return;
+ }
+
+ into.Add( new TextureStackChangeSource(){ resource = resource, drawIndex = drawIndex } );
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackChangeSource.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackChangeSource.cs.uid
new file mode 100644
index 0000000..e82e8a9
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackChangeSource.cs.uid
@@ -0,0 +1 @@
+uid://c0bhsw3kns2ot
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackCompositor.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositor.cs
new file mode 100644
index 0000000..bf28d95
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositor.cs
@@ -0,0 +1,617 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Renders a TextureStack into a DrawableTexture2D.
+
+ A plain C# class on purpose: it carries no authored data, only the
+ materials and buffers needed while compositing, so nothing about the
+ stack or the output texture depends on it being a Resource or a Node.
+ That keeps it drivable from the editor plugin at tool time and from the
+ TimelineManager/OnTick pattern at runtime with no duplicated logic.
+
+ Per layer and per placement rect it does:
+
+ 1. Rent a rect-sized scratch buffer and let the layer draw its content
+ into it, blending disabled.
+ 2. Composite that onto the accumulation, which is always
+ context.target itself - so the target holds a valid image after
+ every single layer, which is what session 3's incremental redraw
+ will build on.
+
+ Step 2 takes one of two paths ( the "hybrid" blend strategy ):
+
+ - Native: one BlitRect with a BlitMaterial, no shader, per-layer
+ opacity riding on MODULATE. Two blits total, one extra buffer.
+ - Composite shader: a BlitRectMulti into a rect-sized ping-pong buffer
+ that receives the accumulation as a source, then a copy back onto the
+ accumulation. Three blits, two extra buffers.
+
+ Why the native path is narrower than "the four BlitMaterial modes":
+ hardware blending applies to alpha as well as colour (spike constraint
+ 2), and only Mix's hardware alpha matches what blendMode_alpha computes
+ in the shader. Add, Sub and Mul would silently produce a different alpha
+ on the native path than on the shader path - Sub zeroes an opaque
+ target's alpha outright - so they go through the shader, where alpha is
+ written deliberately.
+ */
+ public class TextureStackCompositor
+ {
+ static readonly Color White = new Color( 1, 1, 1, 1 );
+ static readonly Color Transparent = new Color( 0, 0, 0, 0 );
+
+ TextureStackBufferPool _pool;
+ public TextureStackBufferPool pool => _pool;
+
+ public TextureStackCompositor( TextureStackBufferPool pool = null )
+ {
+ _pool = pool != null ? pool : TextureStackBufferPool.shared;
+ }
+
+ TextureStackContentMaterial _contentMaterial;
+
+ /** The shared content-pass material. Layers set its uniforms in Draw()
+ rather than allocating their own material per blit.
+ */
+ public TextureStackContentMaterial contentMaterial
+ {
+ get
+ {
+ if ( _contentMaterial == null )
+ {
+ _contentMaterial = new TextureStackContentMaterial();
+ }
+
+ return _contentMaterial;
+ }
+ }
+
+ TextureStackCompositeMaterial _compositeMaterial;
+
+ public TextureStackCompositeMaterial compositeMaterial
+ {
+ get
+ {
+ if ( _compositeMaterial == null )
+ {
+ _compositeMaterial = new TextureStackCompositeMaterial();
+ }
+
+ return _compositeMaterial;
+ }
+ }
+
+ TextureStackEffectMaterial _effectMaterial;
+
+ /** The shared effect-pass material, for TextureStackEffectLayer. Kept here
+ with the other two so an effect layer does not allocate a material per
+ composite either.
+ */
+ public TextureStackEffectMaterial effectMaterial
+ {
+ get
+ {
+ if ( _effectMaterial == null )
+ {
+ _effectMaterial = new TextureStackEffectMaterial();
+ }
+
+ return _effectMaterial;
+ }
+ }
+
+ BlitMaterial _nativeBlit;
+ BlitMaterial _copyBlit;
+
+ /** Straight overwrite, blending disabled. Used to copy the ping-pong result
+ back onto the accumulation and to move the incremental checkpoint in and
+ out.
+ */
+ public BlitMaterial copyBlit
+ {
+ get
+ {
+ if ( _copyBlit == null )
+ {
+ _copyBlit = new BlitMaterial();
+ _copyBlit.BlendMode = BlitMaterial.BlendModeEnum.Disabled;
+ }
+
+ return _copyBlit;
+ }
+ }
+
+ /** Shared empty extraTargets array, for the single-output v1 path. Layers
+ use it too, so nothing allocates a throwaway array per blit.
+ */
+ public static readonly Godot.Collections.Array noExtraTargets
+ = new Godot.Collections.Array();
+
+
+ /** True when a layer cannot be blitted straight onto the accumulation and
+ needs the composite shader, which means it also needs a ping-pong
+ buffer. Used by TextureStack.needsPingPong to size the memory estimate.
+ */
+ public static bool NeedsCompositeShader( TextureStackLayer layer )
+ {
+ if ( layer == null )
+ {
+ return false;
+ }
+
+ if ( layer.blendMode != TextureStackBlendMode.Mix )
+ {
+ return true;
+ }
+
+ if ( layer.clipToBelow )
+ {
+ return true;
+ }
+
+ return layer.GetUsableMasks().Count > 0;
+ }
+
+
+ /** Composites context.stack into context.target.
+
+ On a full composite ( context.fromLayer == 0 ) this calls Setup on the
+ target, so the target's own size/format/clear-colour fields are
+ irrelevant - the context's are authoritative. An incremental redraw
+ restores the checkpoint instead, because Setup clears.
+ */
+ public void Composite( TextureStackCompositorContext context )
+ {
+ if ( context == null )
+ {
+ return;
+ }
+
+ var target = context.target;
+
+ if ( target == null )
+ {
+ RJLog.Error( "TextureStackCompositor: no target in the context." );
+ return;
+ }
+
+ if ( context.size.X <= 0 || context.size.Y <= 0 )
+ {
+ RJLog.Error( "TextureStackCompositor: invalid size", context.size );
+ return;
+ }
+
+ context.compositor = this;
+
+ var stack = context.stack;
+
+ if ( stack == null || stack.layers == null )
+ {
+ PrepareTarget( context, target );
+
+ return;
+ }
+
+ // The materials and the placement list are per instance and re-set per
+ // layer, so one instance cannot composite two stacks at once. Nesting goes
+ // through its own compositor for exactly this reason - see
+ // TextureStackStackLayer.
+ if ( _busy )
+ {
+ RJLog.Error( "TextureStackCompositor: re-entered while compositing.",
+ "A nested stack needs its own compositor instance."
+ );
+
+ return;
+ }
+
+ _busy = true;
+
+ try
+ {
+ CompositeLayers( context, stack, target );
+ }
+ finally
+ {
+ _busy = false;
+
+ context.layer = null;
+ context.scratch = null;
+ }
+ }
+
+ bool _busy = false;
+
+ void CompositeLayers( TextureStackCompositorContext context, TextureStack stack,
+ DrawableTexture2D target )
+ {
+ // Clamped, because a stale fromLayer - a layer removed since the edit that
+ // scheduled this - would otherwise restore a checkpoint and then replay
+ // nothing over it.
+ var fromLayer = Mathf.Clamp( context.fromLayer, 0, stack.numLayers );
+
+ var startLayer = BeginAccumulation( context, target, fromLayer );
+
+ // Draw order: index 0 is the *bottom* layer and drawn first. The array
+ // itself is top-first, which is the authoring reading - DrawLayerAt does
+ // the flip, and it is the only place that knows about it. Every index in
+ // this class, including the checkpoint's and context.fromLayer, is a draw
+ // index.
+ for ( var l = startLayer; l < stack.numLayers; l++ )
+ {
+ var layer = stack.DrawLayerAt( l );
+
+ if ( layer == null || ! layer.visible )
+ {
+ continue;
+ }
+
+ // Snapshot the state *below* the layer being edited, so the next edit to
+ // the same layer can replay from here instead of from zero.
+ if ( context.incrementalRedraw && l == fromLayer && l > 0 )
+ {
+ SaveCheckpoint( context, target, l );
+ }
+
+ context.layer = layer;
+ context.layerIndex = l;
+
+ _placements.Clear();
+ layer.CollectPlacements( context, _placements );
+
+ for ( var p = 0; p < _placements.Count; p++ )
+ {
+ var placement = _placements[ p ];
+
+ if ( ! placement.ClipTo( context.fullRect ) )
+ {
+ continue;
+ }
+
+ context.placement = placement;
+
+ DrawLayerRect( context, layer, placement.rect, target );
+ }
+ }
+
+ if ( context.mipmaps )
+ {
+ target.GenerateMipmaps();
+ }
+ }
+
+ void DrawLayerRect( TextureStackCompositorContext context, TextureStackLayer layer,
+ Rect2I rect, DrawableTexture2D target )
+ {
+ // Transparent, so a layer that only writes part of its rect leaves the
+ // remainder contributing nothing instead of showing the previous
+ // tenant's pixels.
+ var scratch = _pool.Rent( rect.Size.X, rect.Size.Y, context.format, Transparent );
+
+ context.scratch = scratch;
+
+ try
+ {
+ layer.Draw( context );
+
+ if ( NeedsCompositeShader( layer ) )
+ {
+ CompositeWithShader( context, layer, rect, target, scratch );
+ }
+ else
+ {
+ CompositeNative( layer, rect, target, scratch );
+ }
+ }
+ finally
+ {
+ context.scratch = null;
+ _pool.Return( scratch );
+ }
+ }
+
+ /** Mix without masks: the hardware does it, and MODULATE carries the
+ layer's opacity for free ( spike finding 4 ).
+ */
+ void CompositeNative( TextureStackLayer layer, Rect2I rect,
+ DrawableTexture2D target, DrawableTexture2D scratch )
+ {
+ if ( _nativeBlit == null )
+ {
+ _nativeBlit = new BlitMaterial();
+ }
+
+ _nativeBlit.BlendMode = layer.blendMode.ToBlitBlendMode();
+
+ var opacity = Mathf.Clamp( layer.opacity, 0, 1 );
+
+ target.BlitRect( rect, scratch, new Color( 1, 1, 1, opacity ), 0, _nativeBlit );
+ }
+
+ void CompositeWithShader( TextureStackCompositorContext context, TextureStackLayer layer,
+ Rect2I rect, DrawableTexture2D target, DrawableTexture2D scratch )
+ {
+ var pingPong = _pool.Rent( rect.Size.X, rect.Size.Y, context.format, Transparent );
+
+ try
+ {
+ var material = compositeMaterial;
+
+ material.targetSize.Set( context.sizeF );
+ material.rectOffset.Set( new Vector2( rect.Position.X, rect.Position.Y ) );
+ material.placementUVRect.Set( context.placement.uvRect );
+ material.blendMode.Set( (int) layer.blendMode );
+ material.opacity.Set( Mathf.Clamp( layer.opacity, 0, 1 ) );
+ material.clipToBelow.Set( layer.clipToBelow );
+ material.ResetMasks();
+
+ // Source order has to match hint_blit_source0..3 in the shader.
+ var sources = new Godot.Collections.Array(){ scratch, target };
+
+ var masks = layer.GetUsableMasks();
+
+ for ( var i = 0; i < masks.Count && i < material.maskSlots; i++ )
+ {
+ material.SetMask( i, masks[ i ] );
+ sources.Add( masks[ i ].texture );
+ }
+
+ // Over the ping-pong buffer's own extent, so UV is 1:1 with the
+ // scratch buffer and the masks.
+ pingPong.BlitRectMulti(
+ new Rect2I( 0, 0, rect.Size.X, rect.Size.Y ),
+ sources, noExtraTargets, White, 0, material
+ );
+
+ target.BlitRect( rect, pingPong, White, 0, copyBlit );
+ }
+ finally
+ {
+ _pool.Return( pingPong );
+ }
+ }
+
+
+ /* PREPARING THE TARGET
+ --------------------------------------------------------------------------
+ Setup() reallocates the texture, and reallocating makes the engine
+ emit the resource's own changed signal - which rebuilds the
+ inspector and collapses every open layer and mask. That was the real cause
+ of the collapse the user hit twice; emitChangedAfterComposite was only ever
+ a second, redundant emitter.
+
+ So Setup() is called only when the allocation actually has to change -
+ first composite, or a different size / format / mipmap flag. Every other
+ composite clears the existing allocation instead, by blitting a 1x1
+ clear-colour texture across the whole output with blending disabled. Same
+ pixels as Setup()'s clear, no reallocation, no signal.
+
+ It also takes the one expensive fixed cost out of a recomposite, which is
+ what makes recompositing every frame for a runtime animation affordable.
+ */
+
+ DrawableTexture2D _setupTarget;
+ Vector2I _setupSize;
+ TextureStackFormat _setupFormat;
+ bool _setupMipmaps;
+
+ DrawableTexture2D _clearSource;
+ Color _clearSourceColor;
+ TextureStackFormat _clearSourceFormat;
+
+ /** Gets the target to "allocated at the right size and cleared", with as
+ little reallocation as possible.
+ */
+ void PrepareTarget( TextureStackCompositorContext context, DrawableTexture2D target )
+ {
+ // A nested group's target comes straight out of the pool, which Setup()s
+ // and clears it on rent. Doing it again would be a second allocation for
+ // nothing.
+ if ( context.targetPrepared )
+ {
+ RememberSetup( context, target );
+ return;
+ }
+
+ if ( NeedsSetup( context, target ) )
+ {
+ target.Setup(
+ context.size.X, context.size.Y,
+ context.format.ToDrawableFormat(),
+ context.clearColor,
+ context.mipmaps
+ );
+
+ RememberSetup( context, target );
+
+ return;
+ }
+
+ ClearTarget( context, target );
+ }
+
+ bool NeedsSetup( TextureStackCompositorContext context, DrawableTexture2D target )
+ {
+ return _setupTarget != target
+ || _setupSize != context.size
+ || _setupFormat != context.format
+ || _setupMipmaps != context.mipmaps;
+ }
+
+ void RememberSetup( TextureStackCompositorContext context, DrawableTexture2D target )
+ {
+ _setupTarget = target;
+ _setupSize = context.size;
+ _setupFormat = context.format;
+ _setupMipmaps = context.mipmaps;
+ }
+
+ /** Fills the whole target with the clear colour. The source is 1x1, so
+ BlitRect's stretch-across-the-rect behaviour - the thing that makes
+ rect-sized scratch buffers mandatory everywhere else - is exactly what is
+ wanted here.
+ */
+ void ClearTarget( TextureStackCompositorContext context, DrawableTexture2D target )
+ {
+ if ( _clearSource == null
+ || _clearSourceColor != context.clearColor
+ || _clearSourceFormat != context.format )
+ {
+ if ( _clearSource == null )
+ {
+ _clearSource = new DrawableTexture2D();
+ }
+
+ _clearSource.Setup( 1, 1, context.format.ToDrawableFormat(), context.clearColor, false );
+
+ _clearSourceColor = context.clearColor;
+ _clearSourceFormat = context.format;
+ }
+
+ target.BlitRect( context.fullRect, _clearSource, White, 0, copyBlit );
+ }
+
+ /** Forces the next composite to call Setup() again. For the case nothing
+ here can see - a target whose allocation was changed from outside.
+ */
+ public void ForgetTargetSetup()
+ {
+ _setupTarget = null;
+ }
+
+
+
+
+ /* INCREMENTAL REDRAW
+ --------------------------------------------------------------------------
+ One checkpoint buffer, not one per layer. It holds the accumulation as it
+ stood *before* _checkpointLayer, so re-editing that layer replays only it
+ and everything above it.
+
+ One buffer rather than a full history because the cost is per full output
+ size - 64 MB at 4k Rgba8 - and the authoring case this exists for is
+ dragging one slider, which hits the same layer over and over. A deeper
+ history would multiply the memory to speed up a case that does not come
+ up.
+
+ It is owned rather than pooled, because it has to survive between
+ composites, which is exactly what the pool does not promise.
+ */
+
+ DrawableTexture2D _checkpoint;
+ int _checkpointLayer = -1;
+ TextureStack _checkpointStack;
+ Vector2I _checkpointSize;
+ TextureStackFormat _checkpointFormat;
+ DrawableTexture2D _checkpointTarget;
+
+ readonly List _placements = new List();
+
+ /** Gets the accumulation into the state layer `startLayer` expects, and
+ returns which layer to start from - either fromLayer's checkpoint or 0.
+ */
+ int BeginAccumulation( TextureStackCompositorContext context, DrawableTexture2D target,
+ int fromLayer )
+ {
+ if ( fromLayer > 0 && CheckpointUsableFor( context, target, fromLayer ) )
+ {
+ target.BlitRect( context.fullRect, _checkpoint, White, 0, copyBlit );
+
+ return _checkpointLayer;
+ }
+
+ PrepareTarget( context, target );
+
+ return 0;
+ }
+
+ bool CheckpointUsableFor( TextureStackCompositorContext context, DrawableTexture2D target,
+ int fromLayer )
+ {
+ return context.incrementalRedraw
+ && _checkpoint != null
+ && _checkpointLayer >= 0
+ && _checkpointLayer <= fromLayer
+ && _checkpointTarget == target
+ && _checkpointStack == context.stack
+ && _checkpointSize == context.size
+ && _checkpointFormat == context.format;
+ }
+
+ void SaveCheckpoint( TextureStackCompositorContext context, DrawableTexture2D target, int layer )
+ {
+ if ( _checkpointLayer == layer && CheckpointUsableFor( context, target, layer ) )
+ {
+ // Already holds this layer's pre-state, and we just restored from it.
+ return;
+ }
+
+ if ( _checkpoint == null )
+ {
+ _checkpoint = new DrawableTexture2D();
+ }
+
+ _checkpoint.Setup(
+ context.size.X, context.size.Y,
+ context.format.ToDrawableFormat(),
+ Transparent,
+ false
+ );
+
+ _checkpoint.BlitRect(
+ new Rect2I( 0, 0, context.size.X, context.size.Y ), target, White, 0, copyBlit
+ );
+
+ _checkpointLayer = layer;
+ _checkpointStack = context.stack;
+ _checkpointSize = context.size;
+ _checkpointFormat = context.format;
+ _checkpointTarget = target;
+ }
+
+ /** Drops the checkpoint buffer. Worth calling after a one-off 4k bake, or
+ whenever the stack is edited structurally rather than per layer.
+ */
+ public void DiscardCheckpoint()
+ {
+ _checkpoint = null;
+ _checkpointLayer = -1;
+ _checkpointStack = null;
+ _checkpointTarget = null;
+ }
+
+ public bool hasCheckpoint => _checkpoint != null;
+
+
+ /** Rough VRAM cost of compositing this stack at that size, in bytes -
+ target plus scratch, plus the ping-pong buffer if any layer needs it,
+ plus the incremental checkpoint if that is on. Full-size numbers;
+ sub-rect layers and stamps cost less.
+ */
+ public static long EstimateMemory( TextureStack stack, Vector2I size,
+ TextureStackFormat format, bool incrementalRedraw = false )
+ {
+ var buffers = 2;
+
+ if ( stack != null && stack.needsPingPong )
+ {
+ buffers = 3;
+ }
+
+ if ( stack != null )
+ {
+ buffers += stack.maxExtraLayerBuffers;
+ }
+
+ if ( incrementalRedraw )
+ {
+ buffers += 1;
+ }
+
+ return (long) buffers * size.X * size.Y * format.BytesPerPixel();
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackCompositor.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositor.cs.uid
new file mode 100644
index 0000000..aad69d8
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositor.cs.uid
@@ -0,0 +1 @@
+uid://s5gkdrpi8u2x
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackCompositorContext.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositorContext.cs
new file mode 100644
index 0000000..edc45ae
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositorContext.cs
@@ -0,0 +1,108 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Everything a layer needs to know while it is being drawn.
+
+ Created fresh per composite by TextureStackCompositor and handed to every
+ layer's Draw(). Layers never see the compositor's buffers directly - they
+ write content into scratch, inside layerRect, and stop there.
+
+ targets is a list from day one, even though v1 only ever
+ has one entry. BlitRectMulti writes up to 4 targets in a single pass, and
+ the planned PBR-set/graph work needs that; keeping the collection shape
+ now means adding multi-target later touches the compositor only, not
+ every layer ever written.
+ */
+ public class TextureStackCompositorContext
+ {
+ public TextureStackCompositor compositor;
+ public TextureStack stack;
+
+ /** Output targets. targets[ 0 ] is the one v1 composites into and is also
+ the accumulation - the compositor keeps it authoritative at every step,
+ so an interrupted or incremental composite still leaves a readable
+ texture there.
+ */
+ public List targets = new List();
+
+ public DrawableTexture2D target => targets.Count > 0 ? targets[ 0 ] : null;
+
+ public Vector2I size = new Vector2I( 1024, 1024 );
+ public Vector2 sizeF => new Vector2( size.X, size.Y );
+
+ public int width => size.X;
+ public int height => size.Y;
+
+ /** Rgba8 - plain, linear, no encode on write. Session 3's live test settled
+ this: Rgba8Srgb never produced a correct result for any combination of
+ input conversions, and raw-in/raw-out does. See the design doc.
+ */
+ public TextureStackFormat format = TextureStackFormat.Rgba8;
+ public Color clearColor = new Color( 0, 0, 0, 0 );
+ public bool mipmaps = true;
+
+ /** Set when the target arrives already allocated and cleared, which is the
+ case for a nested group's pooled buffer. Stops the compositor
+ reallocating it a second time - see TextureStackCompositor.PrepareTarget.
+ */
+ public bool targetPrepared = false;
+
+ /** Content buffer for the layer being drawn, rented from the pool at
+ exactly layerRect's size - not at the output size.
+
+ That sizing is not an optimisation, it is what makes placement work at
+ all: BlitRect stretches its whole source across the destination rect,
+ so a full-size scratch blitted into a small decal rect would squeeze the
+ entire output into the decal. Rect-sized means every blit maps 1:1 and
+ a small decal only costs a small buffer.
+ */
+ public DrawableTexture2D scratch;
+
+ /** The rect to pass to scratch.BlitRect - the scratch buffer's own full
+ extent, origin ( 0, 0 ). Layers blit here, not into layerRect.
+ */
+ public Rect2I scratchRect => new Rect2I( 0, 0, layerRect.Size.X, layerRect.Size.Y );
+
+ /** Index of the layer currently being drawn. */
+ public int layerIndex = 0;
+
+ public TextureStackLayer layer;
+
+ /** The placement currently being drawn - already clipped to the output,
+ with the UV slice that clipping implies. A layer with stamps gets one
+ Draw() per entry.
+ */
+ public TextureStackPlacement placement = TextureStackPlacement.At( new Rect2I() );
+
+ /** The rect the current layer is drawing into. UV inside a blit over this
+ rect is rect-local (spike gate G6), which is what gives layers free
+ sub-region placement.
+ */
+ public Rect2I layerRect => placement.rect;
+
+ public Rect2I fullRect => new Rect2I( 0, 0, size.X, size.Y );
+
+ /** Lowest layer index that has to be redrawn. 0 is a full composite.
+
+ Anything above 0 asks the compositor for an incremental redraw: it
+ restores the accumulation from its checkpoint and replays from there,
+ which is what makes tweaking one layer of a deep 4k stack interactive.
+ It falls back to a full composite whenever no usable checkpoint exists.
+ */
+ public int fromLayer = 0;
+
+ /** Off skips the checkpoint buffer entirely, trading interactive editing
+ for one less full-size buffer. See TextureStackCompositor.
+ */
+ public bool incrementalRedraw = true;
+
+ /** For sampling a full-size buffer from inside a rect-local blit:
+ globalUV = FRAGCOORD.xy / targetSize.
+ */
+ public Vector2 targetSize => sizeF;
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackCompositorContext.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositorContext.cs.uid
new file mode 100644
index 0000000..8975723
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackCompositorContext.cs.uid
@@ -0,0 +1 @@
+uid://bu7ndgqew47v
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackFormat.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackFormat.cs
new file mode 100644
index 0000000..81ad0b2
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackFormat.cs
@@ -0,0 +1,54 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Mirrors DrawableTexture2D.DrawableFormat as a top-level enum, so it can be
+ exported from a Resource without relying on a nested type showing up in
+ the inspector.
+
+ Rgba8Srgb is the default for albedo-like output; Rgba8 (linear) for normal
+ maps and ORM, following the pattern in the drawable-textures C# demo.
+ Half and Float are the opt-in HDR formats - they double/quadruple memory,
+ which matters because compositing holds up to three full-size buffers
+ (target, scratch, ping-pong).
+ */
+ public enum TextureStackFormat
+ {
+ Rgba8 = 0,
+ Rgba8Srgb = 1,
+ RgbaHalf = 2,
+ RgbaFloat = 3
+ }
+
+ public static class TextureStackFormatX
+ {
+ public static DrawableTexture2D.DrawableFormat ToDrawableFormat( this TextureStackFormat format )
+ {
+ switch ( format )
+ {
+ case TextureStackFormat.Rgba8: return DrawableTexture2D.DrawableFormat.Rgba8;
+ case TextureStackFormat.Rgba8Srgb: return DrawableTexture2D.DrawableFormat.Rgba8Srgb;
+ case TextureStackFormat.RgbaHalf: return DrawableTexture2D.DrawableFormat.Rgbah;
+ case TextureStackFormat.RgbaFloat: return DrawableTexture2D.DrawableFormat.Rgbaf;
+ }
+
+ return DrawableTexture2D.DrawableFormat.Rgba8;
+ }
+
+ /** Bytes per pixel, for the memory estimate the compositor logs. */
+ public static int BytesPerPixel( this TextureStackFormat format )
+ {
+ switch ( format )
+ {
+ case TextureStackFormat.Rgba8: return 4;
+ case TextureStackFormat.Rgba8Srgb: return 4;
+ case TextureStackFormat.RgbaHalf: return 8;
+ case TextureStackFormat.RgbaFloat: return 16;
+ }
+
+ return 4;
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackFormat.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackFormat.cs.uid
new file mode 100644
index 0000000..727a457
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackFormat.cs.uid
@@ -0,0 +1 @@
+uid://plhfl6al3lgm
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackLayer.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackLayer.cs
new file mode 100644
index 0000000..9268a9e
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackLayer.cs
@@ -0,0 +1,329 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** How a layer's placement rect is expressed. */
+ public enum TextureStackPlacementUnits
+ {
+ /** 0-1 of the output size, so the same layer composites correctly at 256
+ for a preview and at 4096 for the final. This is the resolution-
+ independent default.
+ */
+ Normalized = 0,
+
+ /** Absolute pixels of the output. Only correct for one output size. */
+ Pixels = 1
+ }
+
+ /** Base class for everything a TextureStack can draw.
+
+ A layer does two separate things, and the split is deliberate (see the
+ "two-pass via scratch buffer" decision in the design doc):
+
+ 1. Draw() renders the layer's own *content* into context.scratch,
+ inside context.layerRect, with blending disabled. It knows nothing
+ about blend modes, opacity or masks.
+
+ 2. The compositor then runs one generic composite pass that applies
+ blendMode, opacity, the masks and clipToBelow.
+
+ That is why subclasses only ever implement Draw() plus, if they place
+ themselves, ComputeBaseRect() or CollectPlacements().
+
+ Mask count is capped because a blit takes exactly 4 sources (spike gate
+ G4) and the composite pass already spends two of them on the layer
+ content and the accumulation.
+
+ Every exported value here is a property that emits changed,
+ not a plain field. Godot does not emit the signal for a scripted
+ Resource's exported field, and the consuming TextureStackTexture2D needs
+ it to know when to recomposite - see TextureStackChangeSource.
+ */
+ [Tool]
+ [GlobalClass]
+ public abstract partial class TextureStackLayer:Resource
+ {
+ /** Content + accumulation + 2 masks == the 4-source ceiling from G4.
+ More masks need a second composite pass, which is not built yet.
+ */
+ public const int MaxMasks = 2;
+
+ string _layerName = "";
+
+ /** Authoring label only - shown by the custom inspector, never used by the
+ compositor.
+ */
+ [Export]
+ public string layerName
+ {
+ get => _layerName;
+ set { _layerName = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _visible = true;
+
+ [Export]
+ public bool visible
+ {
+ get => _visible;
+ set { _visible = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureStackBlendMode _blendMode = TextureStackBlendMode.Mix;
+
+ [Export]
+ public TextureStackBlendMode blendMode
+ {
+ get => _blendMode;
+ set { _blendMode = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _opacity = 1;
+
+ [Export( PropertyHint.Range, "0,1" )]
+ public float opacity
+ {
+ get => _opacity;
+ set { _opacity = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureStackMask[] _masks = new TextureStackMask[ 0 ];
+
+ /** Multiplied together, capped at MaxMasks. Entries without a texture are
+ skipped rather than treated as black - a missing mask means "no mask",
+ not "mask everything out".
+ */
+ [Export]
+ public TextureStackMask[] masks
+ {
+ get => _masks;
+ set { _masks = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _clipToBelow = false;
+
+ /** Photoshop-style "use the alpha beneath as this layer's mask". Reads the
+ accumulation's alpha, so it forces the composite-shader path.
+ */
+ [Export]
+ public bool clipToBelow
+ {
+ get => _clipToBelow;
+ set { _clipToBelow = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportGroup( "Placement" )]
+
+ bool _fullTexture = true;
+
+ /** On: the layer covers the whole output and the three fields below are
+ ignored. Off: it is blitted into placementPosition/placementSize only,
+ which is what makes a decal cheap - the blit touches just those pixels.
+ */
+ [Export]
+ public bool fullTexture
+ {
+ get => _fullTexture;
+ set { _fullTexture = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureStackPlacementUnits _placementUnits = TextureStackPlacementUnits.Normalized;
+
+ [Export]
+ public TextureStackPlacementUnits placementUnits
+ {
+ get => _placementUnits;
+ set { _placementUnits = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Vector2 _placementPosition = Vector2.Zero;
+
+ [Export]
+ public Vector2 placementPosition
+ {
+ get => _placementPosition;
+ set { _placementPosition = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Vector2 _placementSize = Vector2.One;
+
+ [Export]
+ public Vector2 placementSize
+ {
+ get => _placementSize;
+ set { _placementSize = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureStackStamps _stamps;
+
+ /** Repeats this layer across its placement rect as a grid or a seeded
+ scatter instead of drawing it once. Empty means one blit.
+ */
+ [Export]
+ public TextureStackStamps stamps
+ {
+ get => _stamps;
+ set { _stamps = value; TextureStackNotify.Changed( this ); }
+ }
+
+
+ /** The layer's own rect, in image space and unclipped -
+ the compositor clips it and works out the resulting UV slice, so a
+ placement hanging over the output's edge shows the part that fits rather
+ than squeezing the whole thing into it.
+
+ Rect2I's origin is the image top-left and UV.y runs with image rows
+ (spike gate G6), so there is no Y flip anywhere in this system.
+ */
+ public virtual Rect2I ComputeBaseRect( TextureStackCompositorContext context )
+ {
+ if ( fullTexture )
+ {
+ return context.fullRect;
+ }
+
+ var position = placementPosition;
+ var size = placementSize;
+
+ if ( placementUnits == TextureStackPlacementUnits.Normalized )
+ {
+ position = position * context.sizeF;
+ size = size * context.sizeF;
+ }
+
+ return new Rect2I(
+ Mathf.RoundToInt( position.X ), Mathf.RoundToInt( position.Y ),
+ Mathf.RoundToInt( size.X ), Mathf.RoundToInt( size.Y )
+ );
+ }
+
+ /** Every blit this layer wants, rects unclipped. One entry without stamps,
+ one per stamp plus edge-wrap copies with them.
+
+ Override to place a layer by some rule of its own; the compositor only
+ ever asks through here.
+ */
+ public virtual void CollectPlacements( TextureStackCompositorContext context,
+ List into
+ )
+ {
+ var baseRect = ComputeBaseRect( context );
+
+ if ( stamps == null )
+ {
+ into.Add( TextureStackPlacement.At( baseRect ) );
+ return;
+ }
+
+ stamps.CollectPlacements( baseRect, context.fullRect, into );
+ }
+
+ /** Renders this layer's content into context.scratch over
+ context.scratchRect, with blending disabled.
+
+ The scratch buffer is pooled but always arrives cleared to transparent,
+ so a layer that writes nothing simply contributes nothing - no need to
+ cover the whole rect defensively.
+ */
+ public abstract void Draw( TextureStackCompositorContext context );
+
+ /** How many buffers beyond the scratch one this layer rents while it draws,
+ for the memory estimate. An effect layer chains passes through
+ intermediates; most layers need none.
+ */
+ public virtual int extraBuffers => 0;
+
+
+
+
+ /** Every nested Resource an edit to which has to recomposite this layer.
+
+ The base collects the masks, their TextureSettings and the stamp set.
+ Subclasses add their own content - a texture and its settings, or a
+ whole nested stack. Plain Texture2Ds are collected too: they are
+ built-in resources and do emit changed properly, so editing a
+ GradientTexture2D mask in place recomposites.
+
+ Overrides must call the base. Nothing here recurses into a
+ TextureStackTexture2D's own stack - that one notifies its dependents
+ itself after it recomposites, see TextureStackTexture2D.
+ */
+ public virtual void CollectChangeSources( List into, int drawIndex )
+ {
+ TextureStackChangeSource.Add( into, _stamps, drawIndex );
+
+ if ( _masks == null )
+ {
+ return;
+ }
+
+ for ( var i = 0; i < _masks.Length; i++ )
+ {
+ var mask = _masks[ i ];
+
+ if ( mask == null )
+ {
+ continue;
+ }
+
+ TextureStackChangeSource.Add( into, mask, drawIndex );
+ TextureStackChangeSource.Add( into, mask.textureSettings, drawIndex );
+ AddTextureChangeSource( into, mask.texture, drawIndex );
+ }
+ }
+
+ /** A plain Texture2D as a change source. */
+ protected static void AddTextureChangeSource( List into,
+ Texture2D texture, int drawIndex
+ )
+ {
+ if ( texture == null )
+ {
+ return;
+ }
+
+ TextureStackChangeSource.Add( into, texture, drawIndex );
+ }
+
+
+ public List GetUsableMasks()
+ {
+ var usable = new List();
+
+ if ( masks == null )
+ {
+ return usable;
+ }
+
+ for ( var i = 0; i < masks.Length; i++ )
+ {
+ var mask = masks[ i ];
+
+ if ( mask == null || ! mask.isUsable )
+ {
+ continue;
+ }
+
+ usable.Add( mask );
+
+ if ( usable.Count == MaxMasks )
+ {
+ if ( i < masks.Length - 1 )
+ {
+ RJLog.Log( "TextureStackLayer:", layerName,
+ "has more than", MaxMasks, "usable masks - the rest are ignored.",
+ "A blit takes 4 sources and the composite pass needs 2 of them."
+ );
+ }
+
+ break;
+ }
+ }
+
+ return usable;
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackLayer.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackLayer.cs.uid
new file mode 100644
index 0000000..164779a
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackLayer.cs.uid
@@ -0,0 +1 @@
+uid://314mvum3x1j2
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackMask.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackMask.cs
new file mode 100644
index 0000000..7610568
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackMask.cs
@@ -0,0 +1,190 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Which scalar a mask texture's pixel is reduced to.
+
+ Int values are switched on in TextureStack.gdshaderinc - do not renumber.
+ */
+ public enum TextureStackMaskChannel
+ {
+ Red = 0,
+ Green = 1,
+ Blue = 2,
+ Alpha = 3,
+ Luminance = 4
+ }
+
+ /** One mask on a TextureStackLayer.
+
+ A mask reduces its texture to a single 0-1 value and multiplies it into
+ the layer's effective opacity. Several masks on one layer multiply
+ together, which is how the outline's masked-decal example is authored
+ without any nesting: a circle-gradient texture and a noise texture as two
+ masks on one layer.
+
+ texture takes any Texture2D, so a TextureStackTexture2D goes straight in
+ and a mask can itself be a composited stack.
+
+ The transform is applied around the centre of the layer's placement rect
+ and is independent of the layer's own content UV transform. Its UV space
+ is rect-local: on a full-texture layer that is the whole texture, on a
+ decal it is the decal, which is what makes a mask follow a stamp.
+
+ Levels remap the sampled value before invert: everything at or below
+ levelsMin becomes 0, everything at or above levelsMax becomes 1. Leave at
+ 0/1 for no remapping. This is the "one noise texture, many hardnesses"
+ control.
+
+ Mask count per layer is capped by the blit source ceiling - see
+ TextureStackLayer.MaxMasks.
+
+ Every value is a property that emits changed, so editing a mask
+ recomposites the stack that reads it - see TextureStackChangeSource.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureStackMask:Resource
+ {
+ Texture2D _texture;
+
+ [Export]
+ public Texture2D texture
+ {
+ get => _texture;
+ set { _texture = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureSettings _textureSettings;
+
+ /** Sampling and colour space for the mask texture. Empty means Linear
+ sampling and no conversion - see TextureSettings.
+
+ This is the slot that matters most in the whole system: a mask is
+ usually authored small, a gradient or a 64px noise, and stretched across
+ the whole output, so Nearest here is the difference between a smooth
+ falloff and visible banding.
+ */
+ [Export]
+ public TextureSettings textureSettings
+ {
+ get => _textureSettings;
+ set { _textureSettings = value; TextureStackNotify.Changed( this ); }
+ }
+
+ TextureStackMaskChannel _channel = TextureStackMaskChannel.Red;
+
+ [Export]
+ public TextureStackMaskChannel channel
+ {
+ get => _channel;
+ set { _channel = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _invert = false;
+
+ [Export]
+ public bool invert
+ {
+ get => _invert;
+ set { _invert = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportGroup( "Transform" )]
+
+ Vector2 _offset = Vector2.Zero;
+
+ [Export]
+ public Vector2 offset
+ {
+ get => _offset;
+ set { _offset = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Vector2 _scale = Vector2.One;
+
+ [Export]
+ public Vector2 scale
+ {
+ get => _scale;
+ set { _scale = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _rotationDegrees = 0;
+
+ [Export]
+ public float rotationDegrees
+ {
+ get => _rotationDegrees;
+ set { _rotationDegrees = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _repeat = false;
+
+ /** Off: the mask is transparent (value 0) outside its own 0-1 range, so a
+ scaled-down mask hides everything around itself. On: it tiles.
+ */
+ [Export]
+ public bool repeat
+ {
+ get => _repeat;
+ set { _repeat = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportGroup( "Levels" )]
+
+ float _levelsMin = 0;
+
+ [Export( PropertyHint.Range, "0,1" )]
+ public float levelsMin
+ {
+ get => _levelsMin;
+ set { _levelsMin = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _levelsMax = 1;
+
+ [Export( PropertyHint.Range, "0,1" )]
+ public float levelsMax
+ {
+ get => _levelsMax;
+ set { _levelsMax = value; TextureStackNotify.Changed( this ); }
+ }
+
+ public bool isUsable => texture != null;
+
+ /** offset.xy / scale.xy, as the composite shader expects it. */
+ public Vector4 packedTransform
+ {
+ get
+ {
+ var s = new Vector2(
+ Mathf.IsZeroApprox( scale.X ) ? 0.0001f : scale.X,
+ Mathf.IsZeroApprox( scale.Y ) ? 0.0001f : scale.Y
+ );
+
+ return new Vector4( offset.X, offset.Y, s.X, s.Y );
+ }
+ }
+
+ /** rotation in radians / repeat as 0-1 / levelsMin / levelsMax. */
+ public Vector4 packedParameters
+ {
+ get
+ {
+ var min = levelsMin;
+ var max = levelsMax;
+
+ // A collapsed or inverted range would divide by zero in the shader.
+ if ( max - min < 0.0001f )
+ {
+ max = min + 0.0001f;
+ }
+
+ return new Vector4( Mathf.DegToRad( rotationDegrees ), repeat ? 1 : 0, min, max );
+ }
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackMask.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackMask.cs.uid
new file mode 100644
index 0000000..bc60a57
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackMask.cs.uid
@@ -0,0 +1 @@
+uid://dsesss2cnv80v
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackNotify.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackNotify.cs
new file mode 100644
index 0000000..ae52650
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackNotify.cs
@@ -0,0 +1,48 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** One switch for every `changed` signal the TextureStack data resources
+ emit.
+
+ Off by default, and that is the point. Session 4 gave all
+ ~55 exported values on TextureStack, TextureStackLayer, its subclasses,
+ TextureStackMask, TextureStackStamps and TextureSettings a setter that
+ calls EmitChanged(), following Godot's own convention for custom
+ resources. Sessions 5 and 6 then spent a long time establishing that the
+ recompositing driven off those signals was never reliable, and session 7
+ found the signals themselves are what the editor reacts badly to: with
+ the stack reached through the composited texture, editing a layer
+ interrupts the inspector - even through a hand-drawn UI with no property
+ editors involved at all, which is the case that rules out everything
+ else.
+
+ So the signals stay in the code but stop firing. Compositing is driven
+ explicitly instead - on load, from a button, or from whatever is ticking
+ the stack - which is the direction the architecture is moving anyway.
+
+ Turn it back on to A/B the behaviour, or if a consumer genuinely needs
+ Resource.Changed from this data. Note that
+ TextureStackTexture2D's auto-on-change hooks subscribe to exactly these
+ signals, so with this off they go quiet and only compositeOnLoad, the
+ Composite button and CompositeTextureStack drive it.
+ */
+ public static class TextureStackNotify
+ {
+ /** Whether the data resources emit Resource.Changed from their setters. */
+ public static bool emitChanged = true;
+
+ /** Called by every setter in place of EmitChanged(). */
+ public static void Changed( Resource resource )
+ {
+ if ( ! emitChanged || resource == null )
+ {
+ return;
+ }
+
+ resource.EmitChanged();
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackNotify.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackNotify.cs.uid
new file mode 100644
index 0000000..40abc9b
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackNotify.cs.uid
@@ -0,0 +1 @@
+uid://cmws0ew0o67bk
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackPlacement.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackPlacement.cs
new file mode 100644
index 0000000..fe3b3a0
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackPlacement.cs
@@ -0,0 +1,106 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** One blit a layer contributes - where it goes, and how the shader should
+ read its content there.
+
+ A plain layer produces exactly one of these. A layer with
+ TextureStackStamps produces one per stamp, plus the extra copies edge
+ wrapping needs.
+
+ Layers fill in rect unclipped and leave
+ uvRect alone; TextureStackCompositor then clips the rect against the
+ output and derives uvRect from the two. That split is what lets a stamp
+ hang off the output's edge: the blit shrinks to the visible part, but
+ uvRect tells the shader which slice of the stamp that part is, so the
+ content and the masks stay aligned with the whole stamp instead of being
+ squeezed into the sliver. Without it, UV being rect-local (spike gate G6)
+ would silently scale every clipped placement.
+ */
+ public struct TextureStackPlacement
+ {
+ /** Identity uvRect: this blit covers the whole placement. */
+ public static readonly Vector4 fullUVRect = new Vector4( 0, 0, 1, 1 );
+
+ /** Image space. Set by the layer unclipped, replaced by the compositor with
+ the part that actually lies inside the output.
+ */
+ public Rect2I rect;
+
+ /** What rect was before clipping. Layers that generate content at the
+ placement's resolution - a nested stack - have to work at this size and
+ then show the uvRect slice of it, or their own full-texture layers would
+ fill the visible sliver instead of the whole placement.
+ */
+ public Rect2I unclippedRect;
+
+ /** offset.xy / scale.zw - the slice of the placement that rect covers.
+ Filled in by the compositor.
+ */
+ public Vector4 uvRect;
+
+ /** Extra content rotation in radians, on top of the layer's own. Non-zero
+ only for scattered stamps.
+
+ Note this rotates the content *inside* an axis-aligned rect, so a
+ non-square stamp gets cropped at its corners - keep scattered stamps
+ square, or leave rotation variation at 0.
+ */
+ public float rotation;
+
+ /** Which stamp this is, 0 for a plain layer. Wrapped copies keep the index
+ of the stamp they belong to, so they draw identically.
+ */
+ public int index;
+
+
+ public static TextureStackPlacement At( Rect2I rect, int index = 0, float rotation = 0 )
+ {
+ var placement = new TextureStackPlacement();
+
+ placement.rect = rect;
+ placement.unclippedRect = rect;
+ placement.uvRect = fullUVRect;
+ placement.rotation = rotation;
+ placement.index = index;
+
+ return placement;
+ }
+
+ /** Clips against the output and works out which slice of the original rect
+ survived. Returns false when nothing of it is visible.
+ */
+ public bool ClipTo( Rect2I bounds )
+ {
+ var unclipped = rect;
+
+ unclippedRect = unclipped;
+
+ if ( unclipped.Size.X <= 0 || unclipped.Size.Y <= 0 )
+ {
+ return false;
+ }
+
+ var clipped = unclipped.Intersection( bounds );
+
+ if ( clipped.Size.X <= 0 || clipped.Size.Y <= 0 )
+ {
+ return false;
+ }
+
+ rect = clipped;
+
+ uvRect = new Vector4(
+ ( clipped.Position.X - unclipped.Position.X ) / (float) unclipped.Size.X,
+ ( clipped.Position.Y - unclipped.Position.Y ) / (float) unclipped.Size.Y,
+ clipped.Size.X / (float) unclipped.Size.X,
+ clipped.Size.Y / (float) unclipped.Size.Y
+ );
+
+ return true;
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackPlacement.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackPlacement.cs.uid
new file mode 100644
index 0000000..de24acc
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackPlacement.cs.uid
@@ -0,0 +1 @@
+uid://t7saxmb5iqlb
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackStamps.cs b/Runtime/Procedural/Textures/TextureStack/TextureStackStamps.cs
new file mode 100644
index 0000000..b95f425
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackStamps.cs
@@ -0,0 +1,323 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ public enum TextureStackStampMode
+ {
+ /** columns x rows, one stamp per cell centre. Regular by default; add
+ positionJitter to break it up.
+ */
+ Grid = 0,
+
+ /** count stamps at seeded random positions. */
+ Scatter = 1
+ }
+
+ /** Turns one layer into many blits - a grid or a seeded scatter of the same
+ content.
+
+ This is the anti-repetition tool the blended-ground-texture example needs:
+ a handful of 1k source textures stamped a few dozen times across a 4k
+ output, each stamp at its own position, size and rotation, beats tiling
+ the same square 16 times.
+
+ Everything is in normalized units relative to the layer's own placement
+ rect, so the same stamp setup composites identically at 512 and at 4096.
+
+ Stamps are cheap because a blit only touches its own rect (spike gate G6):
+ 32 stamps of 1/8th the output each cost half an output's worth of pixels,
+ not 32 outputs' worth.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class TextureStackStamps:Resource
+ {
+ TextureStackStampMode _mode = TextureStackStampMode.Grid;
+
+ /** Properties rather than fields throughout, because Godot does not emit a
+ scripted Resource's changed signal for a plain exported field, and the
+ consuming texture recomposites off that signal. See
+ TextureStackChangeSource.
+ */
+ [Export]
+ public TextureStackStampMode mode
+ {
+ get => _mode;
+ set { _mode = value; TextureStackNotify.Changed( this ); }
+ }
+
+ Vector2 _stampSize = new Vector2( 0.25f, 0.25f );
+
+ /** Size of one stamp, as a fraction of the layer's placement rect. */
+ [Export]
+ public Vector2 stampSize
+ {
+ get => _stampSize;
+ set { _stampSize = value; TextureStackNotify.Changed( this ); }
+ }
+
+ bool _edgeWrapping = true;
+
+ /** A stamp that crosses the output's border is blitted again on the
+ opposite side, so the output stays seamlessly tileable. Costs up to
+ three extra blits for the stamps that actually cross - corner stamps
+ cross in both axes.
+
+ Wrapping is against the output, not the layer's
+ placement rect: tileability is a property of the finished texture.
+ */
+ [Export]
+ public bool edgeWrapping
+ {
+ get => _edgeWrapping;
+ set { _edgeWrapping = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportGroup( "Grid" )]
+
+ int _columns = 4;
+
+ [Export]
+ public int columns
+ {
+ get => _columns;
+ set { _columns = value; TextureStackNotify.Changed( this ); }
+ }
+
+ int _rows = 4;
+
+ [Export]
+ public int rows
+ {
+ get => _rows;
+ set { _rows = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportGroup( "Scatter" )]
+
+ int _count = 16;
+
+ [Export]
+ public int count
+ {
+ get => _count;
+ set { _count = value; TextureStackNotify.Changed( this ); }
+ }
+
+ int _seed = 0;
+
+ /** Same seed means the same arrangement every composite - stamps must not
+ move when a texture is recomposited after an unrelated edit.
+ */
+ [Export]
+ public int seed
+ {
+ get => _seed;
+ set { _seed = value; TextureStackNotify.Changed( this ); }
+ }
+
+ [ExportGroup( "Variation" )]
+
+ Vector2 _positionJitter = Vector2.Zero;
+
+ /** Random offset per stamp, as a fraction of the placement rect. Mostly for
+ Grid, where it breaks up the regularity without losing the coverage.
+ */
+ [Export]
+ public Vector2 positionJitter
+ {
+ get => _positionJitter;
+ set { _positionJitter = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _sizeVariation = 0;
+
+ /** 0 = every stamp at stampSize. 0.5 = anywhere between half and one and a
+ half times it. Scales the rect, not the content's UV, so the texture
+ inside is not zoomed.
+ */
+ [Export( PropertyHint.Range, "0,1" )]
+ public float sizeVariation
+ {
+ get => _sizeVariation;
+ set { _sizeVariation = value; TextureStackNotify.Changed( this ); }
+ }
+
+ float _rotationVariationDegrees = 0;
+
+ /** Random content rotation per stamp, symmetric around 0, so 360 is fully
+ random. Rotates inside the axis-aligned rect - see the note on
+ TextureStackPlacement.rotation.
+ */
+ [Export( PropertyHint.Range, "0,360" )]
+ public float rotationVariationDegrees
+ {
+ get => _rotationVariationDegrees;
+ set { _rotationVariationDegrees = value; TextureStackNotify.Changed( this ); }
+ }
+
+
+ /** How many stamps before edge wrapping multiplies some of them. */
+ public int stampCount
+ {
+ get
+ {
+ if ( mode == TextureStackStampMode.Scatter )
+ {
+ return Mathf.Max( 0, count );
+ }
+
+ return Mathf.Max( 0, columns ) * Mathf.Max( 0, rows );
+ }
+ }
+
+
+ /** Appends every blit this stamp set wants, rects unclipped.
+
+ baseRect is the layer's own placement rect, wrapBounds the whole output.
+ */
+ public void CollectPlacements( Rect2I baseRect, Rect2I wrapBounds,
+ List into
+ )
+ {
+ var stamps = stampCount;
+
+ if ( stamps <= 0 )
+ {
+ return;
+ }
+
+ // Seeded, so the arrangement is stable across composites. Jitter, size and
+ // rotation all draw from this one stream, which is why changing any of
+ // them reshuffles the others - deliberate, and cheaper to explain than
+ // three separate streams.
+ var random = LCG.WithSeed( seed );
+
+ var baseSize = new Vector2( baseRect.Size.X, baseRect.Size.Y );
+ var basePosition = new Vector2( baseRect.Position.X, baseRect.Position.Y );
+
+ var gridColumns = Mathf.Max( 1, columns );
+ var gridRows = Mathf.Max( 1, rows );
+
+ for ( var i = 0; i < stamps; i++ )
+ {
+ var center = Vector2.Zero;
+
+ if ( mode == TextureStackStampMode.Scatter )
+ {
+ center = new Vector2( random.Next(), random.Next() );
+ }
+ else
+ {
+ var column = i % gridColumns;
+ var row = i / gridColumns;
+
+ center = new Vector2(
+ ( column + 0.5f ) / gridColumns,
+ ( row + 0.5f ) / gridRows
+ );
+ }
+
+ if ( positionJitter != Vector2.Zero )
+ {
+ center += new Vector2(
+ random.Polar( positionJitter.X ),
+ random.Polar( positionJitter.Y )
+ );
+ }
+
+ var sizeFactor = 1f;
+
+ if ( sizeVariation > 0 )
+ {
+ sizeFactor = 1f + random.Polar( sizeVariation );
+ }
+
+ var rotation = 0f;
+
+ if ( rotationVariationDegrees > 0 )
+ {
+ rotation = Mathf.DegToRad( random.Polar( rotationVariationDegrees * 0.5f ) );
+ }
+
+ var size = stampSize * baseSize * sizeFactor;
+
+ if ( size.X < 1 || size.Y < 1 )
+ {
+ continue;
+ }
+
+ var centerPixels = basePosition + center * baseSize;
+ var topLeft = centerPixels - size * 0.5f;
+
+ var rect = new Rect2I(
+ Mathf.RoundToInt( topLeft.X ), Mathf.RoundToInt( topLeft.Y ),
+ Mathf.Max( 1, Mathf.RoundToInt( size.X ) ), Mathf.Max( 1, Mathf.RoundToInt( size.Y ) )
+ );
+
+ AddWithWrapping( rect, i, rotation, wrapBounds, into );
+ }
+ }
+
+ /** Adds the stamp, plus a copy on the far side for each axis it hangs over.
+ A corner stamp therefore produces four, which together read as one
+ continuous stamp on a tiled surface.
+ */
+ void AddWithWrapping( Rect2I rect, int index, float rotation, Rect2I wrapBounds,
+ List into
+ )
+ {
+ into.Add( TextureStackPlacement.At( rect, index, rotation ) );
+
+ if ( ! edgeWrapping )
+ {
+ return;
+ }
+
+ var shiftX = 0;
+ var shiftY = 0;
+
+ if ( rect.Position.X < wrapBounds.Position.X )
+ {
+ shiftX = wrapBounds.Size.X;
+ }
+ else if ( rect.Position.X + rect.Size.X > wrapBounds.Position.X + wrapBounds.Size.X )
+ {
+ shiftX = -wrapBounds.Size.X;
+ }
+
+ if ( rect.Position.Y < wrapBounds.Position.Y )
+ {
+ shiftY = wrapBounds.Size.Y;
+ }
+ else if ( rect.Position.Y + rect.Size.Y > wrapBounds.Position.Y + wrapBounds.Size.Y )
+ {
+ shiftY = -wrapBounds.Size.Y;
+ }
+
+ if ( shiftX != 0 )
+ {
+ into.Add( TextureStackPlacement.At(
+ new Rect2I( rect.Position + new Vector2I( shiftX, 0 ), rect.Size ), index, rotation
+ ) );
+ }
+
+ if ( shiftY != 0 )
+ {
+ into.Add( TextureStackPlacement.At(
+ new Rect2I( rect.Position + new Vector2I( 0, shiftY ), rect.Size ), index, rotation
+ ) );
+ }
+
+ if ( shiftX != 0 && shiftY != 0 )
+ {
+ into.Add( TextureStackPlacement.At(
+ new Rect2I( rect.Position + new Vector2I( shiftX, shiftY ), rect.Size ), index, rotation
+ ) );
+ }
+ }
+ }
+}
diff --git a/Runtime/Procedural/Textures/TextureStack/TextureStackStamps.cs.uid b/Runtime/Procedural/Textures/TextureStack/TextureStackStamps.cs.uid
new file mode 100644
index 0000000..ae8cea2
--- /dev/null
+++ b/Runtime/Procedural/Textures/TextureStack/TextureStackStamps.cs.uid
@@ -0,0 +1 @@
+uid://yh2oub1hd7cr
diff --git a/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/TerrainOffsetShader.cs b/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/TerrainOffsetShader.cs
new file mode 100644
index 0000000..bc4f15d
--- /dev/null
+++ b/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/TerrainOffsetShader.cs
@@ -0,0 +1,25 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** The uniform names GeometryTerrainOffset declares, as property names.
+
+ Anything that feeds a terrain material - HeightMapGenerator today - sets
+ them through here rather than with string literals, so the names exist in
+ exactly two places: the uniform block in GeometryTerrainOffset and this
+ class. The shader include that consumes them is
+ Shading/Library/Terrain.gdshaderinc.
+ */
+ public class TerrainOffsetShader
+ {
+ public static readonly Sampler2DPropertyName heightMap = Sampler2DPropertyName.Create( "terrainHeightMap" );
+ public static readonly Vector2PropertyName centerXZ = Vector2PropertyName.Create( "terrainCenterXZ" );
+ public static readonly Vector2PropertyName sizeXZ = Vector2PropertyName.Create( "terrainSizeXZ" );
+ public static readonly FloatPropertyName minHeight = FloatPropertyName.Create( "terrainMinHeight" );
+ public static readonly FloatPropertyName maxHeight = FloatPropertyName.Create( "terrainMaxHeight" );
+
+ public static readonly Sampler2DPropertyName normalMap = Sampler2DPropertyName.Create( "terrainNormalMap" );
+ }
+}
diff --git a/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/TerrainOffsetShader.cs.uid b/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/TerrainOffsetShader.cs.uid
new file mode 100644
index 0000000..3614fa5
--- /dev/null
+++ b/Runtime/Shading/Generators/Spatial/Geometry/TerrainOffset/TerrainOffsetShader.cs.uid
@@ -0,0 +1 @@
+uid://bso4mw30dckec
diff --git a/Runtime/Time/TimelineRunner.cs b/Runtime/Time/TimelineRunner.cs
index 0a9f96f..bab742f 100644
--- a/Runtime/Time/TimelineRunner.cs
+++ b/Runtime/Time/TimelineRunner.cs
@@ -146,33 +146,26 @@ public class TimelineRunner
for ( int i = 0; i < events.Count; i++ )
{
- var eventPosition = events[ i ].position;
-
- if ( events[ i ].looping )
- {
- var next = events[ i ].GetNextLoopPosition( lastPosition );
- var previous = events[ i ].GetPreviousLoopPosition( position );
+ var timelineEvent = events[ i ];
- if ( next != previous )
- {
- continue;
- }
-
- eventPosition = next;
- }
-
- if ( ! RangeDouble.ContainsExclusiveMax( lastPosition, position, eventPosition ) )
+ if ( timelineEvent.looping )
{
- if ( events[ i ].wasInside )
+ ProcessLoopingEvent( timelineEvent );
+ continue;
+ }
+
+ if ( ! RangeDouble.ContainsExclusiveMax( lastPosition, position, timelineEvent.position ) )
+ {
+ if ( timelineEvent.wasInside )
{
- eventRemovals = AddRemoval( events[ i ].persistent, i, eventRemovals );
+ eventRemovals = AddRemoval( timelineEvent.persistent, i, eventRemovals );
}
continue;
}
- events[ i ].callback( events[ i ] );
- events[ i ].wasInside = true;
+ timelineEvent.callback( timelineEvent );
+ timelineEvent.wasInside = true;
}
@@ -184,8 +177,36 @@ public class TimelineRunner
}
+ // Upper bound of callbacks a single looping event can emit in one frame, so a huge
+ // frame spike or a jumped timeline position can't stall the game with catching up.
+ const int maxLoopCallbacksPerFrame = 128;
+
+ // Fires once for every loop boundary inside [ lastPosition, position ). Checking for a
+ // single boundary dropped the tick completely whenever a frame was longer than the loop
+ // duration, which silently stopped loop events at low frame rates. Looping events are
+ // only removed by an explicit RemoveEvent, never by elapsing.
+ void ProcessLoopingEvent( TimelineEvent timelineEvent )
+ {
+ if ( timelineEvent.loopDuration <= 0 )
+ {
+ return;
+ }
+
+ var boundary = timelineEvent.GetNextLoopPosition( lastPosition );
+ var numCallbacks = 0;
+
+ while ( boundary < position && numCallbacks < maxLoopCallbacksPerFrame )
+ {
+ timelineEvent.callback( timelineEvent );
+ timelineEvent.wasInside = true;
+
+ boundary += timelineEvent.loopDuration;
+ numCallbacks ++;
+ }
+ }
+
void ProcessSpans()
- {
+ {
if ( spans.Count == 0 )
{
return;
diff --git a/Tools/Editor/NodeDuplicator.cs b/Tools/Editor/NodeDuplicator.cs
index 9933c8b..15fcdb6 100644
--- a/Tools/Editor/NodeDuplicator.cs
+++ b/Tools/Editor/NodeDuplicator.cs
@@ -11,8 +11,24 @@ namespace Rokojori.Tools
// duplicated subtree (parent, siblings, cousins, ...) keep pointing at the original
// external target instead of being nulled out. Members pointing inside the subtree are
// remapped to the corresponding duplicated node, same as native duplicate already does.
+ //
+ // Scene instances (nodes with a SceneFilePath, like an instanced Cyli.tscn) are not
+ // copied node by node, they are re-instantiated from their scene with the editor edit
+ // state, so the copy stays an instance of that scene instead of being flattened into
+ // the edited scene. Node pairing is done by relative path, never by iteration index,
+ // because the copy's subtree can legitimately differ from the live one in the editor.
public static class NodeDuplicator
{
+ static readonly HashSet nonCopyableProperties = new HashSet()
+ {
+ "name",
+ "owner",
+ "script",
+ "scene_file_path",
+ "unique_name_in_owner",
+ "multiplayer"
+ };
+
public static Node DuplicateDeep( Node original )
{
if ( original == null )
@@ -28,52 +44,216 @@ namespace Rokojori.Tools
return null;
}
- var duplicate = original.Duplicate( (int) Node.DuplicateFlags.Default );
+ var originalNodes = GetSubtree( original );
+ var duplicate = CreateCopy( original );
- parent.AddChild( duplicate );
+ if ( duplicate == null )
+ {
+ return null;
+ }
+
+ parent.AddChild( duplicate, true );
parent.MoveChild( duplicate, original.GetIndex() + 1 );
- SetOwnerRecursively( duplicate, original.Owner );
- RemapExternalReferences( original, duplicate );
+ var map = MapByPath( original, originalNodes, duplicate );
+
+ AddMissingNodes( original, originalNodes, duplicate, map );
+ AssignOwners( original, originalNodes, map );
+ RemapExternalReferences( originalNodes, map );
duplicate.LogInfo( "Duplicated", original.Name, "as", duplicate.Name );
return duplicate;
}
- static void SetOwnerRecursively( Node node, Node sceneOwner )
+ static List GetSubtree( Node root )
{
- if ( sceneOwner == null )
+ var nodes = new List();
+ NodesWalker.Get().Iterate( root, n => nodes.Add( n ), false );
+ return nodes;
+ }
+
+ static Node CreateCopy( Node original )
+ {
+ var scenePath = original.SceneFilePath;
+
+ if ( string.IsNullOrEmpty( scenePath ) )
{
- return;
+ return original.Duplicate( (int) Node.DuplicateFlags.Default );
}
- node.Owner = sceneOwner;
+ var scene = ResourceLoader.Load( scenePath );
- foreach ( Node child in node.GetChildren() )
+ if ( scene == null )
{
- SetOwnerRecursively( child, sceneOwner );
+ original.LogError( "Couldn't load scene for duplication:", scenePath, "using plain duplicate." );
+ return original.Duplicate( (int) Node.DuplicateFlags.Default );
+ }
+
+ var copy = scene.Instantiate( PackedScene.GenEditState.Instance );
+
+ if ( copy == null )
+ {
+ original.LogError( "Couldn't instantiate scene for duplication:", scenePath );
+ return null;
+ }
+
+ copy.Name = original.Name;
+ CopyStoredProperties( original, copy );
+
+ return copy;
+ }
+
+ // An instanced scene only stores overrides on top of its scene, so the freshly
+ // instantiated copy has to receive the original's own property values.
+ static void CopyStoredProperties( Node original, Node copy )
+ {
+ foreach ( var propertyInfo in original.GetPropertyList() )
+ {
+ var dict = (Godot.Collections.Dictionary) propertyInfo;
+
+ if ( ! IsStored( dict ) )
+ {
+ continue;
+ }
+
+ var name = dict[ "name" ].AsString();
+
+ if ( string.IsNullOrEmpty( name ) || nonCopyableProperties.Contains( name ) )
+ {
+ continue;
+ }
+
+ copy.Set( name, original.Get( name ) );
}
}
- static void RemapExternalReferences( Node originalRoot, Node duplicateRoot )
+ // Pairs original nodes with copied nodes by their path relative to the subtree root.
+ // Nodes missing in the copy are left out instead of shifting all following pairs.
+ static Dictionary MapByPath( Node originalRoot, List originalNodes, Node duplicateRoot )
{
- var originalNodes = new List();
- var duplicateNodes = new List();
+ var map = new Dictionary();
- NodesWalker.Get().Iterate( originalRoot, n => originalNodes.Add( n ), false );
- NodesWalker.Get().Iterate( duplicateRoot, n => duplicateNodes.Add( n ), false );
-
- var originalToDuplicate = new Dictionary();
-
- for ( var i = 0; i < originalNodes.Count && i < duplicateNodes.Count; i++ )
+ foreach ( var node in originalNodes )
{
- originalToDuplicate[ originalNodes[ i ] ] = duplicateNodes[ i ];
+ if ( node == originalRoot )
+ {
+ map[ node ] = duplicateRoot;
+ continue;
+ }
+
+ var counterpart = duplicateRoot.GetNodeOrNull( originalRoot.GetPathTo( node ) );
+
+ if ( counterpart == null )
+ {
+ continue;
+ }
+
+ map[ node ] = counterpart;
}
- for ( var i = 0; i < originalNodes.Count; i++ )
+ return map;
+ }
+
+ // Nodes that only exist in the live original, like children created at editor runtime
+ // by tool scripts or nodes added to a non editable instance, are not part of the
+ // re-instantiated copy. They get recreated at the same place so the copy matches the
+ // original and references to them can be remapped.
+ static void AddMissingNodes( Node originalRoot, List originalNodes, Node duplicateRoot, Dictionary map )
+ {
+ var added = new List();
+ var unresolved = new List();
+
+ // originalNodes is in pre order, so a parent is always recreated before its children.
+ foreach ( var original in originalNodes )
{
- RemapNodeExports( originalNodes[ i ], duplicateNodes[ i ], originalToDuplicate );
+ if ( map.ContainsKey( original ) )
+ {
+ continue;
+ }
+
+ var relativePath = originalRoot.GetPathTo( original ).ToString();
+ var counterpart = duplicateRoot.GetNodeOrNull( relativePath );
+
+ if ( counterpart != null )
+ {
+ // Came in as a child of an already recreated node.
+ map[ original ] = counterpart;
+ continue;
+ }
+
+ var originalParent = original.GetParent();
+
+ if ( originalParent == null || ! map.TryGetValue( originalParent, out var duplicateParent ) )
+ {
+ unresolved.Add( relativePath );
+ continue;
+ }
+
+ var copy = CreateCopy( original );
+
+ if ( copy == null )
+ {
+ unresolved.Add( relativePath );
+ continue;
+ }
+
+ copy.Name = original.Name;
+ duplicateParent.AddChild( copy );
+ duplicateParent.MoveChild( copy, Mathf.Min( original.GetIndex(), duplicateParent.GetChildCount() - 1 ) );
+
+ map[ original ] = copy;
+ added.Add( relativePath );
+ }
+
+ if ( added.Count > 0 )
+ {
+ duplicateRoot.LogInfo(
+ "Recreated", added.Count, "nodes missing in the copy:", string.Join( ", ", added )
+ );
+ }
+
+ if ( unresolved.Count > 0 )
+ {
+ duplicateRoot.LogError(
+ "Couldn't recreate", unresolved.Count, "nodes, references to them are kept as is:",
+ string.Join( ", ", unresolved )
+ );
+ }
+ }
+
+ // Mirrors the original's ownership instead of owning everything, so the inner nodes
+ // of an instanced scene stay owned by their instance root.
+ static void AssignOwners( Node originalRoot, List originalNodes, Dictionary map )
+ {
+ foreach ( var original in originalNodes )
+ {
+ if ( ! map.TryGetValue( original, out var duplicate ) )
+ {
+ continue;
+ }
+
+ var owner = original == originalRoot ? originalRoot.Owner : original.Owner;
+
+ if ( owner == null )
+ {
+ continue;
+ }
+
+ duplicate.Owner = Remap( owner, map );
+ }
+ }
+
+ static void RemapExternalReferences( List originalNodes, Dictionary map )
+ {
+ foreach ( var original in originalNodes )
+ {
+ if ( ! map.TryGetValue( original, out var duplicate ) )
+ {
+ continue;
+ }
+
+ RemapNodeExports( original, duplicate, map );
}
}
@@ -135,12 +315,7 @@ namespace Rokojori.Tools
return;
}
- var remapped = Remap( targetNode, map );
-
- if ( remapped != targetNode )
- {
- duplicate.Set( name, remapped );
- }
+ duplicate.Set( name, Remap( targetNode, map ) );
}
static void RemapArrayProperty( Node original, Node duplicate, string name, Dictionary map )
@@ -153,7 +328,7 @@ namespace Rokojori.Tools
}
var array = value.AsGodotArray();
- var changed = false;
+ var containsNodes = false;
for ( var i = 0; i < array.Count; i++ )
{
@@ -164,16 +339,11 @@ namespace Rokojori.Tools
continue;
}
- var remapped = Remap( elementNode, map );
-
- if ( remapped != elementNode )
- {
- array[ i ] = remapped;
- changed = true;
- }
+ array[ i ] = Remap( elementNode, map );
+ containsNodes = true;
}
- if ( changed )
+ if ( containsNodes )
{
duplicate.Set( name, array );
}
@@ -181,7 +351,7 @@ namespace Rokojori.Tools
static Node Remap( Node originalTarget, Dictionary map )
{
- if ( map.TryGetValue( originalTarget, out var mapped ) )
+ if ( originalTarget != null && map.TryGetValue( originalTarget, out var mapped ) )
{
return mapped;
}