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