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:
parent
211613e06b
commit
f86625e1fb
|
|
@ -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 ) );
|
||||
|
|
|
|||
|
|
@ -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 )
|
||||
if ( onFloor || inAirMovementSmoothing == null )
|
||||
{
|
||||
_smoothedMovement = onFloorMovementSmoothing.Smooth( movement, delta );
|
||||
_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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://4rxkgyiwl0jh
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bgllq0x0hjmep
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://47vr2c471q1l
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://kdfvmlxdb1kq
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cupj0jf3oelma
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://w4gb1lh7afw
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bq4wwxf5ixnjg
|
||||
|
|
@ -12,7 +12,7 @@ namespace Rokojori
|
|||
{
|
||||
[Tool]
|
||||
[GlobalClass]
|
||||
public partial class TextureCombinerLayer:Resource
|
||||
public abstract partial class TextureCombinerLayer:Resource
|
||||
{
|
||||
[Export]
|
||||
public TextureCombinerBlendMode blendMode;
|
||||
|
|
|
|||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b1i3cv4rrjdxa
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://gq6m51ydiqc6
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dyi4x53ie6gcc
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://djjpt5nrk5gb1
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dfa7mpf2mi2hg
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://qbwnefncnum1
|
||||
|
|
@ -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 );
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://rn8ao07hwquw
|
||||
|
|
@ -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 );
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://0f1jqpr7f0xx
|
||||
|
|
@ -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 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://d227a7ijp6nso
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cg27yqywuaegx
|
||||
|
|
@ -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 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://v68c1dde2d3g
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cn67f7ce24xs2
|
||||
|
|
@ -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 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dh6akknoyhn7w
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://daqj0w2xw168d
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dbkcat1h845do
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c0r4tu6bmabi
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://ifil1yg3eyou
|
||||
|
|
@ -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 } );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c0bhsw3kns2ot
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://s5gkdrpi8u2x
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bu7ndgqew47v
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://plhfl6al3lgm
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://314mvum3x1j2
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dsesss2cnv80v
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cmws0ew0o67bk
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://t7saxmb5iqlb
|
||||
|
|
@ -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
|
||||
) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://yh2oub1hd7cr
|
||||
|
|
@ -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" );
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bso4mw30dckec
|
||||
|
|
@ -146,33 +146,26 @@ public class TimelineRunner
|
|||
|
||||
for ( int i = 0; i < events.Count; i++ )
|
||||
{
|
||||
var eventPosition = events[ i ].position;
|
||||
var timelineEvent = events[ i ];
|
||||
|
||||
if ( events[ i ].looping )
|
||||
if ( timelineEvent.looping )
|
||||
{
|
||||
var next = events[ i ].GetNextLoopPosition( lastPosition );
|
||||
var previous = events[ i ].GetPreviousLoopPosition( position );
|
||||
|
||||
if ( next != previous )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
eventPosition = next;
|
||||
ProcessLoopingEvent( timelineEvent );
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! RangeDouble.ContainsExclusiveMax( lastPosition, position, eventPosition ) )
|
||||
if ( ! RangeDouble.ContainsExclusiveMax( lastPosition, position, timelineEvent.position ) )
|
||||
{
|
||||
if ( events[ i ].wasInside )
|
||||
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,6 +177,34 @@ 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 )
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue