Height map generator: HeightMapGenerator, draw/generator layer bases, circle primitive, shape shader, TerrainOffsetShader

Also lands the TextureStack compositor and TextureSettings, which were built
but never committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-10-28 21:47:04 +01:00
parent 211613e06b
commit f86625e1fb
73 changed files with 6216 additions and 68 deletions

View File

@ -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 ) );

View File

@ -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;
}
}
}

View File

@ -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 <marked>native</marked> 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 <marked>absolute</marked>
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;
}
}
}

View File

@ -0,0 +1 @@
uid://4rxkgyiwl0jh

View File

@ -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 <marked>moving it in the
3D viewport</marked> - the generator converts world XZ to texels, never the
other way round, and nothing in the system has a texel-space authoring
concept at all.
Height is stored normalized in the R channel and decoded by
addTerrainOffset ( Shading/Library/Terrain.gdshaderinc ) as
mix( minHeight, maxHeight, r ). minHeight/maxHeight are therefore part of
the contract between this node and the terrain material, which is what
PushToMaterials() exists to keep in sync.
Rendering is flag-driven, not immediate: a layer that moves or changes
calls SetDirty(), and the next _Process re-renders once. Several edits in
one frame - dragging a node, which moves it and re-emits every frame -
collapse into a single render. The render itself is GPU blits only, so at
the sizes this is meant for it is affordable per frame, which is what makes
dragging a layer around feel live in the editor.
Layers are collected by walking the children, so <marked>nesting is
grouping</marked>: put layers under a plain Node3D to move them together.
A nested HeightMapGenerator stops the walk - it owns its own map.
*/
[Tool]
[GlobalClass]
public partial class HeightMapGenerator:Node3D
{
static readonly Color White = new Color( 1, 1, 1, 1 );
static readonly Color Transparent = new Color( 0, 0, 0, 0 );
[ExportToolButton( "Render" )]
public Callable RenderButton => Callable.From( () => Render() );
Vector2 _size = new Vector2( 100, 100 );
/** The map's extent in world meters, on X and Z. */
[Export]
public Vector2 size
{
get => _size;
set { _size = value; SetDirty(); }
}
Vector2I _resolution = new Vector2I( 512, 512 );
/** The map's texture size. Combined with `size` this is the texel density -
512 over 100 m is roughly 0.2 m per texel.
*/
[Export]
public Vector2I resolution
{
get => _resolution;
set { _resolution = value; SetDirty(); }
}
float _minHeight = 0;
/** World height a stored 0 decodes to. Must match terrainMinHeight on the
material - PushToMaterials() does that.
*/
[Export]
public float minHeight
{
get => _minHeight;
set { _minHeight = value; SetDirty(); }
}
float _maxHeight = 100;
/** World height a stored 1 decodes to. Everything a layer writes is a
fraction of maxHeight - minHeight, so widening the range flattens the
existing layers rather than rescaling them.
*/
[Export]
public float maxHeight
{
get => _maxHeight;
set { _maxHeight = value; SetDirty(); }
}
float _baseHeight = 0;
/** The world height the map starts at before any layer draws. */
[Export]
public float baseHeight
{
get => _baseHeight;
set { _baseHeight = value; SetDirty(); }
}
TextureStackFormat _format = TextureStackFormat.RgbaHalf;
/** RgbaHalf by default, not Rgba8: 8 bits give 256 height steps over the
whole min-to-max range, which terraces visibly on any slope. Half is
smooth and still only 8 bytes per texel.
*/
[Export]
public TextureStackFormat format
{
get => _format;
set { _format = value; SetDirty(); }
}
/** The map itself. A DrawableTexture2D because that is what can be blitted
into; it is still a plain Texture2D to everything that samples it, so it
goes straight into a material's sampler slot.
Left empty, one is created on the first render - but then only this node
holds it, so the material has to be fed through `materials` below. Assign
a shared DrawableTexture2D here to reference the same map from a material
in the scene file instead.
*/
[Export]
public DrawableTexture2D heightMapTarget;
/** 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<HeightMapDrawLayer> _layers = new List<HeightMapDrawLayer>();
bool _rendering = false;
/** Clears the map to baseHeight and lets every visible layer underneath draw
into it, in tree order - so the layer listed last wins, the same way the
3D viewport draws it.
*/
public void Render()
{
// A layer that calls SetDirty() from inside its own draw - a transform
// notification from a tool script, say - would otherwise recurse.
if ( _rendering )
{
return;
}
_dirty = false;
_rendering = true;
try
{
PrepareTarget();
_layers.Clear();
CollectLayers( this, _layers );
renderedLayers = 0;
for ( var i = 0; i < _layers.Count; i++ )
{
var layer = _layers[ i ];
if ( ! layer.IsVisibleInTree() )
{
continue;
}
layer.RenderToHeightMap( this );
renderedLayers++;
}
PushToMaterials();
}
finally
{
_rendering = false;
}
}
void CollectLayers( Node node, List<HeightMapDrawLayer> into )
{
foreach ( var child in node.GetChildren() )
{
// Its own map, its own layers.
if ( child is HeightMapGenerator )
{
continue;
}
if ( child is HeightMapDrawLayer layer )
{
into.Add( layer );
}
CollectLayers( child, into );
}
}
/** One blit per layer pair: the shape into a rect-sized scratch buffer with
blending disabled, then the scratch onto the map with the blend mode's
hardware blend.
The scratch buffer is rect-sized rather than full-size because BlitRect
stretches its whole source across the destination rect - a full-size
source would be squeezed into the rect.
Called by layers after they have set up `shapeMaterial`; the rect and
blend-mode uniforms are set here, so a layer cannot get the world mapping
wrong.
*/
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();
}
}
}

View File

@ -0,0 +1 @@
uid://bgllq0x0hjmep

View File

@ -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 );
}
}
}

View File

@ -0,0 +1 @@
uid://47vr2c471q1l

View File

@ -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 <marked>when
</marked> 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<HeightMapGenerator>( 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();
}
}
}
}

View File

@ -0,0 +1 @@
uid://kdfvmlxdb1kq

View File

@ -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 );
}
}
}

View File

@ -0,0 +1 @@
uid://cupj0jf3oelma

View File

@ -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 );
}
}

View File

@ -0,0 +1 @@
uid://w4gb1lh7afw

View File

@ -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> shader = new CachedResource<Shader>(
"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<Vector2> rectWorldMin;
public readonly CustomMaterialProperty<Vector2> rectWorldSize;
public readonly CustomMaterialProperty<int> shapeType;
public readonly CustomMaterialProperty<int> blendMode;
public readonly CustomMaterialProperty<float> value;
public readonly CustomMaterialProperty<Vector2> center;
public readonly CustomMaterialProperty<float> innerRadius;
public readonly CustomMaterialProperty<float> outerRadius;
public readonly CustomMaterialProperty<bool> smoothFalloff;
public HeightMapShapeMaterial()
{
Shader = HeightMapShapeShader.shader.Get();
rectWorldMin = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.rectWorldMin );
rectWorldSize = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.rectWorldSize );
shapeType = new CustomMaterialProperty<int>( this, HeightMapShapeShader.shapeType );
blendMode = new CustomMaterialProperty<int>( this, HeightMapShapeShader.blendMode );
value = new CustomMaterialProperty<float>( this, HeightMapShapeShader.value );
center = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.center );
innerRadius = new CustomMaterialProperty<float>( this, HeightMapShapeShader.innerRadius );
outerRadius = new CustomMaterialProperty<float>( this, HeightMapShapeShader.outerRadius );
smoothFalloff = new CustomMaterialProperty<bool>( 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 );
}
}
}

View File

@ -0,0 +1 @@
uid://bq4wwxf5ixnjg

View File

@ -12,7 +12,7 @@ namespace Rokojori
{
[Tool]
[GlobalClass]
public partial class TextureCombinerLayer:Resource
public abstract partial class TextureCombinerLayer:Resource
{
[Export]
public TextureCombinerBlendMode blendMode;

View File

@ -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.
<marked>Leaving the slot empty means Linear sampling and no
conversion</marked>, 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 );
}
}
}

View File

@ -0,0 +1 @@
uid://b1i3cv4rrjdxa

View File

@ -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();
}
}
}
}

View File

@ -0,0 +1 @@
uid://gq6m51ydiqc6

View File

@ -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 <marked>defines a file as its target and
always writes its result there</marked>.
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. <code>bakeOnChange</code> 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 <marked>load its file</marked>, 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<Resource> _hookedResources = new List<Resource>();
readonly List<Callable> _hookedCallables = new List<Callable>();
readonly List<TextureStackChangeSource> _changeSources
= new List<TextureStackChangeSource>();
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();
}
}
}

View File

@ -0,0 +1 @@
uid://dyi4x53ie6gcc

View File

@ -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. <marked>Set blendMode to Add</marked> - 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
<marked>content</marked> 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 <marked>width</marked>, 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<Texture2D>(){ source };
destination.BlitRectMulti(
new Rect2I( 0, 0, size.X, size.Y ), sources,
TextureStackCompositor.noExtraTargets, modulate, 0,
context.compositor.effectMaterial
);
}
}
}

View File

@ -0,0 +1 @@
uid://djjpt5nrk5gb1

View File

@ -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 <marked>resource of its own</marked> 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 <marked>no texture resource at
all</marked> - 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<Texture2D>(){ 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<TextureStackChangeSource> 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;
}
}
}
}

View File

@ -0,0 +1 @@
uid://dfa7mpf2mi2hg

View File

@ -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<Texture2D>(){ 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<TextureStackChangeSource> 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 );
}
}
}

View File

@ -0,0 +1 @@
uid://qbwnefncnum1

View File

@ -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 );
}

View File

@ -0,0 +1 @@
uid://rn8ao07hwquw

View File

@ -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 );
}

View File

@ -0,0 +1 @@
uid://0f1jqpr7f0xx

View File

@ -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> shader = new CachedResource<Shader>(
"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<Vector2> targetSize;
public readonly CustomMaterialProperty<Vector2> rectOffset;
public readonly CustomMaterialProperty<Vector4> placementUVRect;
public readonly CustomMaterialProperty<int> blendMode;
public readonly CustomMaterialProperty<float> opacity;
public readonly CustomMaterialProperty<bool> clipToBelow;
readonly CustomMaterialProperty<bool>[] _maskEnabled;
readonly CustomMaterialProperty<Vector4>[] _maskTransform;
readonly CustomMaterialProperty<Vector4>[] _maskParameters;
readonly CustomMaterialProperty<int>[] _maskChannel;
readonly CustomMaterialProperty<bool>[] _maskInvert;
readonly CustomMaterialProperty<int>[] _maskSampling;
readonly CustomMaterialProperty<int>[] _maskColorSpace;
public int maskSlots => _maskEnabled.Length;
public TextureStackCompositeMaterial()
{
Shader = TextureStackCompositeShader.shader.Get();
targetSize = new CustomMaterialProperty<Vector2>( this, TextureStackCompositeShader.targetSize );
rectOffset = new CustomMaterialProperty<Vector2>( this, TextureStackCompositeShader.rectOffset );
placementUVRect = new CustomMaterialProperty<Vector4>( this, TextureStackCompositeShader.placementUVRect );
blendMode = new CustomMaterialProperty<int>( this, TextureStackCompositeShader.blendMode );
opacity = new CustomMaterialProperty<float>( this, TextureStackCompositeShader.opacity );
clipToBelow = new CustomMaterialProperty<bool>( this, TextureStackCompositeShader.clipToBelow );
_maskEnabled = new CustomMaterialProperty<bool>[]
{
new CustomMaterialProperty<bool>( this, TextureStackCompositeShader.mask0Enabled ),
new CustomMaterialProperty<bool>( this, TextureStackCompositeShader.mask1Enabled )
};
_maskTransform = new CustomMaterialProperty<Vector4>[]
{
new CustomMaterialProperty<Vector4>( this, TextureStackCompositeShader.mask0Transform ),
new CustomMaterialProperty<Vector4>( this, TextureStackCompositeShader.mask1Transform )
};
_maskParameters = new CustomMaterialProperty<Vector4>[]
{
new CustomMaterialProperty<Vector4>( this, TextureStackCompositeShader.mask0Parameters ),
new CustomMaterialProperty<Vector4>( this, TextureStackCompositeShader.mask1Parameters )
};
_maskChannel = new CustomMaterialProperty<int>[]
{
new CustomMaterialProperty<int>( this, TextureStackCompositeShader.mask0Channel ),
new CustomMaterialProperty<int>( this, TextureStackCompositeShader.mask1Channel )
};
_maskInvert = new CustomMaterialProperty<bool>[]
{
new CustomMaterialProperty<bool>( this, TextureStackCompositeShader.mask0Invert ),
new CustomMaterialProperty<bool>( this, TextureStackCompositeShader.mask1Invert )
};
_maskSampling = new CustomMaterialProperty<int>[]
{
new CustomMaterialProperty<int>( this, TextureStackCompositeShader.mask0Sampling ),
new CustomMaterialProperty<int>( this, TextureStackCompositeShader.mask1Sampling )
};
_maskColorSpace = new CustomMaterialProperty<int>[]
{
new CustomMaterialProperty<int>( this, TextureStackCompositeShader.mask0ColorSpace ),
new CustomMaterialProperty<int>( 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 ) );
}
}
}

View File

@ -0,0 +1 @@
uid://d227a7ijp6nso

View File

@ -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;
}
}

View File

@ -0,0 +1 @@
uid://cg27yqywuaegx

View File

@ -0,0 +1,90 @@
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
public class TextureStackContentShader
{
public static readonly CachedResource<Shader> shader = new CachedResource<Shader>(
"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<bool> contentHasTexture;
public readonly CustomMaterialProperty<Vector2> contentOffset;
public readonly CustomMaterialProperty<Vector2> contentScale;
public readonly CustomMaterialProperty<float> contentRotation;
public readonly CustomMaterialProperty<bool> contentRepeat;
public readonly CustomMaterialProperty<Color> contentTint;
public readonly CustomMaterialProperty<int> contentSampling;
public readonly CustomMaterialProperty<int> contentColorSpace;
public readonly CustomMaterialProperty<Vector4> placementUVRect;
public TextureStackContentMaterial()
{
Shader = TextureStackContentShader.shader.Get();
contentHasTexture = new CustomMaterialProperty<bool>( this, TextureStackContentShader.contentHasTexture );
contentOffset = new CustomMaterialProperty<Vector2>( this, TextureStackContentShader.contentOffset );
contentScale = new CustomMaterialProperty<Vector2>( this, TextureStackContentShader.contentScale );
contentRotation = new CustomMaterialProperty<float>( this, TextureStackContentShader.contentRotation );
contentRepeat = new CustomMaterialProperty<bool>( this, TextureStackContentShader.contentRepeat );
contentTint = new CustomMaterialProperty<Color>( this, TextureStackContentShader.contentTint );
contentSampling = new CustomMaterialProperty<int>( this, TextureStackContentShader.contentSampling );
contentColorSpace = new CustomMaterialProperty<int>( this, TextureStackContentShader.contentColorSpace );
placementUVRect = new CustomMaterialProperty<Vector4>( 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 ) );
}
}
}

View File

@ -0,0 +1 @@
uid://v68c1dde2d3g

View File

@ -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;
}

View File

@ -0,0 +1 @@
uid://cn67f7ce24xs2

View File

@ -0,0 +1,117 @@
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
public class TextureStackEffectShader
{
public static readonly CachedResource<Shader> shader = new CachedResource<Shader>(
"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<int> passMode;
public readonly CustomMaterialProperty<Vector2> sourceUVOffset;
public readonly CustomMaterialProperty<Vector2> sourceUVScale;
public readonly CustomMaterialProperty<bool> sourceRepeat;
public readonly CustomMaterialProperty<Vector2> blurDirection;
public readonly CustomMaterialProperty<float> blurRadiusPixels;
public readonly CustomMaterialProperty<int> blurSamples;
public readonly CustomMaterialProperty<float> brightThreshold;
public readonly CustomMaterialProperty<float> brightKnee;
public TextureStackEffectMaterial()
{
Shader = TextureStackEffectShader.shader.Get();
passMode = new CustomMaterialProperty<int>( this, TextureStackEffectShader.passMode );
sourceUVOffset = new CustomMaterialProperty<Vector2>( this, TextureStackEffectShader.sourceUVOffset );
sourceUVScale = new CustomMaterialProperty<Vector2>( this, TextureStackEffectShader.sourceUVScale );
sourceRepeat = new CustomMaterialProperty<bool>( this, TextureStackEffectShader.sourceRepeat );
blurDirection = new CustomMaterialProperty<Vector2>( this, TextureStackEffectShader.blurDirection );
blurRadiusPixels = new CustomMaterialProperty<float>( this, TextureStackEffectShader.blurRadiusPixels );
blurSamples = new CustomMaterialProperty<int>( this, TextureStackEffectShader.blurSamples );
brightThreshold = new CustomMaterialProperty<float>( this, TextureStackEffectShader.brightThreshold );
brightKnee = new CustomMaterialProperty<float>( 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 ) );
}
}
}

View File

@ -0,0 +1 @@
uid://dh6akknoyhn7w

View File

@ -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.
<marked>Layer order: layers[ 0 ] is the top layer</marked> - 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<TextureStackChangeSource> 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 );
}
}
}
}

View File

@ -0,0 +1 @@
uid://daqj0w2xw168d

View File

@ -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 <code>GetImage()</code> works directly on
a DrawableTexture2D, so none of the fallbacks the plan budgeted for
(RenderingServer.Texture2DGet, a viewport quad) are needed.
- Writing. <code>Procedural/Baking/Textures.Save</code> 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<Texture2D>( path );
if ( texture != null )
{
return texture.GetImage();
}
}
if ( ! FileAccess.FileExists( path ) )
{
return null;
}
return Image.LoadFromFile( path );
}
}
}

View File

@ -0,0 +1 @@
uid://dbkcat1h845do

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
uid://c0r4tu6bmabi

View File

@ -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<Key,List<DrawableTexture2D>> _free = new Dictionary<Key,List<DrawableTexture2D>>();
/** 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<DrawableTexture2D,Key> _keys = new Dictionary<DrawableTexture2D,Key>();
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<DrawableTexture2D> 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<DrawableTexture2D> buffers;
if ( ! _free.TryGetValue( key, out buffers ) )
{
buffers = new List<DrawableTexture2D>();
_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();
}
}
}

View File

@ -0,0 +1 @@
uid://ifil1yg3eyou

View File

@ -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: <marked>Godot does not emit `changed` on a
scripted Resource when the inspector edits a plain [Export] field.</marked>
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<TextureStackChangeSource> 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 } );
}
}
}

View File

@ -0,0 +1 @@
uid://c0bhsw3kns2ot

View File

@ -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<DrawableTexture2D> noExtraTargets
= new Godot.Collections.Array<DrawableTexture2D>();
/** 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<Texture2D>(){ 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 <marked>reallocating makes the engine
emit the resource's own changed signal</marked> - 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<TextureStackPlacement> _placements = new List<TextureStackPlacement>();
/** 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();
}
}
}

View File

@ -0,0 +1 @@
uid://s5gkdrpi8u2x

View File

@ -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.
<marked>targets is a list from day one</marked>, 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<DrawableTexture2D> targets = new List<DrawableTexture2D>();
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;
}
}

View File

@ -0,0 +1 @@
uid://bu7ndgqew47v

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
uid://plhfl6al3lgm

View File

@ -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.
<marked>Every exported value here is a property that emits changed</marked>,
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 <marked>unclipped</marked> -
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<TextureStackPlacement> 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<TextureStackChangeSource> 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<TextureStackChangeSource> into,
Texture2D texture, int drawIndex
)
{
if ( texture == null )
{
return;
}
TextureStackChangeSource.Add( into, texture, drawIndex );
}
public List<TextureStackMask> GetUsableMasks()
{
var usable = new List<TextureStackMask>();
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;
}
}
}

View File

@ -0,0 +1 @@
uid://314mvum3x1j2

View File

@ -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 );
}
}
}
}

View File

@ -0,0 +1 @@
uid://dsesss2cnv80v

View File

@ -0,0 +1,48 @@
using Godot;
using System;
using Rokojori.Extensions;
namespace Rokojori
{
/** One switch for every `changed` signal the TextureStack data resources
emit.
<marked>Off by default, and that is the point.</marked> 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();
}
}
}

View File

@ -0,0 +1 @@
uid://cmws0ew0o67bk

View File

@ -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 <code>rect</code> <marked>unclipped</marked> 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;
}
}
}

View File

@ -0,0 +1 @@
uid://t7saxmb5iqlb

View File

@ -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 <marked>output</marked>, 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<TextureStackPlacement> 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<TextureStackPlacement> 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
) );
}
}
}
}

View File

@ -0,0 +1 @@
uid://yh2oub1hd7cr

View File

@ -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" );
}
}

View File

@ -0,0 +1 @@
uid://bso4mw30dckec

View File

@ -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;

View File

@ -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<string> nonCopyableProperties = new HashSet<string>()
{
"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<Node> GetSubtree( Node root )
{
if ( sceneOwner == null )
var nodes = new List<Node>();
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<PackedScene>( 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<Node>( 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<Node,Node> MapByPath( Node originalRoot, List<Node> originalNodes, Node duplicateRoot )
{
var originalNodes = new List<Node>();
var duplicateNodes = new List<Node>();
var map = new Dictionary<Node,Node>();
NodesWalker.Get().Iterate( originalRoot, n => originalNodes.Add( n ), false );
NodesWalker.Get().Iterate( duplicateRoot, n => duplicateNodes.Add( n ), false );
var originalToDuplicate = new Dictionary<Node, Node>();
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<Node> originalNodes, Node duplicateRoot, Dictionary<Node,Node> map )
{
var added = new List<string>();
var unresolved = new List<string>();
// 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<Node> originalNodes, Dictionary<Node,Node> 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<Node> originalNodes, Dictionary<Node,Node> 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<Node, Node> 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<Node, Node> map )
{
if ( map.TryGetValue( originalTarget, out var mapped ) )
if ( originalTarget != null && map.TryGetValue( originalTarget, out var mapped ) )
{
return mapped;
}