diff --git a/Runtime/Procedural/ControlMaps/BitSizeEnum.cs b/Runtime/Procedural/ControlMaps/BitSizeEnum.cs
new file mode 100644
index 0000000..261015a
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/BitSizeEnum.cs
@@ -0,0 +1,38 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** A bit width, named so the trade it makes is legible where it is edited.
+
+ A plain int range works and says nothing: "5" does not look like a choice
+ between 32 and 64 textures until someone works out that it is a power of
+ two. Every entry here carries both halves of that - the width it costs and
+ the number of distinct values it buys - so narrowing a field to pay for
+ another one is a decision made in the inspector rather than on paper.
+
+ The underlying value is the bit count, so a cast is the
+ width and no lookup table exists to get out of step with the names.
+
+ 0 bits is a legitimate choice: a field with no bits is absent, decodes to 0
+ everywhere, and costs nothing. For a texture index it means exactly one
+ texture rather than none.
+ */
+ public enum BitSizeEnum
+ {
+ Bits0_1Value = 0,
+ Bits1_2Values = 1,
+ Bits2_4Values = 2,
+ Bits3_8Values = 3,
+ Bits4_16Values = 4,
+ Bits5_32Values = 5,
+ Bits6_64Values = 6,
+ Bits7_128Values = 7,
+ Bits8_256Values = 8,
+ Bits9_512Values = 9,
+ Bits10_1024Values = 10,
+ Bits11_2048Values = 11,
+ Bits12_4096Values = 12
+ }
+}
diff --git a/Runtime/Procedural/ControlMaps/BitSizeEnum.cs.uid b/Runtime/Procedural/ControlMaps/BitSizeEnum.cs.uid
new file mode 100644
index 0000000..e5c10c4
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/BitSizeEnum.cs.uid
@@ -0,0 +1 @@
+uid://cm2junhdqbjuo
diff --git a/Runtime/Procedural/ControlMaps/ControlMapDebugPattern.cs b/Runtime/Procedural/ControlMaps/ControlMapDebugPattern.cs
new file mode 100644
index 0000000..72eb6ee
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapDebugPattern.cs
@@ -0,0 +1,53 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** What ControlMapTerrain fills a fresh control map with.
+
+ There is no painting system yet, so the map needs a source in order for
+ there to be anything to look at. These are deliberately patterns rather
+ than content: each one isolates a single thing that can be wrong, so a
+ live test says which part broke instead of only that something did.
+ */
+ public enum ControlMapDebugPattern
+ {
+ /** Texture 0 everywhere, neutral in every field. What a painting system
+ would start from, and the control that proves nothing in the decode path
+ is adding anything of its own.
+ */
+ Flat = 0,
+
+ /** Texture index cycling per cell, no blending. Hard index boundaries -
+ this is what shows whether the 4-tap read is sampling the texels it
+ thinks it is, since a half-texel error puts the seams visibly off the
+ cell grid.
+ */
+ Checkerboard = 1,
+
+ /** Texture 0 under texture 1, blend ramping along X. Isolates the
+ base/overlay crossfade and the height weighting; blendSharpness should
+ visibly change the shape of the transition and nothing else.
+ */
+ BlendGradient = 2,
+
+ /** Per-cell uv scale and rotation, one texture. Isolates the two fields
+ that are nothing but uv manipulation - and therefore the derivative
+ handling, because this is the pattern that goes blurry at every cell
+ border if textureGrad is not doing its job.
+ */
+ ScaleAndRotation = 3,
+
+ /** Holes punched in a grid, texture 0 elsewhere. Isolates the flag path,
+ which is read from a single texel and so should have perfectly square
+ edges even with bilinear on.
+ */
+ Holes = 4,
+
+ /** All four of the above in quadrants, so one look covers everything. The
+ one to start a live test with.
+ */
+ Showcase = 5
+ }
+}
diff --git a/Runtime/Procedural/ControlMaps/ControlMapDebugPattern.cs.uid b/Runtime/Procedural/ControlMaps/ControlMapDebugPattern.cs.uid
new file mode 100644
index 0000000..a3ac1a2
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapDebugPattern.cs.uid
@@ -0,0 +1 @@
+uid://da0bgh7152kh6
diff --git a/Runtime/Procedural/ControlMaps/ControlMapFormat.cs b/Runtime/Procedural/ControlMaps/ControlMapFormat.cs
new file mode 100644
index 0000000..5d079a6
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapFormat.cs
@@ -0,0 +1,432 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** How one control-map texel's 32 bits are divided up.
+
+ Terrain3D fixes this in shader macros. Here it is a Resource, for one
+ reason: it is the only description of the layout, and both
+ sides read it. The pack pass and the decoders in
+ Shading/Library/ControlMap.gdshaderinc ( through PushToMaterial ) cannot
+ drift apart, because neither of them knows an offset - they are both told.
+
+ Hard-coding the offsets would be an optimization, and the cost it buys is
+ a handful of shifts per fragment against the certainty that a layout change
+ means editing two files that have to agree.
+
+ Only bit sizes are authored. Offsets are derived by packing
+ the fields from bit 0 upwards in a fixed order, so a layout is always
+ internally valid and a widened field pushes the rest along rather than
+ silently overlapping one.
+
+ THE WIDTHS, AND WHY THEY ARE BETTER THAN TERRAIN3D'S
+ ------------------------------------------------------------------------
+ The default adds up to 27 of 32 bits - the same budget Terrain3D spends -
+ but buys 32 rotation steps instead of 16 and 16 scale steps instead of 8,
+ because the base/overlay blend drops from 8 bits to 6.
+
+ That is affordable here and not there: with the weight-stack edit format the
+ blend is derived at pack time from the two largest texture
+ weights rather than authored by hand, and 64 steps of crossfade is already
+ past what a ground transition can show. Terrain3D has to leave room for
+ someone painting a blend value directly.
+
+ The 5 spare bits are deliberately left spare. A custom channel should be
+ easy to add; none is built.
+
+ OVERFLOW IS THE USER'S PROBLEM
+ ------------------------------------------------------------------------
+ A layout past 32 bits warns and lets the high fields truncate. It does not
+ clamp and it does not rearrange, because either one would silently give the
+ user a layout they did not ask for. Safe to be this blunt because nothing
+ is ever persisted as control-map pixel data - the map is generated from the
+ layer stack every session, so re-rendering is the entire migration.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class ControlMapFormat:Resource
+ {
+ public const int TotalBits = 32;
+
+ /* THE AUTHORED WIDTHS
+ ------------------------------------------------------------------------
+ FIELD ORDER is low bits first: base, overlay, blend, rotation, scale,
+ hole, navigable. The order itself is arbitrary - nothing reads a field
+ without being told where it is - but it is fixed rather than exported,
+ because a reorderable order is a second thing that could disagree with
+ itself and buys nothing.
+ */
+
+ BitSizeEnum _numTexturesBits = BitSizeEnum.Bits5_32Values;
+
+ /** How many ground textures an index can reach, for both
+ the base and the overlay index.
+
+ One width rather than two, even though Terrain3D-style layouts sometimes
+ narrow the overlay to buy bits back. Two widths that can disagree is a
+ state where a texture is paintable as a base and not as an overlay, which
+ is a bug report waiting to happen for a few bits that the blend field
+ already gave back.
+ */
+ [Export]
+ public BitSizeEnum numTexturesBits
+ {
+ get => _numTexturesBits;
+ set { _numTexturesBits = value; EmitChanged(); }
+ }
+
+ BitSizeEnum _textureBlendBits = BitSizeEnum.Bits6_64Values;
+
+ /** Resolution of the base-to-overlay crossfade. Derived at pack time from
+ the weight stack, not authored, which is why 6 bits is enough.
+ */
+ [Export]
+ public BitSizeEnum textureBlendBits
+ {
+ get => _textureBlendBits;
+ set { _textureBlendBits = value; EmitChanged(); }
+ }
+
+ BitSizeEnum _rotationBits = BitSizeEnum.Bits5_32Values;
+
+ /** How many uv rotation steps over a full turn. 5 bits is 32 steps of
+ 11.25 degrees.
+ */
+ [Export]
+ public BitSizeEnum rotationBits
+ {
+ get => _rotationBits;
+ set { _rotationBits = value; EmitChanged(); }
+ }
+
+ BitSizeEnum _scaleBits = BitSizeEnum.Bits4_16Values;
+
+ /** How many uv scale steps. The value is scaleMin + raw * scaleStep. */
+ [Export]
+ public BitSizeEnum scaleBits
+ {
+ get => _scaleBits;
+ set { _scaleBits = value; EmitChanged(); }
+ }
+
+ bool _holeFlag = true;
+
+ /** Whether the terrain is absent here. The consumer discards or writes a
+ NaN vertex; see the preview shader.
+
+ A bool rather than a width, because a 3-bit hole flag was never a
+ meaningful thing to be able to ask for.
+ */
+ [Export]
+ public bool holeFlag
+ {
+ get => _holeFlag;
+ set { _holeFlag = value; EmitChanged(); }
+ }
+
+ bool _navigationFlag = true;
+
+ /** Whether the terrain is walkable here. Never reaches the shader's output -
+ it is read by the navmesh bake and by the flag read in ControlMap_shade.
+ */
+ [Export]
+ public bool navigationFlag
+ {
+ get => _navigationFlag;
+ set { _navigationFlag = value; EmitChanged(); }
+ }
+
+ [ExportGroup( "Scale Mapping" )]
+
+ float _scaleMin = 1;
+
+ /** The uv multiplier a raw scale of 0 means. Deliberately 1 by default, so
+ an all-zero control map - which is what a freshly allocated one is -
+ renders every texture at its authored size rather than at some arbitrary
+ step. Every other field is neutral at 0 for the same reason.
+ */
+ [Export]
+ public float scaleMin
+ {
+ get => _scaleMin;
+ set { _scaleMin = value; EmitChanged(); }
+ }
+
+ float _scaleStep = 0.25f;
+
+ /** How much one scale step adds to the multiplier. With the default 4 bits
+ and 0.25, the range is 1.0 to 4.75 - finer tiling only. Set a negative
+ step with a higher scaleMin to tile coarser instead.
+ */
+ [Export]
+ public float scaleStep
+ {
+ get => _scaleStep;
+ set { _scaleStep = value; EmitChanged(); }
+ }
+
+
+ /* DERIVED WIDTHS
+ ------------------------------------------------------------------------
+ The authored values as plain bit counts. Named ...BitCount throughout so
+ there is never a question of which of the two a name means.
+ */
+
+ public int baseBitCount => (int) numTexturesBits;
+ public int overlayBitCount => (int) numTexturesBits;
+ public int blendBitCount => (int) textureBlendBits;
+ public int rotationBitCount => (int) rotationBits;
+ public int scaleBitCount => (int) scaleBits;
+ public int holeBitCount => holeFlag ? 1 : 0;
+ public int navigableBitCount => navigationFlag ? 1 : 0;
+
+
+ /* DERIVED OFFSETS
+ ------------------------------------------------------------------------
+ Each field is ( offset, bits ), which is exactly the ivec2 the shader
+ takes. A bit count of 0 gives a field the shader reads as absent.
+ */
+
+ public int baseOffset => 0;
+ public int overlayOffset => baseOffset + baseBitCount;
+ public int blendOffset => overlayOffset + overlayBitCount;
+ public int rotationOffset => blendOffset + blendBitCount;
+ public int scaleOffset => rotationOffset + rotationBitCount;
+ public int holeOffset => scaleOffset + scaleBitCount;
+ public int navigableOffset => holeOffset + holeBitCount;
+
+ public int usedBits => navigableOffset + navigableBitCount;
+
+ /** True when the layout does not fit. Nothing enforces it - the widths are
+ individually legal but their sum is not checked - so the generator checks
+ this and warns rather than silently writing a flag into nowhere.
+ */
+ public bool isOverflowing => usedBits > TotalBits;
+
+ public Vector2I baseField => new Vector2I( baseOffset, baseBitCount );
+ public Vector2I overlayField => new Vector2I( overlayOffset, overlayBitCount );
+ public Vector2I blendField => new Vector2I( blendOffset, blendBitCount );
+ public Vector2I rotationField => new Vector2I( rotationOffset, rotationBitCount );
+ public Vector2I scaleField => new Vector2I( scaleOffset, scaleBitCount );
+ public Vector2I holeField => new Vector2I( holeOffset, holeBitCount );
+ public Vector2I navigableField => new Vector2I( navigableOffset, navigableBitCount );
+
+ /** How many textures an index can address. */
+ public int maxTextures => baseBitCount <= 0 ? 1 : 1 << baseBitCount;
+
+
+ /* PACKING
+ ------------------------------------------------------------------------
+ The CPU half. Mirrors ControlMap_extract and the decoders on top of it -
+ and mirrors them by construction, since both are driven by the same
+ offsets above.
+
+ The pack pass itself is a compute shader and does none of this; these stay
+ because a seeded test, a navmesh query and a debug pattern all need to
+ speak the format without a GPU dispatch.
+ */
+
+ static uint Mask( int bits )
+ {
+ if ( bits <= 0 )
+ {
+ return 0;
+ }
+
+ return bits >= 32 ? uint.MaxValue : ( 1u << bits ) - 1u;
+ }
+
+ static uint Write( uint control, int offset, int bits, int value )
+ {
+ if ( bits <= 0 )
+ {
+ return control;
+ }
+
+ var mask = Mask( bits );
+ var raw = (uint) Mathf.Clamp( value, 0, (int) mask ) & mask;
+
+ return control | ( raw << offset );
+ }
+
+ public int Read( uint control, Vector2I field )
+ {
+ if ( field.Y <= 0 )
+ {
+ return 0;
+ }
+
+ return (int) ( ( control >> field.X ) & Mask( field.Y ) );
+ }
+
+ /** A blend of 0..1 as its raw field value. The maximum maps to the field's
+ maximum, so both endpoints are reachable.
+ */
+ public int BlendToRaw( float blend )
+ {
+ if ( blendBitCount <= 0 )
+ {
+ return 0;
+ }
+
+ return Mathf.RoundToInt( Mathf.Clamp( blend, 0, 1 ) * Mask( blendBitCount ) );
+ }
+
+ public float RawToBlend( int raw )
+ {
+ return blendBitCount <= 0 ? 0 : raw / (float) Mask( blendBitCount );
+ }
+
+ /** A rotation in radians as its raw field value. Divided by the step
+ count rather than by the maximum, because the quantity
+ wraps - mapping the last step onto a full turn would make it a duplicate
+ of 0 and waste it.
+ */
+ public int RotationToRaw( float radians )
+ {
+ if ( rotationBitCount <= 0 )
+ {
+ return 0;
+ }
+
+ var steps = 1 << rotationBitCount;
+ var turns = radians / Mathf.Tau;
+
+ // Mathf.PosMod so a negative angle wraps rather than clamping to 0.
+ return Mathf.PosMod( Mathf.RoundToInt( turns * steps ), steps );
+ }
+
+ public float RawToRotation( int raw )
+ {
+ return rotationBitCount <= 0 ? 0 : raw / (float) ( 1 << rotationBitCount ) * Mathf.Tau;
+ }
+
+ /** A uv multiplier as its raw field value, inverting the shader's
+ scaleMin + raw * scaleStep.
+ */
+ public int ScaleToRaw( float scale )
+ {
+ if ( scaleBitCount <= 0 || Mathf.Abs( scaleStep ) < 0.000001f )
+ {
+ return 0;
+ }
+
+ return Mathf.RoundToInt( ( scale - scaleMin ) / scaleStep );
+ }
+
+ public float RawToScale( int raw )
+ {
+ return scaleBitCount <= 0 ? 1 : scaleMin + raw * scaleStep;
+ }
+
+ /** One texel's control value.
+
+ blend is 0..1, rotation is radians, scale is the uv multiplier - the same
+ units the decoders hand back, so a caller never sees a raw field value
+ unless it wants to.
+ */
+ public uint Pack(
+ int baseIndex, int overlayIndex, float blend, float rotation, float scale,
+ bool hole = false, bool navigable = true
+ )
+ {
+ return PackRaw(
+ baseIndex, overlayIndex,
+ BlendToRaw( blend ), RotationToRaw( rotation ), ScaleToRaw( scale ),
+ hole, navigable
+ );
+ }
+
+ /** Pack from raw field values, for a caller that is stepping a field rather
+ than expressing a quantity - a debug pattern walking every rotation, for
+ instance.
+ */
+ public uint PackRaw(
+ int baseIndex, int overlayIndex, int blendRaw, int rotationRaw, int scaleRaw,
+ bool hole = false, bool navigable = true
+ )
+ {
+ uint control = 0;
+
+ control = Write( control, baseOffset, baseBitCount, baseIndex );
+ control = Write( control, overlayOffset, overlayBitCount, overlayIndex );
+ control = Write( control, blendOffset, blendBitCount, blendRaw );
+ control = Write( control, rotationOffset, rotationBitCount, rotationRaw );
+ control = Write( control, scaleOffset, scaleBitCount, scaleRaw );
+ control = Write( control, holeOffset, holeBitCount, hole ? 1 : 0 );
+ control = Write( control, navigableOffset, navigableBitCount, navigable ? 1 : 0 );
+
+ return control;
+ }
+
+ /** The neutral texel: texture 0, no overlay, no rotation, scale 1, not a
+ hole, navigable. Identical to PackRaw with every raw value 0 except the
+ navigable bit, which is what makes a zero-filled map sane.
+ */
+ public uint packedDefault => PackRaw( 0, 0, 0, 0, 0, false, true );
+
+
+ /* STORAGE
+ ------------------------------------------------------------------------
+ The map is a single-channel float texture, so a control value travels as
+ the float with those exact bits. Not a conversion - a reinterpretation,
+ undone on the GPU by floatBitsToUint.
+
+ R32F and not R32UI, and this is not a preference: Godot's Image has no
+ integer formats, and texture_rd_create maps an RD format to an
+ Image::Format before wrapping it - so no integer-format texture can ever
+ become a Texture2D. Measured in the phase 0 probe. imageStore to an r32f
+ target through uintBitsToFloat was measured bit-exact for both NaN
+ patterns, so the reinterpretation is safe through a compute write.
+
+ Which is also why the map must never be filtered, mipmapped or stored in
+ any format that renormalizes: a control value is frequently a NaN or a
+ denormal bit pattern as a float, and anything that does arithmetic on it
+ is free to flush it to zero.
+ */
+
+ public static float ToStorage( uint control )
+ {
+ return BitConverter.Int32BitsToSingle( unchecked( (int) control ) );
+ }
+
+ public static uint FromStorage( float stored )
+ {
+ return unchecked( (uint) BitConverter.SingleToInt32Bits( stored ) );
+ }
+
+
+ /** Pushes the layout and its value mapping onto a material declaring
+ ControlMapShader's block.
+ */
+ public void PushToMaterial( Material material )
+ {
+ if ( material == null )
+ {
+ return;
+ }
+
+ ControlMapShader.baseField.Set( material, baseField );
+ ControlMapShader.overlayField.Set( material, overlayField );
+ ControlMapShader.blendField.Set( material, blendField );
+
+ // The one place the two vocabularies meet. The shader side calls this the
+ // angle field, because what it decodes is radians; the authoring side
+ // calls it rotation, because what the user picks is how finely a texel can
+ // be turned. Mapped here rather than renamed on either side - the include
+ // is a render-side contract that predates this resource, and its symbols
+ // are deliberately left untouched so that the first time Godot compiles it
+ // is a test of the include alone.
+ ControlMapShader.angleField.Set( material, rotationField );
+
+ ControlMapShader.scaleField.Set( material, scaleField );
+ ControlMapShader.holeField.Set( material, holeField );
+ ControlMapShader.navigableField.Set( material, navigableField );
+
+ ControlMapShader.scaleMin.Set( material, scaleMin );
+ ControlMapShader.scaleStep.Set( material, scaleStep );
+ }
+ }
+}
diff --git a/Runtime/Procedural/ControlMaps/ControlMapFormat.cs.uid b/Runtime/Procedural/ControlMaps/ControlMapFormat.cs.uid
new file mode 100644
index 0000000..bd88006
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapFormat.cs.uid
@@ -0,0 +1 @@
+uid://qj55rfbltk7o
\ No newline at end of file
diff --git a/Runtime/Procedural/ControlMaps/ControlMapShader.cs b/Runtime/Procedural/ControlMaps/ControlMapShader.cs
new file mode 100644
index 0000000..8aa9c14
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapShader.cs
@@ -0,0 +1,98 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** The uniform block Shading/Library/ControlMap.gdshaderinc expects a consumer
+ to declare, as property names.
+
+ Same role TerrainOffsetShader plays for GeometryTerrainOffset: the names
+ live in one place, so ControlMapTerrain.PushToMaterials() feeds any
+ material that declares them without either side holding a string literal.
+
+ The include itself declares no uniforms - it takes a ControlMapSetup struct
+ the consumer fills. These are the uniforms the consumer needs in order to
+ fill it.
+ */
+ public class ControlMapShader
+ {
+ /** The packed control map. R32F, read with texelFetch only. */
+ public static readonly Sampler2DPropertyName controlMap
+ = Sampler2DPropertyName.Create( "control_map" );
+
+ /** Ground albedo, with the texture's height in alpha. */
+ public static readonly Sampler2DArrayPropertyName albedoArray
+ = Sampler2DArrayPropertyName.Create( "control_albedo_array" );
+
+ /** Ground normal maps, with roughness in alpha. */
+ public static readonly Sampler2DArrayPropertyName normalArray
+ = Sampler2DArrayPropertyName.Create( "control_normal_array" );
+
+ /** One row of ( metersPerTile, detile, normalStrength, roughness ), one
+ texel per texture index.
+ */
+ public static readonly Sampler2DPropertyName textureParams
+ = Sampler2DPropertyName.Create( "control_texture_params" );
+
+ public static readonly BoolPropertyName hasTextureParams
+ = BoolPropertyName.Create( "control_has_texture_params" );
+
+ public static readonly Vector4PropertyName defaultParams
+ = Vector4PropertyName.Create( "control_default_params" );
+
+ /* Placement - the same convention as TerrainOffsetShader, so a control map
+ and a height map over the same rect line up texel for texel.
+ */
+
+ public static readonly Vector2PropertyName centerXZ
+ = Vector2PropertyName.Create( "control_center_xz" );
+
+ public static readonly Vector2PropertyName sizeXZ
+ = Vector2PropertyName.Create( "control_size_xz" );
+
+ /* The bit layout. Each is ( offset, bit count ), pushed from
+ ControlMapFormat so the packer and the GPU decoder cannot disagree.
+
+ angleField is fed from ControlMapFormat.rotationField - the shader calls
+ it an angle because it decodes radians, the authoring side calls it a
+ rotation because that is what the user picks. See
+ ControlMapFormat.PushToMaterial, which is the only place the two meet.
+ */
+
+ public static readonly Vector2IPropertyName baseField
+ = Vector2IPropertyName.Create( "control_base_field" );
+
+ public static readonly Vector2IPropertyName overlayField
+ = Vector2IPropertyName.Create( "control_overlay_field" );
+
+ public static readonly Vector2IPropertyName blendField
+ = Vector2IPropertyName.Create( "control_blend_field" );
+
+ public static readonly Vector2IPropertyName angleField
+ = Vector2IPropertyName.Create( "control_angle_field" );
+
+ public static readonly Vector2IPropertyName scaleField
+ = Vector2IPropertyName.Create( "control_scale_field" );
+
+ public static readonly Vector2IPropertyName holeField
+ = Vector2IPropertyName.Create( "control_hole_field" );
+
+ public static readonly Vector2IPropertyName navigableField
+ = Vector2IPropertyName.Create( "control_navigable_field" );
+
+ /* Value mapping and shading options. */
+
+ public static readonly FloatPropertyName scaleMin
+ = FloatPropertyName.Create( "control_scale_min" );
+
+ public static readonly FloatPropertyName scaleStep
+ = FloatPropertyName.Create( "control_scale_step" );
+
+ public static readonly FloatPropertyName blendSharpness
+ = FloatPropertyName.Create( "control_blend_sharpness" );
+
+ public static readonly BoolPropertyName bilinear
+ = BoolPropertyName.Create( "control_bilinear" );
+ }
+}
diff --git a/Runtime/Procedural/ControlMaps/ControlMapShader.cs.uid b/Runtime/Procedural/ControlMaps/ControlMapShader.cs.uid
new file mode 100644
index 0000000..14086e2
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapShader.cs.uid
@@ -0,0 +1 @@
+uid://b8tcfqwwnqfhd
diff --git a/Runtime/Procedural/ControlMaps/ControlMapTerrain.cs b/Runtime/Procedural/ControlMaps/ControlMapTerrain.cs
new file mode 100644
index 0000000..cc82287
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapTerrain.cs
@@ -0,0 +1,963 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** The one node that sets up control-map texture painting.
+
+ It owns the packed control map, builds the two Texture2DArrays and the
+ per-texture parameter strip out of the `textures` list, and pushes all of
+ it plus the bit layout onto the materials that declare ControlMapShader's
+ block - so a terrain material never references a texture directly and
+ never knows a bit offset.
+
+ Placement follows HeightMapGenerator exactly: the node's global XZ is the
+ map's center and `size` is how many meters it covers. A control map and a
+ height map authored over the same rect therefore line up texel for texel,
+ which is what will let the two merge later.
+
+ This is the authoring half of a painting system that does not exist
+ yet. There are no paint layers and no brush - the map is filled
+ from a debug pattern ( see ControlMapDebugPattern ) so that the rendering
+ path has something to render. The patterns are the testable output: each
+ one isolates one decode field, so a live test reports which part is wrong
+ rather than only that something is.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class ControlMapTerrain:Node3D
+ {
+ [ExportToolButton( "Build" )]
+ public Callable BuildButton => Callable.From( () => Build() );
+
+
+ Vector2 _size = new Vector2( 1024, 1024 );
+
+ /** The map's extent in world meters, on X and Z. */
+ [Export]
+ public Vector2 size
+ {
+ get => _size;
+ set { _size = value; SetDirty(); }
+ }
+
+ Vector2I _resolution = new Vector2I( 256, 256 );
+
+ /** The control map's texture size - one texel is one paintable unit.
+
+ This is the painting resolution, not the visual one: the textures it
+ indexes are sampled at their own tile density, so 4 m per control texel
+ still gives meter-scale ground detail. Terrain3D ships 1 texel per meter;
+ the default here is coarser so a debug pattern is legible at a glance.
+ */
+ [Export]
+ public Vector2I resolution
+ {
+ get => _resolution;
+ set { _resolution = value; SetDirty(); }
+ }
+
+ ControlMapFormat _format;
+
+ /** How a texel's 32 bits are divided. Left empty, a default format is
+ created on the first build - 32 textures, 64 blend steps, 32 rotations,
+ 16 scales.
+ */
+ [Export]
+ public ControlMapFormat format
+ {
+ get => _format;
+ set { _format = value; SetDirty(); }
+ }
+
+ ControlMapTexture[] _textures = new ControlMapTexture[ 0 ];
+
+ /** The ground textures, in index order - an entry's
+ position in this array is the number a control texel stores. Reordering
+ repaints the terrain.
+ */
+ [Export]
+ public ControlMapTexture[] textures
+ {
+ get => _textures;
+ set { _textures = value; SetDirty(); }
+ }
+
+ /** Materials to push the maps, the layout and the shading options onto after
+ every build. The whole wiring between this node and anything rendering
+ the ground, by uniform name - same role as HeightMapGenerator.materials.
+ */
+ [Export]
+ public Material[] materials = new Material[ 0 ];
+
+ /** Off makes the node rebuild only on the Build button. The texture arrays
+ are a CPU resize of every ground texture, so this is not a per-frame
+ operation the way a height-map render is.
+ */
+ [Export]
+ public bool autoBuild = true;
+
+ [ExportGroup( "Texture Arrays" )]
+
+ int _arrayResolution = 1024;
+
+ /** The size every layer of the two Texture2DArrays is resized to.
+
+ A texture array requires every layer to share one size and one format,
+ which is the real constraint of this technique on the art side: a 512
+ gravel and a 4k cliff cannot coexist, one of them is resampled. Picking
+ this is picking which.
+ */
+ [Export( PropertyHint.Range, "64,4096" )]
+ public int arrayResolution
+ {
+ get => _arrayResolution;
+ set { _arrayResolution = Mathf.Clamp( value, 64, 4096 ); SetDirty(); }
+ }
+
+ bool _generateMipmaps = true;
+
+ /** Leave on. The shader samples with textureGrad, which selects a mip
+ explicitly - with no mip chain to select from there is nothing to select
+ and the ground aliases into noise at any distance.
+ */
+ [Export]
+ public bool generateMipmaps
+ {
+ get => _generateMipmaps;
+ set { _generateMipmaps = value; SetDirty(); }
+ }
+
+ bool _albedoSrgbToLinear = false;
+
+ /** Converts the albedo layers to linear on the CPU during the build.
+
+ Whether a Texture2DArray built from Rgba8 images is treated as sRGB is
+ not something this node can see, so this is the escape hatch rather than
+ a setting with a known right value: if the ground renders washed out,
+ turn it on; if it renders muddy, turn it off. Costs precision in the
+ darks either way, which is why a shipping version would want the arrays
+ imported rather than built at tool time.
+ */
+ [Export]
+ public bool albedoSrgbToLinear
+ {
+ get => _albedoSrgbToLinear;
+ set { _albedoSrgbToLinear = value; SetDirty(); }
+ }
+
+ [ExportGroup( "Shading" )]
+
+ float _blendSharpness = 4;
+
+ /** How sharply textures resolve against each other, using the albedo's alpha
+ as a height map. 1 is a plain weighted average - a dissolve. Higher lets
+ the taller texture win where it is tall, so a transition follows the
+ textures' own detail instead of fading.
+ */
+ [Export( PropertyHint.Range, "1,16" )]
+ public float blendSharpness
+ {
+ get => _blendSharpness;
+ set { _blendSharpness = Mathf.Max( 1, value ); SetDirty(); }
+ }
+
+ bool _bilinear = true;
+
+ /** On reads the 4 control texels around a fragment and blends their shading;
+ off reads 1. Off is four times cheaper and shows the control map's texel
+ grid as hard edges, which is the quickest way to see what is actually
+ painted.
+ */
+ [Export]
+ public bool bilinear
+ {
+ get => _bilinear;
+ set { _bilinear = value; SetDirty(); }
+ }
+
+ [ExportGroup( "Debug Pattern" )]
+
+ ControlMapDebugPattern _debugPattern = ControlMapDebugPattern.Showcase;
+
+ /** What to fill the map with, until there is something to paint with. */
+ [Export]
+ public ControlMapDebugPattern debugPattern
+ {
+ get => _debugPattern;
+ set { _debugPattern = value; SetDirty(); }
+ }
+
+ int _debugCellTexels = 16;
+
+ /** How many control texels one cell of the pattern covers. */
+ [Export( PropertyHint.Range, "1,128" )]
+ public int debugCellTexels
+ {
+ get => _debugCellTexels;
+ set { _debugCellTexels = Mathf.Max( 1, value ); SetDirty(); }
+ }
+
+ int _debugTextureCount = 0;
+
+ /** How many texture indices the pattern cycles through. 0 uses however many
+ entries `textures` has.
+ */
+ [Export]
+ public int debugTextureCount
+ {
+ get => _debugTextureCount;
+ set { _debugTextureCount = Mathf.Max( 0, value ); SetDirty(); }
+ }
+
+ [ExportGroup( "Debug Info" )]
+
+ /** Bits the layout uses of its 32. Red is an overflowing layout. */
+ [Export]
+ public int usedBits;
+
+ [Export]
+ public int builtTextures;
+
+
+ /* THE DIRTY FLAG
+ ------------------------------------------------------------------------
+ Same reasoning as HeightMapGenerator: an inspector slider emits a change
+ per pixel of mouse travel, so a build per notification would run several
+ to show one result. Unlike the height map this one is not cheap enough to
+ want per frame - the texture arrays are a CPU resize of every ground
+ texture - so the arrays are additionally only rebuilt when their own
+ signature changed.
+ */
+
+ bool _dirty = true;
+
+ public void SetDirty()
+ {
+ _dirty = true;
+
+ if ( Engine.IsEditorHint() )
+ {
+ UpdateGizmos();
+ }
+ }
+
+ public bool isDirty => _dirty;
+
+ public override void _Ready()
+ {
+ SetDirty();
+ }
+
+ public override void _Process( double delta )
+ {
+ if ( ! _dirty || ! autoBuild )
+ {
+ return;
+ }
+
+ Build();
+ }
+
+
+ /* COORDINATES
+ ------------------------------------------------------------------------
+ Identical to HeightMapGenerator's, deliberately: texel (0,0) is the map's
+ minimum XZ corner, image +Y is world +Z, and nothing has a texel-space
+ authoring concept.
+ */
+
+ public Vector2 centerXZ
+ {
+ get
+ {
+ var p = GlobalPosition;
+ return new Vector2( p.X, p.Z );
+ }
+ }
+
+ public Vector2 minXZ => centerXZ - size / 2;
+
+ public Vector2I textureSize
+ => new Vector2I( Mathf.Max( 1, resolution.X ), Mathf.Max( 1, resolution.Y ) );
+
+ public Vector2 metersPerTexel
+ {
+ get
+ {
+ var r = textureSize;
+ return new Vector2( size.X / r.X, size.Y / r.Y );
+ }
+ }
+
+ public Vector2I WorldXZToTexel( Vector2 worldXZ )
+ {
+ var m = metersPerTexel;
+ var origin = minXZ;
+
+ return new Vector2I(
+ Mathf.FloorToInt( ( worldXZ.X - origin.X ) / m.X ),
+ Mathf.FloorToInt( ( worldXZ.Y - origin.Y ) / m.Y )
+ );
+ }
+
+
+ /* THE MAPS */
+
+ ImageTexture _controlMapTexture;
+ Image _controlImage;
+ Vector2I _controlImageSize = Vector2I.Zero;
+
+ /** The packed control map, R32F. A plain Texture2D to any sampler, but it
+ must only ever be read with texelFetch - see ControlMapFormat.ToStorage.
+ */
+ public Texture2D controlMapTexture => _controlMapTexture;
+
+ Texture2DArray _albedoArray;
+ Texture2DArray _normalArray;
+
+ public Texture2DArray albedoArray => _albedoArray;
+ public Texture2DArray normalArray => _normalArray;
+
+ ImageTexture _paramsTexture;
+ Image _paramsImage;
+
+ /** The raw control values, kept CPU-side so a read does not need a GPU
+ readback. This is what a navmesh bake or a hole query will read.
+ */
+ uint[] _controls = new uint[ 0 ];
+
+ bool _rebuildArrays = true;
+ string _arraySignature = "";
+
+ ControlMapFormat _resolvedFormat;
+
+ /** The format in use, creating the default one if none is assigned. */
+ public ControlMapFormat resolvedFormat
+ {
+ get
+ {
+ if ( format != null )
+ {
+ return format;
+ }
+
+ if ( _resolvedFormat == null )
+ {
+ _resolvedFormat = new ControlMapFormat();
+ }
+
+ return _resolvedFormat;
+ }
+ }
+
+
+ /* BUILDING */
+
+ bool _building = false;
+
+ public void Build()
+ {
+ if ( _building )
+ {
+ return;
+ }
+
+ _dirty = false;
+ _building = true;
+
+ try
+ {
+ var activeFormat = resolvedFormat;
+
+ usedBits = activeFormat.usedBits;
+
+ if ( activeFormat.isOverflowing )
+ {
+ this.LogError(
+ "ControlMapFormat needs " + usedBits + " bits and only has " + ControlMapFormat.TotalBits +
+ " - the highest fields are being truncated. Narrow a field."
+ );
+ }
+
+ UpdateChangeHooks();
+
+ BuildTextureArrays();
+ BuildTextureParams();
+ BuildControlMap();
+
+ PushToMaterials();
+ }
+ finally
+ {
+ _building = false;
+ }
+ }
+
+
+ /* CHANGE HOOKS
+ ------------------------------------------------------------------------
+ The node's own exported values flag it through their setters, but a value
+ on an assigned Resource does not: editing metersPerTile on a
+ ControlMapTexture emits that resource's changed signal, which nothing was
+ listening to. So every assigned format and texture entry gets subscribed.
+
+ Connected with new Callable( this, MethodName.X ) rather than from a
+ lambda. A lambda's Callable is a ManagedCallable holding a GCHandle, and a
+ C# assembly reload releases every one of them - so after any build with
+ the editor open the connection errors with "delegate_handle.value is
+ null". That cost the TextureStack a session to find; it is not being
+ rediscovered here.
+ */
+
+ readonly List _hooked = new List();
+ readonly List _wanted = new List();
+
+ public void OnSourceChanged()
+ {
+ SetDirty();
+ }
+
+ void UpdateChangeHooks()
+ {
+ _wanted.Clear();
+
+ if ( format != null )
+ {
+ _wanted.Add( format );
+ }
+
+ if ( textures != null )
+ {
+ for ( var i = 0; i < textures.Length; i++ )
+ {
+ if ( textures[ i ] != null )
+ {
+ _wanted.Add( textures[ i ] );
+ }
+ }
+ }
+
+ if ( SameAsHooked() )
+ {
+ return;
+ }
+
+ var callable = new Callable( this, MethodName.OnSourceChanged );
+
+ for ( var i = 0; i < _hooked.Count; i++ )
+ {
+ var source = _hooked[ i ];
+
+ if ( IsInstanceValid( source ) && source.IsConnected( Resource.SignalName.Changed, callable ) )
+ {
+ source.Disconnect( Resource.SignalName.Changed, callable );
+ }
+ }
+
+ _hooked.Clear();
+
+ for ( var i = 0; i < _wanted.Count; i++ )
+ {
+ var source = _wanted[ i ];
+
+ if ( ! source.IsConnected( Resource.SignalName.Changed, callable ) )
+ {
+ source.Connect( Resource.SignalName.Changed, callable );
+ }
+
+ _hooked.Add( source );
+ }
+ }
+
+ bool SameAsHooked()
+ {
+ if ( _hooked.Count != _wanted.Count )
+ {
+ return false;
+ }
+
+ for ( var i = 0; i < _hooked.Count; i++ )
+ {
+ if ( _hooked[ i ] != _wanted[ i ] )
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+
+ /* THE CONTROL MAP
+ ------------------------------------------------------------------------
+ Written as raw bytes rather than through Image.SetPixel, which is the one
+ non-obvious correctness point in this file.
+
+ A control value is a 32-bit integer reinterpreted as a float, and most of
+ them are not valid floats - the high bits of a texture index land in the
+ exponent, so a perfectly ordinary texel is a NaN or a denormal. Anything
+ that puts such a value through a float register is entitled to quiet the
+ NaN or flush the denormal to zero, and either one silently corrupts
+ several fields at once.
+
+ So the uint's bytes go into the buffer directly, little-endian, and the
+ GPU's floatBitsToUint reads them back. No float ever holds a control
+ value on the way through.
+ */
+
+ void BuildControlMap()
+ {
+ var wanted = textureSize;
+ var count = wanted.X * wanted.Y;
+
+ if ( _controls.Length != count )
+ {
+ _controls = new uint[ count ];
+ }
+
+ FillDebugPattern( wanted );
+
+ var data = new byte[ count * 4 ];
+
+ for ( var i = 0; i < count; i++ )
+ {
+ var control = _controls[ i ];
+ var offset = i * 4;
+
+ data[ offset + 0 ] = (byte) ( control & 0xFF );
+ data[ offset + 1 ] = (byte) ( ( control >> 8 ) & 0xFF );
+ data[ offset + 2 ] = (byte) ( ( control >> 16 ) & 0xFF );
+ data[ offset + 3 ] = (byte) ( ( control >> 24 ) & 0xFF );
+ }
+
+ // No mipmaps, ever. A mip of a control map is an average of bit patterns.
+ _controlImage = Image.CreateFromData( wanted.X, wanted.Y, false, Image.Format.Rf, data );
+
+ if ( _controlMapTexture == null || _controlImageSize != wanted )
+ {
+ _controlMapTexture = ImageTexture.CreateFromImage( _controlImage );
+ _controlImageSize = wanted;
+ }
+ else
+ {
+ _controlMapTexture.Update( _controlImage );
+ }
+ }
+
+ /** The control value at a world position, or the format's neutral value
+ outside the map. CPU-side, no readback.
+ */
+ public uint SampleControl( Vector3 worldPosition )
+ {
+ var texel = WorldXZToTexel( new Vector2( worldPosition.X, worldPosition.Z ) );
+ var wanted = textureSize;
+
+ if ( texel.X < 0 || texel.Y < 0 || texel.X >= wanted.X || texel.Y >= wanted.Y )
+ {
+ return resolvedFormat.packedDefault;
+ }
+
+ var index = texel.Y * wanted.X + texel.X;
+
+ return index < _controls.Length ? _controls[ index ] : resolvedFormat.packedDefault;
+ }
+
+ public bool IsHole( Vector3 worldPosition )
+ {
+ var activeFormat = resolvedFormat;
+
+ return activeFormat.Read( SampleControl( worldPosition ), activeFormat.holeField ) != 0;
+ }
+
+ public bool IsNavigable( Vector3 worldPosition )
+ {
+ var activeFormat = resolvedFormat;
+
+ return activeFormat.Read( SampleControl( worldPosition ), activeFormat.navigableField ) != 0;
+ }
+
+
+ /* THE DEBUG PATTERNS */
+
+ int patternTextureCount
+ {
+ get
+ {
+ if ( debugTextureCount > 0 )
+ {
+ return debugTextureCount;
+ }
+
+ return Mathf.Max( 1, textures == null ? 1 : textures.Length );
+ }
+ }
+
+ void FillDebugPattern( Vector2I wanted )
+ {
+ var activeFormat = resolvedFormat;
+
+ for ( var y = 0; y < wanted.Y; y++ )
+ {
+ for ( var x = 0; x < wanted.X; x++ )
+ {
+ _controls[ y * wanted.X + x ] = PatternControl( activeFormat, debugPattern, x, y, wanted );
+ }
+ }
+ }
+
+ uint PatternControl(
+ ControlMapFormat activeFormat, ControlMapDebugPattern pattern,
+ int x, int y, Vector2I wanted
+ )
+ {
+ var count = patternTextureCount;
+ var cell = debugCellTexels;
+
+ var cellX = x / cell;
+ var cellY = y / cell;
+ var cellIndex = cellX + cellY;
+
+ switch ( pattern )
+ {
+ case ControlMapDebugPattern.Flat:
+
+ return activeFormat.packedDefault;
+
+ case ControlMapDebugPattern.Checkerboard:
+
+ return activeFormat.PackRaw( cellIndex % count, 0, 0, 0, 0 );
+
+ case ControlMapDebugPattern.BlendGradient:
+ {
+ var blend = wanted.X <= 1 ? 0 : x / (float) ( wanted.X - 1 );
+
+ return activeFormat.Pack( 0, Mathf.Min( 1, count - 1 ), blend, 0, 1 );
+ }
+
+ case ControlMapDebugPattern.ScaleAndRotation:
+ {
+ var rotationSteps = activeFormat.rotationBitCount <= 0 ? 1 : 1 << activeFormat.rotationBitCount;
+ var scaleSteps = activeFormat.scaleBitCount <= 0 ? 1 : 1 << activeFormat.scaleBitCount;
+
+ return activeFormat.PackRaw(
+ 0, 0, 0, cellX % rotationSteps, cellY % scaleSteps
+ );
+ }
+
+ case ControlMapDebugPattern.Holes:
+ {
+ var hole = cellX % 3 == 1 && cellY % 3 == 1;
+
+ return activeFormat.PackRaw( 0, 0, 0, 0, 0, hole, ! hole );
+ }
+
+ case ControlMapDebugPattern.Showcase:
+ {
+ // Quadrants, each evaluated in its own local coordinates so a
+ // gradient runs the full width of its quadrant rather than being a
+ // slice of a map-wide one.
+ var half = new Vector2I( Mathf.Max( 1, wanted.X / 2 ), Mathf.Max( 1, wanted.Y / 2 ) );
+
+ var localX = x % half.X;
+ var localY = y % half.Y;
+
+ var quadrant = ( x < half.X ? 0 : 1 ) + ( y < half.Y ? 0 : 2 );
+
+ switch ( quadrant )
+ {
+ case 0: return PatternControl( activeFormat, ControlMapDebugPattern.Checkerboard, localX, localY, half );
+ case 1: return PatternControl( activeFormat, ControlMapDebugPattern.BlendGradient, localX, localY, half );
+ case 2: return PatternControl( activeFormat, ControlMapDebugPattern.ScaleAndRotation, localX, localY, half );
+ }
+
+ return PatternControl( activeFormat, ControlMapDebugPattern.Holes, localX, localY, half );
+ }
+ }
+
+ return activeFormat.packedDefault;
+ }
+
+
+ /* THE TEXTURE ARRAYS
+ ------------------------------------------------------------------------
+ Rebuilt only when their signature changed, because this is the expensive
+ part: every ground texture is pulled to the CPU, decompressed, resized and
+ mipmapped. An inspector edit to blendSharpness must not pay for that.
+ */
+
+ string BuildArraySignature()
+ {
+ var signature = arrayResolution + "|" + generateMipmaps + "|" + albedoSrgbToLinear + "|";
+
+ if ( textures == null )
+ {
+ return signature;
+ }
+
+ for ( var i = 0; i < textures.Length; i++ )
+ {
+ var entry = textures[ i ];
+
+ if ( entry == null )
+ {
+ signature += "null,";
+ continue;
+ }
+
+ var albedoPath = entry.albedo == null ? "-" : entry.albedo.ResourcePath;
+ var normalPath = entry.normal == null ? "-" : entry.normal.ResourcePath;
+
+ // The instance ids cover a texture that has no path, like a generated
+ // NoiseTexture2D authored inline in the scene.
+ signature += albedoPath + "#" + ( entry.albedo == null ? 0 : entry.albedo.GetInstanceId() )
+ + ":" + normalPath + "#" + ( entry.normal == null ? 0 : entry.normal.GetInstanceId() )
+ + ",";
+ }
+
+ return signature;
+ }
+
+ void BuildTextureArrays()
+ {
+ var signature = BuildArraySignature();
+
+ if ( ! _rebuildArrays && signature == _arraySignature && _albedoArray != null && _normalArray != null )
+ {
+ return;
+ }
+
+ _arraySignature = signature;
+ _rebuildArrays = false;
+
+ var count = textures == null ? 0 : textures.Length;
+
+ builtTextures = count;
+
+ if ( count == 0 )
+ {
+ _albedoArray = null;
+ _normalArray = null;
+
+ return;
+ }
+
+ var albedoImages = new Godot.Collections.Array();
+ var normalImages = new Godot.Collections.Array();
+
+ for ( var i = 0; i < count; i++ )
+ {
+ var entry = textures[ i ];
+
+ var albedo = entry == null ? null : entry.albedo;
+ var normal = entry == null ? null : entry.normal;
+
+ // Magenta rather than something neutral: an unassigned slot should be
+ // impossible to mistake for a ground texture.
+ albedoImages.Add( PrepareImage( albedo, new Color( 1, 0, 1, 1 ), albedoSrgbToLinear ) );
+
+ // Flat tangent normal with full roughness in alpha.
+ normalImages.Add( PrepareImage( normal, new Color( 0.5f, 0.5f, 1, 1 ), false ) );
+ }
+
+ _albedoArray = new Texture2DArray();
+ _normalArray = new Texture2DArray();
+
+ var albedoResult = _albedoArray.CreateFromImages( albedoImages );
+ var normalResult = _normalArray.CreateFromImages( normalImages );
+
+ if ( albedoResult != Error.Ok || normalResult != Error.Ok )
+ {
+ this.LogError( "Could not create the texture arrays:", albedoResult, normalResult );
+ }
+ }
+
+ /** One array layer: decompressed, converted, resized to arrayResolution and
+ mipmapped. Every layer has to come out at the same size and format or
+ CreateFromImages refuses the whole array.
+ */
+ Image PrepareImage( Texture2D texture, Color fallback, bool srgbToLinear )
+ {
+ var size = arrayResolution;
+
+ Image image = null;
+
+ if ( texture != null )
+ {
+ image = texture.GetImage();
+ }
+
+ if ( image == null )
+ {
+ image = Image.CreateEmpty( size, size, false, Image.Format.Rgba8 );
+ image.Fill( fallback );
+ }
+ else
+ {
+ /* DUPLICATE BEFORE TOUCHING ANYTHING
+ --------------------------------------------------------------------
+ Texture2D.GetImage() does not reliably hand back a copy. A
+ CompressedTexture2D decodes a fresh Image, but an ImageTexture, a
+ NoiseTexture2D and anything else holding its pixels in memory return
+ their own Image by reference - so converting,
+ resizing or color-converting it edits the asset in place.
+
+ That made the build destructive and, with albedoSrgbToLinear on,
+ cumulative: every rebuild applied the sRGB-to-linear curve to the
+ already-converted source, and the ground got darker every time
+ anything flagged the node. It read as a caching problem because the
+ damage survived turning the option back off - nothing was cached, the
+ source texture itself had been overwritten.
+
+ Duplicating costs one copy of an image that is about to be resized
+ anyway.
+ */
+ image = (Image) image.Duplicate( true );
+
+ // GetImage() hands back whatever the importer produced, which for a
+ // normal project asset is a compressed format that cannot be resized.
+ if ( image.IsCompressed() )
+ {
+ image.Decompress();
+ }
+
+ image.Convert( Image.Format.Rgba8 );
+
+ // Before the resize, not after: an imported texture arrives with its
+ // mip chain already built, and resizing an image that has one is
+ // refused rather than handled.
+ image.ClearMipmaps();
+
+ if ( image.GetWidth() != size || image.GetHeight() != size )
+ {
+ image.Resize( size, size, Image.Interpolation.Lanczos );
+ }
+
+ if ( srgbToLinear )
+ {
+ image.SrgbToLinear();
+ }
+ }
+
+ if ( generateMipmaps )
+ {
+ image.GenerateMipmaps();
+ }
+
+ return image;
+ }
+
+
+ /* THE PARAMETER STRIP
+ ------------------------------------------------------------------------
+ One Rgbaf texel per texture index, holding the settings that are per
+ texture rather than per texel.
+
+ A texture rather than a uniform array, because a uniform array has to be
+ declared with a fixed length - which would put back exactly the kind of
+ hard-coded ceiling the variable bit layout exists to remove. One
+ texelFetch per tap costs nothing next to the albedo and normal samples it
+ is feeding.
+ */
+
+ void BuildTextureParams()
+ {
+ var count = textures == null ? 0 : textures.Length;
+
+ if ( count == 0 )
+ {
+ _paramsTexture = null;
+ _paramsImage = null;
+
+ return;
+ }
+
+ if ( _paramsImage == null || _paramsImage.GetWidth() != count )
+ {
+ _paramsImage = Image.CreateEmpty( count, 1, false, Image.Format.Rgbaf );
+ _paramsTexture = null;
+ }
+
+ for ( var i = 0; i < count; i++ )
+ {
+ var entry = textures[ i ];
+ var value = entry == null ? new Color( 4, 0, 1, 1 ) : entry.ToParams();
+
+ _paramsImage.SetPixel( i, 0, value );
+ }
+
+ if ( _paramsTexture == null )
+ {
+ _paramsTexture = ImageTexture.CreateFromImage( _paramsImage );
+ }
+ else
+ {
+ _paramsTexture.Update( _paramsImage );
+ }
+ }
+
+
+ /** Pushes the maps, the placement, the format and the shading options onto
+ every assigned material.
+ */
+ public void PushToMaterials()
+ {
+ if ( materials == null )
+ {
+ return;
+ }
+
+ var activeFormat = resolvedFormat;
+
+ for ( var i = 0; i < materials.Length; i++ )
+ {
+ var material = materials[ i ];
+
+ if ( material == null )
+ {
+ continue;
+ }
+
+ ControlMapShader.controlMap.Set( material, _controlMapTexture );
+ ControlMapShader.albedoArray.Set( material, _albedoArray );
+ ControlMapShader.normalArray.Set( material, _normalArray );
+
+ ControlMapShader.textureParams.Set( material, _paramsTexture );
+ ControlMapShader.hasTextureParams.Set( material, _paramsTexture != null );
+ ControlMapShader.defaultParams.Set( material, new Vector4( 4, 0, 1, 1 ) );
+
+ ControlMapShader.centerXZ.Set( material, centerXZ );
+ ControlMapShader.sizeXZ.Set( material, size );
+
+ ControlMapShader.blendSharpness.Set( material, blendSharpness );
+ ControlMapShader.bilinear.Set( material, bilinear );
+
+ activeFormat.PushToMaterial( material );
+ }
+ }
+
+
+ /** Rough VRAM cost of what this node owns, in bytes. The texture arrays
+ dominate by orders of magnitude - which is the thing to know about this
+ technique: the control map is cheap and the textures it indexes are not.
+ */
+ public long EstimateMemory()
+ {
+ var r = textureSize;
+
+ // R32F, no mipmaps.
+ long total = (long) r.X * r.Y * 4;
+
+ var count = textures == null ? 0 : textures.Length;
+
+ // Two arrays, Rgba8, plus a third of their size again for the mip chain.
+ var perLayer = (long) arrayResolution * arrayResolution * 4;
+
+ total += perLayer * count * 2 * ( generateMipmaps ? 4 : 3 ) / 3;
+
+ return total;
+ }
+ }
+}
diff --git a/Runtime/Procedural/ControlMaps/ControlMapTerrain.cs.uid b/Runtime/Procedural/ControlMaps/ControlMapTerrain.cs.uid
new file mode 100644
index 0000000..c450f86
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapTerrain.cs.uid
@@ -0,0 +1 @@
+uid://05mpp6hmlyo3
diff --git a/Runtime/Procedural/ControlMaps/ControlMapTexture.cs b/Runtime/Procedural/ControlMaps/ControlMapTexture.cs
new file mode 100644
index 0000000..8f37fa2
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapTexture.cs
@@ -0,0 +1,113 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** One ground texture in a ControlMapTerrain's set.
+
+ Its position in the terrain's `textures` array is its
+ index, and that index is what a control-map texel stores. So reordering
+ the array repaints the terrain - worth knowing before dragging entries
+ around.
+
+ Channel packing. Two textures per entry, not four:
+ albedo RGB + height in alpha, and normal RGB + roughness in alpha. The
+ packing is not an optimization here, it is the only affordable shape - a
+ fragment reads up to 8 of these ( 4 control taps, base and overlay each ),
+ so every extra sampler is 8 more fetches. Height has to be in alpha
+ because the blend weighting needs it at the same time as the albedo, and
+ roughness rides along with the normal because nothing needs it alone.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class ControlMapTexture:Resource
+ {
+ Texture2D _albedo;
+
+ /** RGB albedo, alpha = height. The height drives the blend weighting, so an
+ entry with a flat alpha of 1 crossfades while one with real height
+ interlocks with its neighbours. A texture with no alpha channel at all
+ becomes opaque on import and therefore crossfades, which is the sane
+ fallback rather than a special case.
+ */
+ [Export]
+ public Texture2D albedo
+ {
+ get => _albedo;
+ set { _albedo = value; EmitChanged(); }
+ }
+
+ Texture2D _normal;
+
+ /** RGB tangent-space normal, alpha = roughness. Left empty, the entry gets
+ a flat normal and full roughness.
+ */
+ [Export]
+ public Texture2D normal
+ {
+ get => _normal;
+ set { _normal = value; EmitChanged(); }
+ }
+
+ float _metersPerTile = 4;
+
+ /** How many world meters one tile of this texture covers, before the
+ per-texel scale multiplies it. This is the per-texture setting that
+ matters most and the reason the parameter strip exists at all - gravel
+ and a cliff face cannot share a tile size.
+ */
+ [Export]
+ public float metersPerTile
+ {
+ get => _metersPerTile;
+ set { _metersPerTile = Mathf.Max( 0.01f, value ); EmitChanged(); }
+ }
+
+ float _detile = 0;
+
+ /** Random rotation per control texel, 0 to 1 of a full turn, on top of the
+ painted rotation.
+
+ A texture with any visible structure reads as a grid at tile scale once
+ it covers a large area; rotating each texel's copy breaks that up. Costs
+ nothing - it is one hash and the rotation was already being applied -
+ but it fights with a texture that has a direction of its own, so it is
+ per texture rather than global.
+ */
+ [Export( PropertyHint.Range, "0,1" )]
+ public float detile
+ {
+ get => _detile;
+ set { _detile = Mathf.Clamp( value, 0, 1 ); EmitChanged(); }
+ }
+
+ float _normalStrength = 1;
+
+ /** Scales the normal map's XY before it is normalized. */
+ [Export]
+ public float normalStrength
+ {
+ get => _normalStrength;
+ set { _normalStrength = value; EmitChanged(); }
+ }
+
+ float _roughness = 1;
+
+ /** Multiplies the roughness read from the normal map's alpha. */
+ [Export]
+ public float roughness
+ {
+ get => _roughness;
+ set { _roughness = value; EmitChanged(); }
+ }
+
+ /** The entry as the parameter strip's texel, in the order the shader's
+ ControlMap_textureParams unpacks it.
+ */
+ public Color ToParams()
+ {
+ return new Color( metersPerTile, detile, normalStrength, roughness );
+ }
+ }
+}
diff --git a/Runtime/Procedural/ControlMaps/ControlMapTexture.cs.uid b/Runtime/Procedural/ControlMaps/ControlMapTexture.cs.uid
new file mode 100644
index 0000000..c57faff
--- /dev/null
+++ b/Runtime/Procedural/ControlMaps/ControlMapTexture.cs.uid
@@ -0,0 +1 @@
+uid://56hhxxntlat1
diff --git a/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
index 4884b43..35db504 100644
--- a/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
+++ b/Runtime/Procedural/HeightMaps/HeightMapGenerator.cs
@@ -193,6 +193,47 @@ namespace Rokojori
[Export]
public bool autoRender = true;
+ [ExportGroup( "Mesh Atlas" )]
+
+ int _meshAtlasMaxSize = 4096;
+
+ /** The cap on the shared mesh-pass buffer - see HeightMapMeshAtlas. A layer
+ whose texel rect does not fit under this is skipped with a warning.
+ */
+ [Export]
+ public int meshAtlasMaxSize
+ {
+ get => _meshAtlasMaxSize;
+ set { _meshAtlasMaxSize = value; SetDirty(); }
+ }
+
+ bool _meshAtlasHdr = true;
+
+ /** Asks the mesh pass's viewport for a float render target. The pass writes
+ UVs rather than colours, so an 8-bit target quantizes them to 256 steps
+ across a texture - leave this on unless it turns out not to work.
+ */
+ [Export]
+ public bool meshAtlasHdr
+ {
+ get => _meshAtlasHdr;
+ set { _meshAtlasHdr = value; SetDirty(); }
+ }
+
+ bool _meshAtlasSrgbDecode = false;
+
+ /** The other half of the question above: a low dynamic range render target
+ is also sRGB-encoded on the way out, which bends the values the mesh pass
+ wrote. Turn this on if the ribbon's textures come out offset and
+ stretched towards one edge with meshAtlasHdr off.
+ */
+ [Export]
+ public bool meshAtlasSrgbDecode
+ {
+ get => _meshAtlasSrgbDecode;
+ set { _meshAtlasSrgbDecode = value; SetDirty(); }
+ }
+
[ExportGroup( "Debug Info" )]
[Export]
@@ -412,6 +453,47 @@ namespace Rokojori
bool _rendering = false;
+
+ /* THE MESH PASS
+ ------------------------------------------------------------------------
+ Created on demand and only by a layer that needs it, as an internal child
+ so it neither shows up in the scene tree nor gets saved with it - and so
+ CollectLayers, which walks the normal children, never sees it.
+
+ The first layer to ask for it in a given render starts the pass; the
+ generator ends it in PrepareLayers once every layer has contributed.
+ */
+
+ HeightMapMeshAtlas _meshAtlas;
+ bool _meshAtlasUsed;
+ bool _meshAtlasRedrawQueued;
+
+ public HeightMapMeshAtlas meshAtlas
+ {
+ get
+ {
+ if ( _meshAtlas == null )
+ {
+ _meshAtlas = new HeightMapMeshAtlas();
+ _meshAtlas.Name = "HeightMapMeshAtlas";
+
+ AddChild( _meshAtlas, false, InternalMode.Front );
+ }
+
+ _meshAtlas.maxSize = Mathf.Clamp( meshAtlasMaxSize, 256, 16384 );
+ _meshAtlas.hdr = meshAtlasHdr;
+
+ if ( ! _meshAtlasUsed )
+ {
+ _meshAtlasUsed = true;
+
+ _meshAtlas.Begin();
+ }
+
+ return _meshAtlas;
+ }
+ }
+
/** 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.
@@ -437,6 +519,8 @@ namespace Rokojori
renderedLayers = 0;
+ PrepareLayers();
+
for ( var i = 0; i < _layers.Count; i++ )
{
var layer = _layers[ i ];
@@ -459,6 +543,48 @@ namespace Rokojori
{
_rendering = false;
}
+
+ // Outside the guard on purpose: the mesh pass renders at the end of this
+ // frame, so the blits above read the previous one. Flagging here makes the
+ // next frame redo them against the finished atlas, and because the pass
+ // reports no change the second time around, it settles after exactly one
+ // extra render.
+ if ( _meshAtlasRedrawQueued )
+ {
+ _meshAtlasRedrawQueued = false;
+
+ SetDirty();
+ }
+ }
+
+ /** The pre-pass: every visible layer gets to set up anything that cannot
+ happen inside the ordered draw loop, then the shared mesh pass runs once.
+
+ The atlas is only touched when a layer actually allocated a region, so a
+ map made of circles and images never creates a viewport at all.
+ */
+ void PrepareLayers()
+ {
+ _meshAtlasUsed = false;
+
+ for ( var i = 0; i < _layers.Count; i++ )
+ {
+ var layer = _layers[ i ];
+
+ if ( ! layer.IsVisibleInTree() )
+ {
+ continue;
+ }
+
+ layer.PrepareRender( this );
+ }
+
+ if ( ! _meshAtlasUsed || _meshAtlas == null )
+ {
+ return;
+ }
+
+ _meshAtlasRedrawQueued = _meshAtlas.Flush();
}
void CollectLayers( Node node, List into )
@@ -492,24 +618,36 @@ namespace Rokojori
blend-mode uniforms are set here, so a layer cannot get the world mapping
wrong.
- `source` and `mask` fill the shader's hint_blit_source0 and
- hint_blit_source1 - an image layer passes its texture and optionally a
- mask, a purely procedural shape passes neither. Both slots are always
- bound, to the shared white 1x1 stand-in when a layer has nothing for
- them: an unbound blit source arrives black, so "no mask" would otherwise
- have to be a flag, while white is already a no-op multiplier.
+ The four textures fill the shader's hint_blit_source0 to
+ hint_blit_source3 - an image layer passes its texture and optionally a
+ mask, a spline layer passes the mesh atlas, a mask, its texture atlas and
+ its level table, a purely procedural shape passes nothing. Four is the
+ exact ceiling the spike measured, so there is no slot left over.
+
+ Every slot is always bound, to the shared white 1x1 stand-in when a layer
+ has nothing for it: an unbound blit source arrives black, so "no mask"
+ would otherwise have to be a flag, while white is already a no-op
+ multiplier.
*/
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode )
{
- DrawShape( rect, blendMode, null, null );
+ DrawShape( rect, blendMode, null, null, null, null );
}
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source )
{
- DrawShape( rect, blendMode, source, null );
+ DrawShape( rect, blendMode, source, null, null, null );
}
public void DrawShape( Rect2I rect, HeightMapBlendMode blendMode, Texture2D source, Texture2D mask )
+ {
+ DrawShape( rect, blendMode, source, mask, null, null );
+ }
+
+ public void DrawShape(
+ Rect2I rect, HeightMapBlendMode blendMode,
+ Texture2D source, Texture2D mask, Texture2D source2, Texture2D source3
+ )
{
if ( heightMapTarget == null || rect.Size.X <= 0 || rect.Size.Y <= 0 )
{
@@ -529,6 +667,8 @@ namespace Rokojori
{
_blitSources[ 0 ] = source != null ? source : (Texture2D) dummySource;
_blitSources[ 1 ] = mask != null ? mask : (Texture2D) dummySource;
+ _blitSources[ 2 ] = source2 != null ? source2 : (Texture2D) dummySource;
+ _blitSources[ 3 ] = source3 != null ? source3 : (Texture2D) dummySource;
// BlitRectMulti rather than BlitRect, because the explicit sources array
// is the documented way to fill more than source 0.
@@ -554,7 +694,7 @@ namespace Rokojori
/** Reused rather than allocated per blit - a drag re-renders every frame. */
readonly Godot.Collections.Array _blitSources
- = new Godot.Collections.Array(){ null, null };
+ = new Godot.Collections.Array(){ null, null, null, null };
DrawableTexture2D _dummySource;
@@ -734,13 +874,23 @@ namespace Rokojori
/** Rough VRAM cost of the maps, 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.
+
+ The mesh atlas is counted when one exists, since it is a full extra
+ buffer and sized by the layers rather than by the map.
*/
public long EstimateMemory()
{
var r = textureSize;
var maps = normalMapTarget != null ? 2 : 1;
- return (long) r.X * r.Y * format.BytesPerPixel() * maps;
+ var total = (long) r.X * r.Y * format.BytesPerPixel() * maps;
+
+ if ( _meshAtlas != null )
+ {
+ total += _meshAtlas.EstimateMemory();
+ }
+
+ return total;
}
}
}
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
index b897f6a..016ed8f 100644
--- a/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapDrawLayer.cs
@@ -33,6 +33,19 @@ namespace Rokojori
*/
public abstract void RenderToHeightMap( HeightMapGenerator generator );
+ /** Called on every visible layer before any of them draws.
+
+ Only for work that has to happen outside the ordered draw loop. The one
+ thing that does is the mesh pass: a spline layer allocates its atlas
+ region and builds its ribbon here, the generator then runs that pass
+ once for all of them, and the draw loop afterwards is pure blits again.
+
+ That split is sound because a ribbon never reads the height map - the
+ dependency runs one way - so nothing a layer does here can depend on
+ what an earlier layer drew.
+ */
+ public virtual void PrepareRender( HeightMapGenerator generator ) {}
+
/* FINDING THE GENERATOR
------------------------------------------------------------------------
diff --git a/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
index 96cb1af..fad5085 100644
--- a/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
+++ b/Runtime/Procedural/HeightMaps/Layers/HeightMapGeneratorLayer.cs
@@ -38,16 +38,37 @@ namespace Rokojori
get => _blendMode; set { _blendMode = value; SetDirty(); }
}
- float _height = 10;
+ float _heightHandle = 10;
/** In meters. The height to add or subtract for Add/Subtract - the node's Y
is ignored there - and the amplitude on top of the node's Y for Replace,
which a flat shape like the circle simply does not use.
*/
[Export]
+ public float heightHandle
+ {
+ get => _heightHandle;
+ set { _heightHandle = value; SetDirty(); }
+ }
+
+ float _height = 0;
+
public float height
{
- get => _height; set { _height = value; SetDirty(); }
+ get => _heightHandle * _heightScale;
+ }
+
+ float _heightScale = 1;
+
+ /** In meters. The height to add or subtract for Add/Subtract - the node's Y
+ is ignored there - and the amplitude on top of the node's Y for Replace,
+ which a flat shape like the circle simply does not use.
+ */
+ [Export]
+ public float heightScale
+ {
+ get => _heightScale;
+ set { _heightScale = value; SetDirty(); }
}
/** Writes the two normalized height uniforms for this layer's blend mode.
@@ -126,7 +147,7 @@ namespace Rokojori
if ( usesHeightHandle )
{
- var top = ToGizmoLocal( new Vector3( 0, height, 0 ) );
+ var top = ToGizmoLocal( new Vector3( 0, heightHandle, 0 ) );
// The stem is what makes the sign readable - a negative height hangs
// below the node instead of looking like a small positive one.
@@ -164,14 +185,14 @@ namespace Rokojori
here rather than on the live position, so an edge drag that moves the
node does not drag the plane out from under itself.
*/
- protected Vector3 dragStartPosition => _dragStartPosition;
+ protected Vector3 dragStartPosition => _dragStartPosition;
public Variant GetHandleValue( EditorNode3DGizmo gizmo, int handleId, bool secondary )
{
- _dragStartHeight = height;
+ _dragStartHeight = _heightHandle;
_dragStartPosition = GlobalPosition;
- return handleId == HandleIdHeight ? Variant.From( height ) : GetShapeHandleValue( handleId );
+ return handleId == HandleIdHeight ? Variant.From( _heightHandle ) : GetShapeHandleValue( handleId );
}
public void SetHandle( EditorNode3DGizmo gizmo, int id, bool secondary, Camera3D camera, Vector2 point )
@@ -182,7 +203,7 @@ namespace Rokojori
if ( ProjectOntoAxis( camera, point, _dragStartPosition, Vector3.Up, out along ) )
{
- height = along;
+ heightHandle = along;
}
}
else
@@ -206,7 +227,7 @@ namespace Rokojori
if ( id == HandleIdHeight )
{
- height = _dragStartHeight;
+ heightHandle = _dragStartHeight;
}
else
{
diff --git a/Runtime/Procedural/HeightMaps/Layers/SplineHeightMapGeneratorLayer.cs b/Runtime/Procedural/HeightMaps/Layers/SplineHeightMapGeneratorLayer.cs
new file mode 100644
index 0000000..14f1469
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/SplineHeightMapGeneratorLayer.cs
@@ -0,0 +1,727 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Draws a Spline into the height map as a widened ribbon - a road, a river
+ bed, a ridge, a trench.
+
+ The third primitive, and the first one that is not a single blit. A ribbon
+ is rasterized as real geometry first, into the shared
+ HeightMapMeshAtlas, and only then blitted onto the map. Two things force
+ that split, and both are worth knowing before changing it:
+
+ - Overlaps have to resolve to the highest piece, and there
+ is no Max among the native blit blend modes. The mesh's Z is its own
+ height, so the depth test does it for free.
+ - An analytic ribbon would have to find the nearest point on a rational
+ bezier per texel, which has no closed form, and the nearest-segment
+ approximation tears its UVs along the medial axis - exactly at inner
+ corners and self-crossings. A mesh overdraws and lets depth decide.
+
+ Width comes from a SplinePointFloatData named `width` -
+ per point, falling back to the spline's defaultData, falling back to
+ `defaultWidth` - and is interpolated linearly from one point to the next.
+ See Spline.ResolveFloatData.
+
+ Height follows the blend mode, as everywhere in this
+ family, with one addition the other layers do not have: under Replace the
+ absolute level is the spline's own interpolated Y, not the
+ layer node's, because a spline carries an elevation per point and that is
+ the whole point of drawing a road with one. `height` is then the amplitude
+ the texture adds on top. Add and Subtract read `height` as a delta as
+ usual, and the spline's Y only decides which piece wins where the ribbon
+ crosses itself.
+
+ Textures are three slots - start, middle, end - packed
+ into one atlas, with the first spline segment taking the start texture, the
+ last one the end texture and everything between the middle one. A closed
+ spline is all middle, since it has no ends. An empty slot falls back to
+ whichever of the three is assigned, so a single middle texture covers the
+ whole ribbon.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class SplineHeightMapGeneratorLayer:HeightMapGeneratorLayer
+ {
+ /** How many texture slots the atlas is divided into - start, middle, end. */
+ public const int Slots = 3;
+
+ Spline _spline;
+
+ /** The spline to draw. Left empty, the first Spline child is used, which
+ makes the common case "a spline under the layer" need no wiring.
+ */
+ [Export]
+ public Spline spline
+ {
+ get => _spline; set { _spline = value; SetDirty(); }
+ }
+
+ string _widthDataName = "width";
+
+ /** The SplinePointFloatData name the width is read from. */
+ [Export]
+ public string widthDataName
+ {
+ get => _widthDataName; set { _widthDataName = value; SetDirty(); }
+ }
+
+ float _defaultWidth = 1;
+
+ /** The width in meters for a point that has no `width` data and a spline
+ that has no default for it either.
+
+ 1 rather than something scene-appropriate on purpose: a ribbon one meter
+ wide is unmistakably "nobody authored a width", where a plausible default
+ would quietly look like a decision.
+ */
+ [Export]
+ public float defaultWidth
+ {
+ get => _defaultWidth; set { _defaultWidth = value; SetDirty(); }
+ }
+
+
+ [ExportGroup( "Textures" )]
+
+ Texture2D _startTexture;
+
+ [Export]
+ public Texture2D startTexture
+ {
+ get => _startTexture; set { _startTexture = value; SetDirty(); }
+ }
+
+ Texture2D _middleTexture;
+
+ [Export]
+ public Texture2D middleTexture
+ {
+ get => _middleTexture; set { _middleTexture = value; SetDirty(); }
+ }
+
+ Texture2D _endTexture;
+
+ [Export]
+ public Texture2D endTexture
+ {
+ get => _endTexture; set { _endTexture = value; SetDirty(); }
+ }
+
+ HeightMapChannel _channel = HeightMapChannel.R;
+
+ /** Which channel of the textures carries the height, and whether alpha is
+ read as coverage - see HeightMapChannel. The paired entries are what make
+ a texture with a cut-out silhouette only affect the map where it is
+ opaque.
+ */
+ [Export]
+ public HeightMapChannel channel
+ {
+ get => _channel; set { _channel = value; SetDirty(); }
+ }
+
+ float _middleTileLength = 0;
+
+ /** How much arc length one copy of the middle texture covers.
+
+ 0 maps exactly one copy onto each spline segment, which is simplest to
+ reason about but stretches the texture wherever the points are spaced
+ unevenly. A length in meters keeps the texture's scale constant instead,
+ at the cost of the tiling no longer lining up with the points.
+ */
+ [Export]
+ public float middleTileLength
+ {
+ get => _middleTileLength; set { _middleTileLength = value; SetDirty(); }
+ }
+
+ int _atlasSlotSize = 512;
+
+ /** The square size each texture is packed to. The textures are stretched
+ into their slot, so this is the resolution the ribbon actually samples -
+ a texture larger than this is downscaled, a smaller one upscaled.
+ */
+ [Export]
+ public int atlasSlotSize
+ {
+ get => _atlasSlotSize; set { _atlasSlotSize = value; SetDirty(); }
+ }
+
+
+ [ExportGroup( "Shaping" )]
+
+ float _edgeFalloff = 0;
+
+ /** Fades the ribbon out towards its two long edges, as a fraction of the
+ half width. 0 is a hard edge.
+
+ It multiplies coverage, not height, so under Replace the
+ ribbon crossfades back into the terrain it is cutting through rather than
+ ending in a cliff - which is the normal thing to want for a road.
+ */
+ [Export( PropertyHint.Range, "0,1" )]
+ public float edgeFalloff
+ {
+ get => _edgeFalloff; set { _edgeFalloff = value; SetDirty(); }
+ }
+
+ float _fadeInLength = 0;
+
+ /** Fades the ribbon in over this many meters from its start. Also coverage,
+ so the road emerges from the terrain instead of starting with a step.
+ */
+ [Export]
+ public float fadeInLength
+ {
+ get => _fadeInLength; set { _fadeInLength = value; SetDirty(); }
+ }
+
+ float _fadeOutLength = 0;
+
+ [Export]
+ public float fadeOutLength
+ {
+ get => _fadeOutLength; set { _fadeOutLength = value; SetDirty(); }
+ }
+
+ Texture2D _mask;
+
+ /** An extra multiplier on coverage, mapped to the whole map
+ rather than to the ribbon.
+
+ Mapped that way on purpose: a spline layer's rect is the curve's bounding
+ box and moves whenever a point does, so a mask anchored to it would slide
+ around while editing. Anchored to the map it stays put, which is what a
+ "fade the road out over here" mask needs.
+ */
+ [Export]
+ public Texture2D mask
+ {
+ get => _mask; set { _mask = value; SetDirty(); }
+ }
+
+ HeightMapChannel _maskChannel = HeightMapChannel.A;
+
+ [Export]
+ public HeightMapChannel maskChannel
+ {
+ get => _maskChannel; set { _maskChannel = value; SetDirty(); }
+ }
+
+
+ [ExportGroup( "Resolution" )]
+
+ int _rowsPerSpan = 24;
+
+ /** Vertex rows per texture instance. The ribbon is rasterized into texels,
+ so this only has to be fine enough that the chords stay inside half a
+ texel of the curve.
+ */
+ [Export( PropertyHint.Range, "2,128" )]
+ public int rowsPerSpan
+ {
+ get => _rowsPerSpan; set { _rowsPerSpan = value; SetDirty(); }
+ }
+
+ int _curveSamplesPerSegment = 16;
+
+ /** How finely the arc-length table samples each segment. It decides how
+ accurately "step one meter along the curve" is answered, and so how
+ evenly the texture is spaced - see SplineArcLengthTable.
+ */
+ [Export( PropertyHint.Range, "2,128" )]
+ public int curveSamplesPerSegment
+ {
+ get => _curveSamplesPerSegment; set { _curveSamplesPerSegment = value; SetDirty(); }
+ }
+
+ int _levelResolution = 512;
+
+ /** Entries in the 1D table that carries the spline's elevation into the
+ blit. 512 stations over the whole curve, linearly interpolated, which is
+ far below the error of anything else in the chain.
+ */
+ [Export( PropertyHint.Range, "2,4096" )]
+ public int levelResolution
+ {
+ get => _levelResolution; set { _levelResolution = value; SetDirty(); }
+ }
+
+ TextureStackFormat _atlasFormat = TextureStackFormat.Rgba8;
+
+ /** The packed texture atlas's format. Rgba8 is enough for a height texture
+ read as an amplitude; raise it for a float noise that would otherwise be
+ quantized before it is even scaled.
+ */
+ [Export]
+ public TextureStackFormat atlasFormat
+ {
+ get => _atlasFormat; set { _atlasFormat = value; SetDirty(); }
+ }
+
+
+ /* RESOLVING THE SPLINE */
+
+ public Spline GetSpline()
+ {
+ if ( spline != null && GodotObject.IsInstanceValid( spline ) )
+ {
+ return spline;
+ }
+
+ for ( var i = 0; i < GetChildCount(); i++ )
+ {
+ var child = GetChild( i ) as Spline;
+
+ if ( child != null )
+ {
+ return child;
+ }
+ }
+
+ return null;
+ }
+
+
+ /* WATCHING THE SPLINE
+ ------------------------------------------------------------------------
+ The spline is a different node, so none of HeightMapDrawLayer's change
+ notification reaches it - moving a SplinePoint changes neither this
+ layer's transform nor any of its exported values. Hence a poll: a cheap
+ signature over the points, compared once per frame.
+
+ Covers positions, the resolved widths, the point count and the closed
+ flag. It deliberately does not cover tangent nodes or the tangent weights
+ - dragging those needs a manual Render.
+ */
+
+ long _splineSignature;
+
+ public override void _Process( double delta )
+ {
+ var signature = SplineSignature();
+
+ if ( signature == _splineSignature )
+ {
+ return;
+ }
+
+ _splineSignature = signature;
+
+ SetDirty();
+ }
+
+ long SplineSignature()
+ {
+ var s = GetSpline();
+
+ if ( s == null )
+ {
+ return 0;
+ }
+
+ var signature = unchecked( (long) 0xcbf29ce484222325 );
+
+ signature = Hash( signature, s.closed ? 1 : 0 );
+
+ for ( var i = 0; i < s.GetChildCount(); i++ )
+ {
+ var point = s.GetChild( i ) as SplinePoint;
+
+ if ( point == null )
+ {
+ continue;
+ }
+
+ var position = point.GlobalPosition;
+
+ signature = Hash( signature, BitConverter.SingleToInt32Bits( position.X ) );
+ signature = Hash( signature, BitConverter.SingleToInt32Bits( position.Y ) );
+ signature = Hash( signature, BitConverter.SingleToInt32Bits( position.Z ) );
+
+ var width = s.ResolveFloatData( point, widthDataName, defaultWidth );
+
+ signature = Hash( signature, BitConverter.SingleToInt32Bits( width ) );
+ }
+
+ return signature;
+ }
+
+ static long Hash( long signature, long value )
+ {
+ return unchecked( ( signature ^ value ) * 0x100000001b3 );
+ }
+
+
+ /* THE MESH PASS */
+
+ HeightMapRibbonBuilder _builder;
+
+ HeightMapRibbonBuilder builder
+ {
+ get
+ {
+ if ( _builder == null )
+ {
+ _builder = new HeightMapRibbonBuilder();
+ }
+
+ return _builder;
+ }
+ }
+
+ HeightMapMeshAtlas _atlas;
+ Rect2I _rect;
+ Rect2I _region;
+ bool _prepared;
+ bool _warnedAboutRegion;
+
+ public override void PrepareRender( HeightMapGenerator generator )
+ {
+ _prepared = false;
+ _atlas = null;
+
+ var s = GetSpline();
+
+ if ( s == null )
+ {
+ return;
+ }
+
+ var b = builder;
+
+ b.rowsPerSpan = Mathf.Clamp( rowsPerSpan, 2, 128 );
+ b.middleTileLength = middleTileLength;
+ b.slots = Slots;
+ b.slotSize = ClampedSlotSize();
+
+ if ( ! b.Prepare( s, widthDataName, defaultWidth, Mathf.Clamp( curveSamplesPerSegment, 2, 128 ) ) )
+ {
+ return;
+ }
+
+ Vector2 min;
+ Vector2 max;
+
+ if ( ! b.GetWorldBounds( out min, out max ) )
+ {
+ return;
+ }
+
+ var rect = generator.WorldBoxToRect( min, max );
+
+ if ( rect.Size.X <= 0 || rect.Size.Y <= 0 )
+ {
+ return;
+ }
+
+ var atlas = generator.meshAtlas;
+ var region = atlas.Allocate( rect.Size );
+
+ if ( region.Size.X <= 0 || region.Size.Y <= 0 )
+ {
+ if ( ! _warnedAboutRegion )
+ {
+ _warnedAboutRegion = true;
+
+ RJLog.Log( "SplineHeightMapGeneratorLayer:", Name,
+ "needs a", rect.Size, "region and the mesh atlas has no room for it.",
+ "Raise HeightMapGenerator.meshAtlasMaxSize, or lower the map's resolution."
+ );
+ }
+
+ return;
+ }
+
+ _warnedAboutRegion = false;
+
+ var mesh = b.BuildMesh( generator, rect, region.Position );
+
+ if ( mesh == null )
+ {
+ return;
+ }
+
+ atlas.Submit( mesh, region, b.signature );
+
+ b.BuildLevels( generator, Mathf.Clamp( levelResolution, 2, 4096 ) );
+
+ BuildTextureAtlas();
+
+ _rect = rect;
+ _region = region;
+ _atlas = atlas;
+ _prepared = true;
+ }
+
+
+ /* THE TEXTURE ATLAS
+ ------------------------------------------------------------------------
+ One blit per slot into a DrawableTexture2D, rebuilt only when the inputs
+ change. BlitRect stretches its whole source across the destination rect,
+ so each texture lands scaled to its slot with no packing math.
+
+ Packed rather than bound as three separate blit sources because the whole
+ ribbon is one blit: the slot a texel belongs to is encoded in the UV the
+ mesh pass wrote, so there is nothing to branch on and no limit of three
+ either.
+ */
+
+ DrawableTexture2D _textureAtlas;
+ BlitMaterial _copyBlit;
+
+ Texture2D _builtStart;
+ Texture2D _builtMiddle;
+ Texture2D _builtEnd;
+ int _builtSlotSize;
+ TextureStackFormat _builtFormat;
+
+ int ClampedSlotSize()
+ {
+ return Mathf.Clamp( atlasSlotSize, 4, 2048 );
+ }
+
+ /** An unassigned slot borrows from the assigned ones, so a ribbon with only
+ a middle texture is textured end to end instead of having two blank
+ segments.
+ */
+ Texture2D SlotTexture( int slot )
+ {
+ if ( slot == HeightMapRibbonBuilder.SlotStart )
+ {
+ return startTexture ?? middleTexture ?? endTexture;
+ }
+
+ if ( slot == HeightMapRibbonBuilder.SlotEnd )
+ {
+ return endTexture ?? middleTexture ?? startTexture;
+ }
+
+ return middleTexture ?? startTexture ?? endTexture;
+ }
+
+ bool hasAnyTexture => startTexture != null || middleTexture != null || endTexture != null;
+
+ void BuildTextureAtlas()
+ {
+ if ( ! hasAnyTexture )
+ {
+ _textureAtlas = null;
+ _builtStart = null;
+ _builtMiddle = null;
+ _builtEnd = null;
+
+ return;
+ }
+
+ var slotSize = ClampedSlotSize();
+
+ var start = SlotTexture( HeightMapRibbonBuilder.SlotStart );
+ var middle = SlotTexture( HeightMapRibbonBuilder.SlotMiddle );
+ var end = SlotTexture( HeightMapRibbonBuilder.SlotEnd );
+
+ var unchangedInputs = _textureAtlas != null
+ && _builtStart == start
+ && _builtMiddle == middle
+ && _builtEnd == end
+ && _builtSlotSize == slotSize
+ && _builtFormat == atlasFormat;
+
+ if ( unchangedInputs )
+ {
+ return;
+ }
+
+ if ( _textureAtlas == null || _builtSlotSize != slotSize || _builtFormat != atlasFormat )
+ {
+ if ( _textureAtlas == null )
+ {
+ _textureAtlas = new DrawableTexture2D();
+ }
+
+ _textureAtlas.Setup(
+ Slots * slotSize, slotSize, atlasFormat.ToDrawableFormat(),
+ new Color( 0, 0, 0, 0 ), false
+ );
+ }
+
+ if ( _copyBlit == null )
+ {
+ _copyBlit = new BlitMaterial();
+ _copyBlit.BlendMode = BlitMaterial.BlendModeEnum.Disabled;
+ }
+
+ BlitSlot( HeightMapRibbonBuilder.SlotStart, start, slotSize );
+ BlitSlot( HeightMapRibbonBuilder.SlotMiddle, middle, slotSize );
+ BlitSlot( HeightMapRibbonBuilder.SlotEnd, end, slotSize );
+
+ _builtStart = start;
+ _builtMiddle = middle;
+ _builtEnd = end;
+ _builtSlotSize = slotSize;
+ _builtFormat = atlasFormat;
+ }
+
+ void BlitSlot( int slot, Texture2D texture, int slotSize )
+ {
+ if ( texture == null )
+ {
+ return;
+ }
+
+ var rect = new Rect2I( slot * slotSize, 0, slotSize, slotSize );
+
+ _textureAtlas.BlitRect( rect, texture, new Color( 1, 1, 1, 1 ), 0, _copyBlit );
+ }
+
+
+ /* THE BLIT */
+
+ public override void RenderToHeightMap( HeightMapGenerator generator )
+ {
+ if ( ! _prepared || _atlas == null )
+ {
+ return;
+ }
+
+ var material = generator.shapeMaterial;
+ var regionUV = _atlas.RegionUV( _region );
+ var atlasSize = _atlas.atlasSize;
+ var length = builder.length;
+
+ material.Reset();
+ material.shapeType.Set( (int) HeightMapShapeType.Ribbon );
+ material.imageChannel.Set( (int) channel );
+
+ material.ribbonRegionUVMin.Set( regionUV.Position );
+ material.ribbonRegionUVSize.Set( regionUV.Size );
+ material.ribbonBufferSize.Set( new Vector2( Mathf.Max( 1, atlasSize.X ), Mathf.Max( 1, atlasSize.Y ) ) );
+ material.ribbonSlots.Set( Slots );
+ material.ribbonEdgeFalloff.Set( Mathf.Clamp( edgeFalloff, 0, 1 ) );
+ material.ribbonFadeIn.Set( ToLengthFraction( fadeInLength, length ) );
+ material.ribbonFadeOut.Set( ToLengthFraction( fadeOutLength, length ) );
+ material.ribbonSrgbDecode.Set( generator.meshAtlasSrgbDecode );
+
+ material.maskChannel.Set( (int) maskChannel );
+ material.SetMaskRect(
+ generator.centerXZ, new Vector2( 1, 0 ), new Vector2( 0, 1 ), generator.size
+ );
+
+ // With no texture the atlas is the white stand-in, so every texel reads an
+ // amount of 1. Under Add that is a uniformly raised embankment, which is
+ // useful; under Replace it would lift the road `height` above the spline
+ // that is supposed to be defining it, so the amplitude goes to 0 and the
+ // ribbon sits flat on the curve.
+ var amplitude = ! hasAnyTexture && blendMode.IsAbsolute() ? 0 : height;
+
+ SetShapeValue( generator, amplitude );
+
+ generator.DrawShape(
+ _rect, blendMode, _atlas.atlasTexture, mask, _textureAtlas, builder.levelTexture
+ );
+ }
+
+ static float ToLengthFraction( float meters, float length )
+ {
+ if ( meters <= 0 || length <= 0.000001f )
+ {
+ return 0;
+ }
+
+ return Mathf.Clamp( meters / length, 0, 1 );
+ }
+
+
+#if TOOLS
+
+ /* GIZMOS
+ ------------------------------------------------------------------------
+ The outline only - the two long edges plus the caps. There are no handles
+ here on purpose: the things worth dragging are the spline's own points,
+ which carry their own gizmos, and the width lives in a point's data rather
+ than on this node.
+
+ Drawn from the builder, which may be holding either the last render's
+ state or nothing at all if no render has happened yet, so it is prepared
+ on demand.
+ */
+
+ protected override void DrawShapeGizmo( List lines, List handles, List ids )
+ {
+ var s = GetSpline();
+
+ if ( s == null )
+ {
+ return;
+ }
+
+ var b = builder;
+
+ b.rowsPerSpan = Mathf.Clamp( rowsPerSpan, 2, 128 );
+ b.middleTileLength = middleTileLength;
+
+ if ( ! b.Prepare( s, widthDataName, defaultWidth, Mathf.Clamp( curveSamplesPerSegment, 2, 128 ) ) )
+ {
+ return;
+ }
+
+ var total = b.length;
+ var stations = Mathf.Max( 2, b.numSegments * b.rowsPerSpan );
+ var delta = Mathf.Max( 0.01f, total * 0.001f );
+
+ var lastLeft = Vector3.Zero;
+ var lastRight = Vector3.Zero;
+ var lastNormal = new Vector2( 1, 0 );
+
+ for ( var i = 0; i < stations; i++ )
+ {
+ var distance = total * i / (float) ( stations - 1 );
+ var position = b.table.PositionAtDistance( distance );
+ var direction = b.table.DirectionAtDistance( distance, delta );
+
+ var directionXZ = new Vector2( direction.X, direction.Z );
+
+ if ( directionXZ.LengthSquared() > 0.000001f )
+ {
+ directionXZ = directionXZ.Normalized();
+ lastNormal = new Vector2( -directionXZ.Y, directionXZ.X );
+ }
+
+ var halfWidth = b.WidthAtDistance( distance ) / 2;
+ var offset = new Vector3( lastNormal.X, 0, lastNormal.Y ) * halfWidth;
+
+ var left = ToGizmoLocal( position - offset - GlobalPosition );
+ var right = ToGizmoLocal( position + offset - GlobalPosition );
+
+ if ( i == 0 )
+ {
+ lines.Add( left );
+ lines.Add( right );
+ }
+ else
+ {
+ lines.Add( lastLeft );
+ lines.Add( left );
+
+ lines.Add( lastRight );
+ lines.Add( right );
+ }
+
+ if ( i == stations - 1 )
+ {
+ lines.Add( left );
+ lines.Add( right );
+ }
+
+ lastLeft = left;
+ lastRight = right;
+ }
+ }
+
+#endif
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Layers/SplineHeightMapGeneratorLayer.cs.uid b/Runtime/Procedural/HeightMaps/Layers/SplineHeightMapGeneratorLayer.cs.uid
new file mode 100644
index 0000000..b75beb4
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Layers/SplineHeightMapGeneratorLayer.cs.uid
@@ -0,0 +1 @@
+uid://dgpxbuhsqfn3u
diff --git a/Runtime/Procedural/HeightMaps/Mesh/HeightMapMeshAtlas.cs b/Runtime/Procedural/HeightMaps/Mesh/HeightMapMeshAtlas.cs
new file mode 100644
index 0000000..8dc0c64
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Mesh/HeightMapMeshAtlas.cs
@@ -0,0 +1,395 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** The one render pass in the height map system that rasterizes meshes instead
+ of blitting rects.
+
+ It exists because the blit path cannot do two things a spline layer needs.
+ It has no Max blend mode - the native modes are Mix, Add,
+ Sub and Mul - so two overlapping pieces of the same ribbon cannot resolve
+ to the higher one; and nearest-segment math in a shader is discontinuous
+ along the medial axis of a curve, so an analytic ribbon would tear its UVs
+ at inner corners and pinch on sharp turns. A mesh overdraws and lets the
+ depth test sort it out, which gives Max for free and makes corners a
+ non-problem.
+
+ One viewport, one draw, any number of layers. Each layer
+ allocates a region the size of its own texel rect, the regions are shelf
+ packed into this viewport's texture, and every ribbon is drawn into its own
+ region in a single pass. A ribbon never reads the height map, so this pass
+ has no ordering relationship with the layer loop at all - it runs before
+ it, once, and the layers then blit their regions out in tree order.
+
+ It only draws when something changed. Every layer's
+ geometry contributes to a signature; if the signature matches the last
+ pass, the viewport is left alone and its texture is reused. So dragging a
+ circle layer in the same map costs no mesh pass, and dragging a spline
+ point costs exactly one.
+
+ The draw is one frame behind. Nothing here calls
+ RenderingServer.ForceDraw(): it is documented as internal and can crash
+ when called from inside an existing draw, and a stale frame is cheap to
+ fix - the generator re-flags itself whenever this pass queued a redraw, so
+ the next render blits the fresh atlas. During a drag every frame is one
+ frame old, which is invisible; a single Render() press settles after one
+ extra frame.
+ */
+ [Tool]
+ public partial class HeightMapMeshAtlas:SubViewport
+ {
+ /** The widest shelf the packer will use, and the cap on the viewport's own
+ size. Regions wider than this cannot be packed at all.
+ */
+ public int maxSize = 4096;
+
+ /** Asks for a 16-bit float render target rather than an 8-bit one.
+
+ This matters more than it looks: the pass writes UVs and a position
+ along the line, not a colour, and an 8-bit target would quantize the UV
+ to 256 steps across a texture and - because a low dynamic range target
+ is also converted to sRGB on the way out - bend the values on top of
+ that. See HeightMapGenerator.meshAtlasSrgbDecode for the other half of
+ that question.
+ */
+ public bool hdr = true;
+
+ /** Rounded up to this, and never shrunk. A drag changes the packing every
+ frame, and resizing a viewport throws its contents away - so the size
+ only ever grows, and in coarse steps.
+ */
+ const int SizeGranularity = 256;
+
+ const int MinSize = SizeGranularity;
+
+ /** Normalized heights are 0..1, so the camera only has to cover that plus
+ clearance on both sides.
+ */
+ const float CameraDistance = 2;
+ const float CameraNear = 0.05f;
+ const float CameraFar = 4;
+
+
+ Camera3D _camera;
+
+ readonly List _instances = new List();
+
+ int _usedInstances;
+ Vector2I _atlasSize = new Vector2I( MinSize, MinSize );
+
+ int _shelfX;
+ int _shelfY;
+ int _shelfHeight;
+ Vector2I _needed;
+
+ long _signature;
+ long _drawnSignature = -1;
+ bool _configured;
+
+ public Vector2I atlasSize => _atlasSize;
+
+ /** The packed buffer. A plain Texture2D to a sampler, so it goes straight
+ into a blit source slot.
+ */
+ public Texture2D atlasTexture => GetTexture();
+
+ /** Region rect to UV, for the shader that reads a layer's region back out.
+ Normalized against the atlas rather than the region, because the sampler
+ works in whole-texture UV.
+ */
+ public Rect2 RegionUV( Rect2I region )
+ {
+ var size = new Vector2( Mathf.Max( 1, _atlasSize.X ), Mathf.Max( 1, _atlasSize.Y ) );
+
+ return new Rect2(
+ new Vector2( region.Position.X, region.Position.Y ) / size,
+ new Vector2( region.Size.X, region.Size.Y ) / size
+ );
+ }
+
+
+ /* SETUP
+ ------------------------------------------------------------------------
+ OwnWorld3D is the one setting that is not an optimisation: without it the
+ viewport renders the scene the generator lives in, and the atlas would
+ come back full of terrain.
+
+ The explicit Environment is there to pin the tonemapper to Linear. The
+ pass writes data, not light, so anything the post stack would normally do
+ to a colour is damage here.
+ */
+
+ void Configure()
+ {
+ if ( _configured )
+ {
+ return;
+ }
+
+ _configured = true;
+
+ OwnWorld3D = true;
+ TransparentBg = true;
+ Msaa3D = Msaa.Disabled;
+ ScreenSpaceAA = ScreenSpaceAAEnum.Disabled;
+ UseTaa = false;
+ UseDebanding = false;
+ UseHdr2D = hdr;
+ PositionalShadowAtlasSize = 0;
+ AudioListenerEnable3D = false;
+ RenderTargetUpdateMode = UpdateMode.Disabled;
+
+ Size = _atlasSize;
+
+ var environment = new Godot.Environment();
+
+ environment.BackgroundMode = Godot.Environment.BGMode.Color;
+ environment.BackgroundColor = new Color( 0, 0, 0, 0 );
+ environment.AmbientLightSource = Godot.Environment.AmbientSource.Disabled;
+ environment.TonemapMode = Godot.Environment.ToneMapper.Linear;
+ environment.TonemapExposure = 1;
+ environment.TonemapWhite = 1;
+ environment.SsaoEnabled = false;
+ environment.SsilEnabled = false;
+ environment.SdfgiEnabled = false;
+ environment.GlowEnabled = false;
+ environment.FogEnabled = false;
+ environment.VolumetricFogEnabled = false;
+ environment.AdjustmentEnabled = false;
+
+ var world = FindWorld3D();
+
+ if ( world != null )
+ {
+ world.Environment = environment;
+ }
+
+ _camera = new Camera3D();
+
+ _camera.Projection = Camera3D.ProjectionType.Orthogonal;
+ _camera.Near = CameraNear;
+ _camera.Far = CameraFar;
+ _camera.Environment = environment;
+
+ AddChild( _camera );
+ }
+
+ /** Places the camera so that one world unit is one atlas texel and texel
+ (0,0) is the texture's first texel.
+
+ With KeepAspect = Height the orthogonal size is the vertical extent, so
+ setting it to the atlas's height makes the horizontal extent come out as
+ the atlas's width on its own - no aspect correction anywhere, and the
+ ribbon builder can emit plain texel coordinates.
+ */
+ void PlaceCamera()
+ {
+ _camera.KeepAspect = Camera3D.KeepAspectEnum.Height;
+ _camera.Size = Mathf.Max( 1, _atlasSize.Y );
+
+ _camera.Position = new Vector3(
+ _atlasSize.X / 2f, -_atlasSize.Y / 2f, CameraDistance
+ );
+
+ _camera.Rotation = Vector3.Zero;
+ }
+
+
+ /* PACKING */
+
+ /** Starts a pass. Every layer that wants a region has to allocate it after
+ this and before Flush().
+ */
+ public void Begin()
+ {
+ Configure();
+
+ // Re-applied rather than only set up front, so flipping it in the
+ // inspector during the live test actually does something. Changing the
+ // target's format throws its contents away, hence the invalidation.
+ if ( UseHdr2D != hdr )
+ {
+ UseHdr2D = hdr;
+ _drawnSignature = -1;
+ }
+
+ _shelfX = 0;
+ _shelfY = 0;
+ _shelfHeight = 0;
+ _needed = Vector2I.Zero;
+ _usedInstances = 0;
+ _signature = 0;
+ }
+
+ /** A region of exactly this size, or a zero-size rect when it does not fit.
+
+ Shelf packing: fill a row left to right, start a new row when the next
+ region would overflow the maximum width. Simple and good enough because
+ the regions are a handful of similar-sized rects, not a sprite sheet.
+
+ The size is the layer's own texel rect, unrounded - the later blit is a
+ 1:1 texel copy out of this region, and anything else would resample the
+ UVs it is reading.
+ */
+ public Rect2I Allocate( Vector2I size )
+ {
+ if ( size.X <= 0 || size.Y <= 0 || size.X > maxSize || size.Y > maxSize )
+ {
+ return new Rect2I( 0, 0, 0, 0 );
+ }
+
+ if ( _shelfX + size.X > maxSize )
+ {
+ _shelfY += _shelfHeight;
+ _shelfX = 0;
+ _shelfHeight = 0;
+ }
+
+ if ( _shelfY + size.Y > maxSize )
+ {
+ return new Rect2I( 0, 0, 0, 0 );
+ }
+
+ var region = new Rect2I( _shelfX, _shelfY, size.X, size.Y );
+
+ _shelfX += size.X;
+ _shelfHeight = Mathf.Max( _shelfHeight, size.Y );
+
+ _needed = new Vector2I(
+ Mathf.Max( _needed.X, _shelfX ),
+ Mathf.Max( _needed.Y, _shelfY + _shelfHeight )
+ );
+
+ return region;
+ }
+
+ /** Hands a built ribbon over. Meshes are drawn in submission order, which
+ has no effect on the result - the depth test decides, not the order.
+
+ `region` is the rect Allocate() returned. It is handed to the material so
+ the shader can clip to it: a layer's texel rect is clipped to the map, so
+ a spline leaving the map has geometry outside its own region, and the
+ region next to it belongs to another layer.
+ */
+ public void Submit( ArrayMesh mesh, Rect2I region, long signature )
+ {
+ if ( mesh == null )
+ {
+ return;
+ }
+
+ var instance = InstanceAt( _usedInstances );
+
+ instance.Mesh = mesh;
+ instance.Visible = true;
+
+ ( (HeightMapRibbonMaterial) instance.MaterialOverride ).SetRegion( region );
+
+ _usedInstances++;
+
+ _signature = unchecked( ( _signature ^ signature ) * 0x100000001b3 );
+ }
+
+ MeshInstance3D InstanceAt( int index )
+ {
+ while ( _instances.Count <= index )
+ {
+ var created = new MeshInstance3D();
+
+ created.MaterialOverride = new HeightMapRibbonMaterial();
+ created.GIMode = GeometryInstance3D.GIModeEnum.Disabled;
+ created.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off;
+
+ AddChild( created );
+
+ _instances.Add( created );
+ }
+
+ return _instances[ index ];
+ }
+
+ /** Sizes the viewport, hides the unused instances and queues the draw.
+
+ True when a draw was queued - which is the generator's signal to flag
+ itself again, so the next render blits an atlas that is actually up to
+ date rather than one frame behind.
+ */
+ public bool Flush()
+ {
+ Configure();
+
+ for ( var i = _usedInstances; i < _instances.Count; i++ )
+ {
+ _instances[ i ].Visible = false;
+ _instances[ i ].Mesh = null;
+ }
+
+ if ( _usedInstances == 0 )
+ {
+ RenderTargetUpdateMode = UpdateMode.Disabled;
+ _drawnSignature = 0;
+
+ return false;
+ }
+
+ var wanted = WantedSize();
+
+ if ( wanted != _atlasSize )
+ {
+ _atlasSize = wanted;
+ Size = wanted;
+
+ // A resize throws the render target's contents away, so the previous
+ // draw cannot be reused no matter what the geometry did.
+ _drawnSignature = -1;
+ }
+
+ PlaceCamera();
+
+ // Folded in because the region coordinates the meshes were built against
+ // only mean anything relative to this size.
+ var signature = unchecked( ( _signature ^ ( _atlasSize.X * 65537L + _atlasSize.Y ) ) * 0x100000001b3 );
+
+ if ( signature == _drawnSignature )
+ {
+ RenderTargetUpdateMode = UpdateMode.Disabled;
+
+ return false;
+ }
+
+ _drawnSignature = signature;
+ RenderTargetUpdateMode = UpdateMode.Once;
+
+ return true;
+ }
+
+ Vector2I WantedSize()
+ {
+ var width = Mathf.Max( MinSize, Grow( _needed.X ) );
+ var height = Mathf.Max( MinSize, Grow( _needed.Y ) );
+
+ // Grow-only, so a drag that momentarily needs less does not resize and
+ // throw the contents away.
+ return new Vector2I(
+ Mathf.Min( maxSize, Mathf.Max( width, _atlasSize.X ) ),
+ Mathf.Min( maxSize, Mathf.Max( height, _atlasSize.Y ) )
+ );
+ }
+
+ static int Grow( int value )
+ {
+ var steps = Mathf.CeilToInt( value / (float) SizeGranularity );
+
+ return Mathf.Max( 1, steps ) * SizeGranularity;
+ }
+
+ /** Rough VRAM cost of the atlas, in bytes. */
+ public long EstimateMemory()
+ {
+ return (long) _atlasSize.X * _atlasSize.Y * ( hdr ? 8 : 4 );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Mesh/HeightMapMeshAtlas.cs.uid b/Runtime/Procedural/HeightMaps/Mesh/HeightMapMeshAtlas.cs.uid
new file mode 100644
index 0000000..fcb8707
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Mesh/HeightMapMeshAtlas.cs.uid
@@ -0,0 +1 @@
+uid://20sndvur2mgj
diff --git a/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs b/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs
new file mode 100644
index 0000000..f69b591
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs
@@ -0,0 +1,539 @@
+using System.Collections.Generic;
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Turns a Spline into the ribbon mesh that HeightMapMeshAtlas rasterizes.
+
+ This is the line-renderer half of the spline layer: a quad strip walked
+ along the curve, widened to the per-point `width` data, with one span of
+ the strip per texture instance. It is the same technique LineVFX uses,
+ with two differences - the strip is flat in XZ because the map is a height
+ field, and the deformation happens here on the CPU rather than in a shader,
+ because there is nothing to animate and the vertex count is in the
+ hundreds.
+
+ Stations are spaced by arc length, not by point index. A
+ rational bezier does not travel at a constant rate along its parameter, so
+ stepping the parameter evenly would both bunch the geometry up in the tight
+ parts of the curve and stretch the texture there - see
+ SplineArcLengthTable, which exists for this.
+
+ Everything is emitted in atlas texel coordinates: X is the
+ atlas column, Y is the negated atlas row, Z is the normalized height. That
+ makes the mesh independent of the atlas's final size - only the camera
+ depends on it - and it is what lets the atlas pack regions first and size
+ itself afterwards. Negated Y because image row 0 is at the top of the
+ camera's view, so rows have to grow downwards in world space.
+ */
+ public class HeightMapRibbonBuilder
+ {
+ /** Vertex rows per span. Fixed rather than derived from curvature, because
+ the strip is rasterized into texels and a span is short - one texture
+ instance - so the chord error is already far below a texel for anything
+ that is not a hairpin.
+ */
+ public int rowsPerSpan = 24;
+
+ /** How much arc length one copy of the middle texture covers. 0 maps one
+ copy onto each spline segment instead, which keeps the authoring simple
+ at the cost of stretching the texture on an unevenly spaced spline.
+ */
+ public float middleTileLength = 0;
+
+ /** Atlas slots, as HeightMapRibbonAtlas packs them: 0 start, 1 middle,
+ 2 end, in one horizontal row of square slots.
+ */
+ public int slots = 3;
+ public int slotSize = 512;
+
+ public const int SlotStart = 0;
+ public const int SlotMiddle = 1;
+ public const int SlotEnd = 2;
+
+ const int MaxRepeatsPerSegment = 64;
+
+
+ /* THE SPLINE STATE
+ ------------------------------------------------------------------------
+ Resolved once per render by Prepare(), because the curve, the length table
+ and the widths are all needed twice - once to find the layer's texel rect
+ and once to build the mesh into the region that rect got.
+ */
+
+ SplineCurve _curve;
+ SplineArcLengthTable _table;
+ float[] _widths;
+ bool _closed;
+
+ public float length => _table == null ? 0 : _table.length;
+ public int numSegments => _table == null ? 0 : _table.numSegments;
+ public SplineArcLengthTable table => _table;
+
+ /** Reads the spline into a curve, an arc-length table and a per-curve-point
+ width array. False when there is nothing to draw.
+
+ The width array is sized to the curve's points, not the
+ spline's: a closed spline's curve carries one extra point duplicating
+ index 0, so the last entry duplicates the first width too and the seam
+ interpolates instead of jumping.
+ */
+ public bool Prepare( Spline spline, string widthDataName, float defaultWidth, int samplesPerSegment )
+ {
+ _curve = null;
+ _table = null;
+ _widths = null;
+
+ if ( spline == null )
+ {
+ return false;
+ }
+
+ // The curve is cached inside the Spline and nothing invalidates it when a
+ // point moves outside the editor's gizmo pass, so it is dropped here.
+ spline.ClearCurveCache();
+
+ var points = spline.GetPoints();
+
+ if ( points.Count < 2 )
+ {
+ return false;
+ }
+
+ _closed = spline.closed;
+ _curve = spline.GetCurve();
+
+ if ( _curve == null || _curve.points.Count < 2 )
+ {
+ _curve = null;
+
+ return false;
+ }
+
+ _table = SplineArcLengthTable.Create( _curve, samplesPerSegment );
+
+ if ( _table.length <= 0.000001f )
+ {
+ _curve = null;
+ _table = null;
+
+ return false;
+ }
+
+ _widths = new float[ _curve.points.Count ];
+
+ for ( var i = 0; i < _widths.Length; i++ )
+ {
+ var point = points[ i % points.Count ];
+
+ _widths[ i ] = Mathf.Max( 0, spline.ResolveFloatData( point, widthDataName, defaultWidth ) );
+ }
+
+ return true;
+ }
+
+ /** Linearly interpolated between the two surrounding points, which is what
+ "the values are interpolated from one point to the other" means - the
+ width follows the point spacing, not the curve's own parameter speed.
+ */
+ public float WidthAtPointIndex( float pointIndex )
+ {
+ var maxIndex = _widths.Length - 1;
+ var clamped = Mathf.Clamp( pointIndex, 0, maxIndex );
+
+ var lower = Mathf.FloorToInt( clamped );
+ var upper = Mathf.Min( lower + 1, maxIndex );
+
+ return Mathf.Lerp( _widths[ lower ], _widths[ upper ], clamped - lower );
+ }
+
+ public float WidthAtDistance( float distance )
+ {
+ return WidthAtPointIndex( _table.PointIndexAtDistance( distance ) );
+ }
+
+ float tangentDelta => Mathf.Max( 0.01f, _table.length * 0.001f );
+
+ /** The ribbon's world XZ bounds, including the half width at every station.
+
+ Sampled at the same density the mesh will use, so the rect cannot end up
+ smaller than the geometry that goes into it.
+ */
+ public bool GetWorldBounds( out Vector2 min, out Vector2 max )
+ {
+ min = Vector2.Zero;
+ max = Vector2.Zero;
+
+ if ( _table == null )
+ {
+ return false;
+ }
+
+ var total = _table.length;
+ var stations = Mathf.Max( 2, numSegments * Mathf.Max( 2, rowsPerSpan ) );
+ var first = true;
+
+ for ( var i = 0; i < stations; i++ )
+ {
+ var distance = total * i / (float) ( stations - 1 );
+ var position = _table.PositionAtDistance( distance );
+ var halfWidth = WidthAtDistance( distance ) / 2;
+
+ var center = new Vector2( position.X, position.Z );
+ var radius = new Vector2( halfWidth, halfWidth );
+
+ if ( first )
+ {
+ min = center - radius;
+ max = center + radius;
+ first = false;
+
+ continue;
+ }
+
+ min = min.Min( center - radius );
+ max = max.Max( center + radius );
+ }
+
+ return ! first;
+ }
+
+
+ /* THE MESH */
+
+ readonly List _vertices = new List();
+ readonly List _uvs = new List();
+ readonly List _uv2s = new List();
+ readonly List _indices = new List();
+
+ long _signature;
+
+ /** Changes whenever the emitted geometry does. The atlas compares these to
+ decide whether the GPU pass has to run at all - moving a circle layer in
+ the same map then costs no draw.
+ */
+ public long signature => _signature;
+
+ /** One ArrayMesh, one surface. The texture choice is in the UVs rather than
+ in a material, so a ribbon crossing from the start texture into the
+ middle one is still a single draw.
+
+ `rect` is the layer's texel rect in the map, `regionOrigin` where that
+ rect was packed into the atlas. The two have the same size by
+ definition, which is what keeps the later blit a 1:1 texel copy.
+ */
+ public ArrayMesh BuildMesh( HeightMapGenerator generator, Rect2I rect, Vector2I regionOrigin )
+ {
+ _vertices.Clear();
+ _uvs.Clear();
+ _uv2s.Clear();
+ _indices.Clear();
+
+ _signature = HashSeed;
+
+ if ( _table == null )
+ {
+ _signature = 0;
+
+ return null;
+ }
+
+ HashLong( regionOrigin.X );
+ HashLong( regionOrigin.Y );
+ HashLong( rect.Size.X );
+ HashLong( rect.Size.Y );
+
+ // World XZ -> map texel -> atlas texel. The map texel offset of the rect
+ // is subtracted, so a layer whose rect the map's edge clipped still lands
+ // at the region's origin.
+ var texelOffset = new Vector2(
+ regionOrigin.X - rect.Position.X,
+ regionOrigin.Y - rect.Position.Y
+ );
+
+ var segments = numSegments;
+
+ for ( var segment = 0; segment < segments; segment++ )
+ {
+ var slot = SlotFor( segment, segments );
+
+ var segmentStart = _table.DistanceAtPointIndex( segment );
+ var segmentEnd = _table.DistanceAtPointIndex( segment + 1 );
+ var segmentLength = segmentEnd - segmentStart;
+
+ if ( segmentLength <= 0.000001f )
+ {
+ continue;
+ }
+
+ var repeats = RepeatsFor( slot, segmentLength );
+
+ for ( var repeat = 0; repeat < repeats; repeat++ )
+ {
+ var from = Mathf.Lerp( segmentStart, segmentEnd, repeat / (float) repeats );
+ var to = Mathf.Lerp( segmentStart, segmentEnd, ( repeat + 1 ) / (float) repeats );
+
+ BuildSpan( generator, texelOffset, slot, from, to );
+ }
+ }
+
+ if ( _indices.Count == 0 )
+ {
+ _signature = 0;
+
+ return null;
+ }
+
+ // Constant and facing the camera. The material is unshaded, so nothing
+ // reads them - they are here because a surface without normals is an
+ // unusual vertex format to hand a spatial shader, and the array costs
+ // nothing at these vertex counts.
+ var normals = new Vector3[ _vertices.Count ];
+
+ for ( var i = 0; i < normals.Length; i++ )
+ {
+ normals[ i ] = Vector3.Back;
+ }
+
+ var arrays = new Godot.Collections.Array();
+ arrays.Resize( (int) Mesh.ArrayType.Max );
+
+ arrays[ (int) Mesh.ArrayType.Vertex ] = _vertices.ToArray();
+ arrays[ (int) Mesh.ArrayType.Normal ] = normals;
+ arrays[ (int) Mesh.ArrayType.TexUV ] = _uvs.ToArray();
+ arrays[ (int) Mesh.ArrayType.TexUV2 ] = _uv2s.ToArray();
+ arrays[ (int) Mesh.ArrayType.Index ] = _indices.ToArray();
+
+ var mesh = new ArrayMesh();
+
+ mesh.AddSurfaceFromArrays( Mesh.PrimitiveType.Triangles, arrays );
+
+ return mesh;
+ }
+
+ /** First segment takes the start texture, last one the end texture, the
+ rest the middle one.
+
+ A closed spline has no first or last segment to speak of - its ends meet
+ - so it is all middle, which also keeps the seam from showing two
+ different textures.
+ */
+ int SlotFor( int segment, int segments )
+ {
+ if ( _closed )
+ {
+ return SlotMiddle;
+ }
+
+ if ( segment == 0 )
+ {
+ return SlotStart;
+ }
+
+ if ( segment == segments - 1 )
+ {
+ return SlotEnd;
+ }
+
+ return SlotMiddle;
+ }
+
+ int RepeatsFor( int slot, float segmentLength )
+ {
+ if ( slot != SlotMiddle || middleTileLength <= 0.000001f )
+ {
+ return 1;
+ }
+
+ var repeats = Mathf.RoundToInt( segmentLength / middleTileLength );
+
+ return Mathf.Clamp( repeats, 1, MaxRepeatsPerSegment );
+ }
+
+ /** One texture instance: a quad strip from `from` to `to` in arc length,
+ with V running 0..1 across it and U running 0..1 across the width.
+
+ Spans do not share their boundary rows. The two rows at a seam are
+ emitted twice, once with V = 1 and once with V = 0, which is what lets
+ the texture restart exactly without any fract() in the sampler and
+ therefore without a filtering seam.
+ */
+ void BuildSpan( HeightMapGenerator generator, Vector2 texelOffset, int slot, float from, float to )
+ {
+ var rows = Mathf.Max( 1, rowsPerSpan );
+ var delta = tangentDelta;
+ var total = _table.length;
+
+ var firstVertex = _vertices.Count;
+ var lastNormal = new Vector2( 1, 0 );
+
+ for ( var row = 0; row <= rows; row++ )
+ {
+ var v = row / (float) rows;
+ var distance = Mathf.Lerp( from, to, v );
+
+ var position = _table.PositionAtDistance( distance );
+ var direction = _table.DirectionAtDistance( distance, delta );
+
+ var directionXZ = new Vector2( direction.X, direction.Z );
+
+ // A vertical tangent has no XZ direction to be perpendicular to, so the
+ // previous row's normal is carried over rather than snapping.
+ if ( directionXZ.LengthSquared() > 0.000001f )
+ {
+ directionXZ = directionXZ.Normalized();
+ lastNormal = new Vector2( -directionXZ.Y, directionXZ.X );
+ }
+
+ var normal = lastNormal;
+ var halfWidth = WidthAtDistance( distance ) / 2;
+ var center = new Vector2( position.X, position.Z );
+
+ var level = Mathf.Clamp( generator.NormalizeLevel( position.Y ), 0, 1 );
+ var w = total <= 0.000001f ? 0 : distance / total;
+
+ AddVertex( generator, texelOffset, center - normal * halfWidth, level, slot, 0, v, w );
+ AddVertex( generator, texelOffset, center + normal * halfWidth, level, slot, 1, v, w );
+ }
+
+ for ( var row = 0; row < rows; row++ )
+ {
+ var a = firstVertex + row * 2;
+ var b = a + 1;
+ var c = a + 2;
+ var d = a + 3;
+
+ _indices.Add( a ); _indices.Add( b ); _indices.Add( c );
+ _indices.Add( b ); _indices.Add( d ); _indices.Add( c );
+ }
+ }
+
+ void AddVertex(
+ HeightMapGenerator generator, Vector2 texelOffset, Vector2 worldXZ,
+ float level, int slot, float u, float v, float w
+ )
+ {
+ var texel = generator.WorldXZToTexel( worldXZ ) + texelOffset;
+
+ // Negated Y: the camera looks at the atlas from the front with Y up, so
+ // atlas row 0 has to sit at the top of its view.
+ var vertex = new Vector3( texel.X, -texel.Y, level );
+
+ _vertices.Add( vertex );
+ _uvs.Add( AtlasUV( slot, u, v ) );
+ _uv2s.Add( new Vector2( w, 0 ) );
+
+ HashFloat( vertex.X );
+ HashFloat( vertex.Y );
+ HashFloat( vertex.Z );
+ HashFloat( u );
+ HashFloat( v );
+ HashFloat( w );
+ HashLong( slot );
+ }
+
+ /** Slot-local 0..1 mapped into the slot's own texels, inset by half a texel
+ on every side.
+
+ The inset is what keeps a linear tap at a slot's border from bleeding the
+ neighbouring slot in - the slots sit directly against each other, so the
+ start texture's edge is the middle texture's edge.
+ */
+ Vector2 AtlasUV( int slot, float u, float v )
+ {
+ var size = Mathf.Max( 2, slotSize );
+ var width = Mathf.Max( 1, slots ) * size;
+
+ var x = ( slot * size + 0.5f + u * ( size - 1 ) ) / width;
+ var y = ( 0.5f + v * ( size - 1 ) ) / size;
+
+ return new Vector2( x, y );
+ }
+
+
+ /* THE LEVEL LOOKUP
+ ------------------------------------------------------------------------
+ The buffer the mesh writes has three usable channels and four things to
+ carry, so the height level does not fit in it - an opaque spatial material
+ cannot write alpha, and making the material transparent to get the channel
+ back would blend the other three by it and skip the depth write.
+
+ So the level travels as a function of W instead: a 1D table the blit
+ samples with the W it read out of the buffer. That works because a
+ ribbon's level only ever varies along the line, never
+ across it, and W is exactly the along-coordinate. The table is float, so
+ the height keeps its full precision regardless of what the render target
+ turned out to be.
+ */
+
+ ImageTexture _levelTexture;
+ Image _levelImage;
+ int _levelSize;
+
+ public Texture2D levelTexture => _levelTexture;
+
+ /** Rebuilt every render - it is one row of a few hundred texels, and
+ tracking what would have invalidated it costs more than redoing it.
+ */
+ public void BuildLevels( HeightMapGenerator generator, int size )
+ {
+ if ( _table == null )
+ {
+ return;
+ }
+
+ var wanted = Mathf.Clamp( size, 2, 4096 );
+
+ if ( _levelImage == null || _levelSize != wanted )
+ {
+ // Rgbaf rather than a packed byte format: this is the one value in the
+ // whole path that may not be quantized, since the map itself is half
+ // float precisely because 8 bits terrace a slope visibly.
+ _levelImage = Image.CreateEmpty( wanted, 1, false, Image.Format.Rgbaf );
+ _levelTexture = null;
+ _levelSize = wanted;
+ }
+
+ var total = _table.length;
+
+ for ( var i = 0; i < wanted; i++ )
+ {
+ var distance = total * i / (float) ( wanted - 1 );
+ var position = _table.PositionAtDistance( distance );
+ var level = Mathf.Clamp( generator.NormalizeLevel( position.Y ), 0, 1 );
+
+ _levelImage.SetPixel( i, 0, new Color( level, level, level, 1 ) );
+ }
+
+ if ( _levelTexture == null )
+ {
+ _levelTexture = ImageTexture.CreateFromImage( _levelImage );
+ }
+ else
+ {
+ _levelTexture.Update( _levelImage );
+ }
+ }
+
+
+ /* HASHING
+ ------------------------------------------------------------------------
+ FNV-1a over the raw bits, so two runs that produce the same geometry
+ produce the same signature and the GPU pass can be skipped.
+ */
+
+ const long HashSeed = unchecked( (long) 0xcbf29ce484222325 );
+
+ void HashLong( long value )
+ {
+ _signature = unchecked( ( _signature ^ value ) * 0x100000001b3 );
+ }
+
+ void HashFloat( float value )
+ {
+ HashLong( BitConverter.SingleToInt32Bits( value ) );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs.uid b/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs.uid
new file mode 100644
index 0000000..e498627
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Mesh/HeightMapRibbonBuilder.cs.uid
@@ -0,0 +1 @@
+uid://dcyraxachyqi8
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader
new file mode 100644
index 0000000..b578503
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader
@@ -0,0 +1,74 @@
+// The mesh half of a spline height map layer - a G-buffer for a ribbon.
+//
+// This runs inside HeightMapMeshAtlas's SubViewport, on the ribbon mesh that
+// HeightMapRibbonBuilder generates, and writes nothing but the ribbon's
+// parametrization. The height itself is looked up later, in the blit, out of
+// the texture atlas this buffer's UV points into - which is the whole reason
+// the pass exists in this shape:
+//
+// - overlapping ribbons resolve by the depth test, because the
+// mesh is real 3D geometry whose Z is the ribbon's own height. The highest
+// fragment wins, with no blending and no accumulation pass, which is the one
+// thing the blit path cannot do - there is no Max among the native blit
+// blend modes.
+// - a ribbon's UV comes off the mesh, so a start / middle / end texture is
+// just three spans of the same strip pointing into three atlas slots. No
+// branching per texel and no limit of three.
+// - changing a texture does not invalidate this buffer at all, since the
+// lookup is not in here.
+//
+// Channels, read back by heightMapShape_ribbon in HeightMapShape.gdshader:
+//
+// R, G - UV in the texture atlas, slot offset already baked in
+// B - 0.5 + 0.5 * W, where W runs 0..1 over the whole
+// spline, for effects that need to know where along the line they are
+// A - 1, and not usable: an opaque spatial material cannot write alpha
+//
+// B carries the +0.5 bias so it also works as the stencil. The buffer is
+// cleared transparent black, so B == 0 means "no ribbon here", and biasing W
+// keeps every written value inside 0..1 - which an unbiased 1 + W would not,
+// and a low dynamic range render target would then clamp it away.
+//
+// Unshaded and opaque on purpose. Opaque because a transparent material in
+// Godot blends its colour by its own alpha and skips the depth write, and both
+// of those would destroy a G-buffer; unshaded because ALBEDO then reaches the
+// render target untouched by any lighting.
+
+shader_type spatial;
+
+render_mode unshaded, cull_disabled, depth_draw_opaque,
+ shadows_disabled, ambient_light_disabled, fog_disabled;
+
+// The ribbon's own region in the atlas, as x, y, width, height in texels.
+//
+// Not a nicety: a layer's texel rect is clipped to the map, so a spline running
+// off the map's edge has geometry outside the region its rect got - and the
+// atlas packs other layers right next to it. Without this clip, a road leaving
+// the map would overwrite the neighbouring layer's region.
+//
+// One material per ribbon rather than an instance uniform, because a material
+// is a proven call shape here and there are only ever a handful of them.
+uniform vec4 ribbon_region = vec4( 0.0, 0.0, 1.0, 1.0 );
+
+// Atlas texel position, reconstructed from the vertex position - which is in
+// atlas texels by construction, with Y negated so image row 0 ends up at the
+// top of the camera's view. Interpolating it is exact, since the mapping from
+// here to the render target is 1:1 and affine.
+varying vec2 atlas_texel;
+
+void vertex()
+{
+ atlas_texel = vec2( VERTEX.x, -VERTEX.y );
+}
+
+void fragment()
+{
+ vec2 from = atlas_texel - ribbon_region.xy;
+
+ if ( from.x < 0.0 || from.y < 0.0 || from.x > ribbon_region.z || from.y > ribbon_region.w )
+ {
+ discard;
+ }
+
+ ALBEDO = vec3( UV, 0.5 + 0.5 * UV2.x );
+}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader.uid b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader.uid
new file mode 100644
index 0000000..ff43664
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader.uid
@@ -0,0 +1 @@
+uid://cxx4pwxqrdrxe
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbonMaterial.cs b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbonMaterial.cs
new file mode 100644
index 0000000..fa63a71
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbonMaterial.cs
@@ -0,0 +1,39 @@
+using Godot;
+using System;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ public class HeightMapRibbonShader
+ {
+ public static readonly CachedResource shader = new CachedResource(
+ "res://addons/rokojori_action_library/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbon.gdshader"
+ );
+
+ public static readonly Vector4PropertyName region = Vector4PropertyName.Create( "ribbon_region" );
+ }
+
+ /** Material for HeightMapRibbon.gdshader.
+
+ One per ribbon rather than one shared, because the region clip has to be
+ per ribbon - see the shader. Everything else the pass writes comes off the
+ mesh's own vertex attributes.
+ */
+ [Tool]
+ public partial class HeightMapRibbonMaterial:CustomMaterial
+ {
+ public readonly CustomMaterialProperty region;
+
+ public HeightMapRibbonMaterial()
+ {
+ Shader = HeightMapRibbonShader.shader.Get();
+
+ region = new CustomMaterialProperty( this, HeightMapRibbonShader.region );
+ }
+
+ public void SetRegion( Rect2I rect )
+ {
+ region.Set( new Vector4( rect.Position.X, rect.Position.Y, rect.Size.X, rect.Size.Y ) );
+ }
+ }
+}
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbonMaterial.cs.uid b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbonMaterial.cs.uid
new file mode 100644
index 0000000..674418f
--- /dev/null
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapRibbonMaterial.cs.uid
@@ -0,0 +1 @@
+uid://b351cuyfhytbo
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
index 4e8ad25..7c62d62 100644
--- a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader
@@ -27,11 +27,13 @@
shader_type texture_blit;
render_mode blend_disabled;
+#include "res://addons/rokojori_action_library/Runtime/Shading/Library/Colors.gdshaderinc"
+
// 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 - 0 Circle, 1 Image.
+// HeightMapShapeType - 0 Circle, 1 Image, 2 Ribbon.
uniform int shape_type = 0;
// HeightMapBlendMode - 0 Add, 1 Subtract, 2 Replace.
@@ -53,6 +55,30 @@ uniform float shape_inner_radius = 0.0;
uniform float shape_outer_radius = 1.0;
uniform bool shape_smooth_falloff = true;
+// Ribbon. Source 0 carries HeightMapMeshAtlas's packed G-buffer rather than an
+// image - see heightMapShape_ribbon.
+uniform sampler2D shape_ribbon_atlas : hint_blit_source2, filter_linear, repeat_enable;
+uniform sampler2D shape_ribbon_levels : hint_blit_source3, filter_linear, repeat_enable;
+
+// Where this layer's region sits in the atlas, and the atlas's size in texels -
+// the size is what lets the sampler land on exact texel centres.
+uniform vec2 shape_ribbon_region_uv_min = vec2( 0.0, 0.0 );
+uniform vec2 shape_ribbon_region_uv_size = vec2( 1.0, 1.0 );
+uniform vec2 shape_ribbon_buffer_size = vec2( 1.0, 1.0 );
+
+// How many slots the texture atlas is divided into, which is also what recovers
+// the across-the-width coordinate from the atlas U.
+uniform int shape_ribbon_slots = 3;
+
+uniform float shape_ribbon_edge_falloff = 0.0;
+uniform float shape_ribbon_fade_in = 0.0;
+uniform float shape_ribbon_fade_out = 0.0;
+
+// Undoes the sRGB encoding a low dynamic range render target applies on its way
+// out. Off when the atlas viewport got the float target it asks for, which is
+// the normal case - see HeightMapMeshAtlas.hdr.
+uniform bool shape_ribbon_srgb_decode = false;
+
// Image. The texture arrives as the blit's source 0; the layer's rect is
// described by its two world-space XZ axes and its size in meters, so a rotated
// layer needs no matrix and no UV rect.
@@ -175,6 +201,89 @@ void heightMapShape_image( vec2 worldXZ, out float amount, out float coverage )
coverage = shape_image_channel >= 4 ? color.a : 1.0;
}
+/** A spline ribbon, read back out of HeightMapMeshAtlas's G-buffer.
+
+ The geometry was already rasterized, with the depth test resolving overlaps
+ to the highest piece, so there is no distance field and no nearest-segment
+ search here - only a lookup. The buffer carries, per texel:
+
+ R, G - UV into the packed start / middle / end texture atlas
+ B - 0.5 + 0.5 * W, with W running 0..1 over the whole spline
+ A - unusable, see HeightMapRibbon.gdshader
+
+ B doubles as the stencil: the buffer is cleared to transparent black, so a
+ B of 0 is "no ribbon covers this texel" and every written value is at least
+ 0.5.
+
+ The height level cannot fit in the buffer, so it arrives as
+ a 1D table keyed by W - which is exact rather than a compromise, because a
+ ribbon's level only varies along the line and W is the along-coordinate.
+*/
+void heightMapShape_ribbon( vec2 uv, out float amount, out float coverage, inout float level )
+{
+ amount = 0.0;
+ coverage = 0.0;
+
+ // The region is texel-identical to the rect being blitted, so this is a 1:1
+ // texel mapping. Snapping to the texel centre keeps the linear sampler from
+ // blending in a neighbour - which at the ribbon's silhouette is cleared black,
+ // and would smear the UV into garbage along the whole outline.
+ vec2 texel = ( shape_ribbon_region_uv_min + uv * shape_ribbon_region_uv_size ) * shape_ribbon_buffer_size;
+ vec2 bufferUV = ( floor( texel ) + 0.5 ) / shape_ribbon_buffer_size;
+
+ vec3 buffer = texture( shape_image, bufferUV ).rgb;
+
+ if ( shape_ribbon_srgb_decode )
+ {
+ buffer = SRGBtoLINEAR( buffer );
+ }
+
+ if ( buffer.b < 0.25 )
+ {
+ return;
+ }
+
+ float w = clamp( ( buffer.b - 0.5 ) * 2.0, 0.0, 1.0 );
+
+ vec4 color = texture( shape_ribbon_atlas, buffer.rg );
+
+ amount = heightMapShape_channel( color, shape_image_channel );
+
+ float textureCoverage = shape_image_channel >= 4 ? color.a : 1.0;
+
+ // Across-the-width, recovered from the slot-local part of the atlas U. The
+ // slots are equal and laid out in one row, and the width spans exactly one
+ // slot, so this is exact at the ribbon's two edges - which is the only place
+ // the falloff has to be right.
+ float slots = float( max( shape_ribbon_slots, 1 ) );
+ float across = fract( buffer.r * slots );
+
+ // 0 at either edge, 1 at the centre line.
+ float fromEdge = min( across, 1.0 - across ) * 2.0;
+
+ float edgeFactor = shape_ribbon_edge_falloff <= 0.0001
+ ? 1.0
+ : smoothstep( 0.0, shape_ribbon_edge_falloff, fromEdge );
+
+ float fadeIn = shape_ribbon_fade_in <= 0.0001
+ ? 1.0
+ : smoothstep( 0.0, shape_ribbon_fade_in, w );
+
+ float fadeOut = shape_ribbon_fade_out <= 0.0001
+ ? 1.0
+ : smoothstep( 0.0, shape_ribbon_fade_out, 1.0 - w );
+
+ coverage = textureCoverage * edgeFactor * fadeIn * fadeOut;
+
+ // Replaces the layer's flat base level with the spline's own interpolated
+ // elevation at this point along the line. Add and Subtract ignore it, the
+ // same way they ignore a shape layer's Y.
+ level = texture(
+ shape_ribbon_levels,
+ heightMapShape_clampToEdge( shape_ribbon_levels, vec2( w, 0.5 ) )
+ ).r;
+}
+
/** The mask's contribution to coverage. 1 everywhere when no mask is assigned,
because the stand-in bound in its place is white.
*/
@@ -198,10 +307,16 @@ void blit()
float amount = 0.0;
float coverage = 0.0;
+ // The base level is a uniform for every shape that sits at one height, and an
+ // inout so the ribbon can replace it per texel with the spline's own
+ // interpolated elevation.
+ float level = shape_value_base;
+
switch ( shape_type )
{
case 0: heightMapShape_circle( worldXZ, amount, coverage ); break;
case 1: heightMapShape_image( worldXZ, amount, coverage ); break;
+ case 2: heightMapShape_ribbon( UV, amount, coverage, level ); break;
}
// Applied to coverage rather than to the height, so a masked-out region falls
@@ -213,7 +328,7 @@ void blit()
{
// Replace: the height goes in RGB and the coverage rides on alpha, so the
// Mix blend crossfades from the layer's height back to what is underneath.
- COLOR0 = vec4( vec3( shape_value_base + shape_value_scale * amount ), coverage );
+ COLOR0 = vec4( vec3( level + shape_value_scale * amount ), coverage );
}
else
{
diff --git a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
index 9ad4f15..fa0c1dc 100644
--- a/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
+++ b/Runtime/Procedural/HeightMaps/Shaders/HeightMapShapeMaterial.cs
@@ -11,7 +11,8 @@ namespace Rokojori
public enum HeightMapShapeType
{
Circle = 0,
- Image = 1
+ Image = 1,
+ Ribbon = 2
}
public class HeightMapShapeShader
@@ -40,6 +41,15 @@ namespace Rokojori
public static readonly IntPropertyName imageChannel = IntPropertyName.Create( "shape_image_channel" );
public static readonly BoolPropertyName imageTiling = BoolPropertyName.Create( "shape_image_tiling" );
+ public static readonly Vector2PropertyName ribbonRegionUVMin = Vector2PropertyName.Create( "shape_ribbon_region_uv_min" );
+ public static readonly Vector2PropertyName ribbonRegionUVSize = Vector2PropertyName.Create( "shape_ribbon_region_uv_size" );
+ public static readonly Vector2PropertyName ribbonBufferSize = Vector2PropertyName.Create( "shape_ribbon_buffer_size" );
+ public static readonly IntPropertyName ribbonSlots = IntPropertyName.Create( "shape_ribbon_slots" );
+ public static readonly FloatPropertyName ribbonEdgeFalloff = FloatPropertyName.Create( "shape_ribbon_edge_falloff" );
+ public static readonly FloatPropertyName ribbonFadeIn = FloatPropertyName.Create( "shape_ribbon_fade_in" );
+ public static readonly FloatPropertyName ribbonFadeOut = FloatPropertyName.Create( "shape_ribbon_fade_out" );
+ public static readonly BoolPropertyName ribbonSrgbDecode = BoolPropertyName.Create( "shape_ribbon_srgb_decode" );
+
public static readonly IntPropertyName maskChannel = IntPropertyName.Create( "shape_mask_channel" );
public static readonly Vector2PropertyName maskCenter = Vector2PropertyName.Create( "shape_mask_center" );
public static readonly Vector2PropertyName maskAxisX = Vector2PropertyName.Create( "shape_mask_axis_x" );
@@ -81,6 +91,15 @@ namespace Rokojori
public readonly CustomMaterialProperty imageChannel;
public readonly CustomMaterialProperty imageTiling;
+ public readonly CustomMaterialProperty ribbonRegionUVMin;
+ public readonly CustomMaterialProperty ribbonRegionUVSize;
+ public readonly CustomMaterialProperty ribbonBufferSize;
+ public readonly CustomMaterialProperty ribbonSlots;
+ public readonly CustomMaterialProperty ribbonEdgeFalloff;
+ public readonly CustomMaterialProperty ribbonFadeIn;
+ public readonly CustomMaterialProperty ribbonFadeOut;
+ public readonly CustomMaterialProperty ribbonSrgbDecode;
+
public readonly CustomMaterialProperty maskChannel;
public readonly CustomMaterialProperty maskCenter;
public readonly CustomMaterialProperty maskAxisX;
@@ -111,6 +130,15 @@ namespace Rokojori
imageChannel = new CustomMaterialProperty( this, HeightMapShapeShader.imageChannel );
imageTiling = new CustomMaterialProperty( this, HeightMapShapeShader.imageTiling );
+ ribbonRegionUVMin = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonRegionUVMin );
+ ribbonRegionUVSize = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonRegionUVSize );
+ ribbonBufferSize = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonBufferSize );
+ ribbonSlots = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonSlots );
+ ribbonEdgeFalloff = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonEdgeFalloff );
+ ribbonFadeIn = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonFadeIn );
+ ribbonFadeOut = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonFadeOut );
+ ribbonSrgbDecode = new CustomMaterialProperty( this, HeightMapShapeShader.ribbonSrgbDecode );
+
maskChannel = new CustomMaterialProperty( this, HeightMapShapeShader.maskChannel );
maskCenter = new CustomMaterialProperty( this, HeightMapShapeShader.maskCenter );
maskAxisX = new CustomMaterialProperty( this, HeightMapShapeShader.maskAxisX );
@@ -155,6 +183,15 @@ namespace Rokojori
imageChannel.Set( (int) HeightMapChannel.R );
imageTiling.Set( false );
+ ribbonRegionUVMin.Set( Vector2.Zero );
+ ribbonRegionUVSize.Set( Vector2.One );
+ ribbonBufferSize.Set( Vector2.One );
+ ribbonSlots.Set( 3 );
+ ribbonEdgeFalloff.Set( 0 );
+ ribbonFadeIn.Set( 0 );
+ ribbonFadeOut.Set( 0 );
+ ribbonSrgbDecode.Set( false );
+
maskChannel.Set( (int) HeightMapChannel.A );
SetMaskRect( Vector2.Zero, new Vector2( 1, 0 ), new Vector2( 0, 1 ), Vector2.One );
}
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointColorData.cs b/Runtime/Procedural/Parametric/Spline/Data/SplinePointColorData.cs
new file mode 100644
index 0000000..a4c6375
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointColorData.cs
@@ -0,0 +1,17 @@
+
+using Godot;
+using Rokojori;
+using System.Collections.Generic;
+
+using Rokojori.Extensions;
+namespace Rokojori;
+
+
+ [Tool]
+ [GlobalClass]
+ public partial class SplinePointColorData:SplinePointData
+ {
+ [Export]
+ public Color color;
+
+ }
\ No newline at end of file
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointColorData.cs.uid b/Runtime/Procedural/Parametric/Spline/Data/SplinePointColorData.cs.uid
new file mode 100644
index 0000000..8f76979
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointColorData.cs.uid
@@ -0,0 +1 @@
+uid://bn4kkkileju7p
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointData.cs b/Runtime/Procedural/Parametric/Spline/Data/SplinePointData.cs
new file mode 100644
index 0000000..9e226e2
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointData.cs
@@ -0,0 +1,17 @@
+
+using Godot;
+using Rokojori;
+using System.Collections.Generic;
+
+using Rokojori.Extensions;
+namespace Rokojori;
+
+
+ [Tool]
+ [GlobalClass]
+ public abstract partial class SplinePointData : Resource
+ {
+ [Export]
+ public string name;
+
+ }
\ No newline at end of file
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointData.cs.uid b/Runtime/Procedural/Parametric/Spline/Data/SplinePointData.cs.uid
new file mode 100644
index 0000000..bd09a1e
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointData.cs.uid
@@ -0,0 +1 @@
+uid://bhykfdc60h5fk
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointDataX.cs b/Runtime/Procedural/Parametric/Spline/Data/SplinePointDataX.cs
new file mode 100644
index 0000000..c61a12d
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointDataX.cs
@@ -0,0 +1,68 @@
+
+using Godot;
+using Rokojori;
+using System.Collections.Generic;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Named lookup over a SplinePointData array.
+
+ The data arrays are the spline's extension mechanism: a consumer asks for
+ an entry by name and type, and anything it does not understand is simply
+ not found. A SplinePoint carries its own `data`, a Spline carries
+ `defaultData` - which is what makes a value authorable once for the
+ whole spline and overridable per point, without the consumer
+ needing a property of its own for either.
+
+ Spline.ResolveFloatData() is the full rule: point, then spline default,
+ then the caller's fallback.
+ */
+ public static class SplinePointDataX
+ {
+ /** The first entry with this name that is also a T, or null.
+
+ Name and type both have to match: two systems can use the same name for
+ different data types without one of them silently reading the other's.
+ */
+ public static T FindData( this SplinePointData[] data, string name ) where T:SplinePointData
+ {
+ if ( data == null || name == null )
+ {
+ return null;
+ }
+
+ for ( var i = 0; i < data.Length; i++ )
+ {
+ var entry = data[ i ];
+
+ if ( entry == null || entry.name != name )
+ {
+ continue;
+ }
+
+ var typed = entry as T;
+
+ if ( typed != null )
+ {
+ return typed;
+ }
+ }
+
+ return null;
+ }
+
+ public static bool HasData( this SplinePointData[] data, string name ) where T:SplinePointData
+ {
+ return data.FindData( name ) != null;
+ }
+
+ /** The named float, or `fallback` when there is no such entry. */
+ public static float FindFloatData( this SplinePointData[] data, string name, float fallback )
+ {
+ var entry = data.FindData( name );
+
+ return entry == null ? fallback : entry.value;
+ }
+ }
+}
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointDataX.cs.uid b/Runtime/Procedural/Parametric/Spline/Data/SplinePointDataX.cs.uid
new file mode 100644
index 0000000..476c086
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointDataX.cs.uid
@@ -0,0 +1 @@
+uid://d16nlsrm24tkn
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointFloatData.cs b/Runtime/Procedural/Parametric/Spline/Data/SplinePointFloatData.cs
new file mode 100644
index 0000000..a70f39e
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointFloatData.cs
@@ -0,0 +1,17 @@
+
+using Godot;
+using Rokojori;
+using System.Collections.Generic;
+
+using Rokojori.Extensions;
+namespace Rokojori;
+
+
+ [Tool]
+ [GlobalClass]
+ public partial class SplinePointFloatData:SplinePointData
+ {
+ [Export]
+ public float value;
+
+ }
\ No newline at end of file
diff --git a/Runtime/Procedural/Parametric/Spline/Data/SplinePointFloatData.cs.uid b/Runtime/Procedural/Parametric/Spline/Data/SplinePointFloatData.cs.uid
new file mode 100644
index 0000000..a4deb2d
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/Data/SplinePointFloatData.cs.uid
@@ -0,0 +1 @@
+uid://dvo63swkcj63f
diff --git a/Runtime/Procedural/Parametric/Spline/Spline.cs b/Runtime/Procedural/Parametric/Spline/Spline.cs
index c8db90b..b4d8b8e 100644
--- a/Runtime/Procedural/Parametric/Spline/Spline.cs
+++ b/Runtime/Procedural/Parametric/Spline/Spline.cs
@@ -63,6 +63,8 @@ namespace Rokojori
[Export]
public int xzPathBakeResolution = 50;
+ [Export]
+ public SplinePointData[] defaultData;
public void SetEditorPointSize( float size )
{
@@ -83,6 +85,49 @@ namespace Rokojori
}
+ /** The spline's points as the curve sees them - direct SplinePoint children
+ with the coincident ones dropped.
+
+ This is the list a consumer has to index when it wants per-point data
+ alongside the curve: GetCurve()'s points are built from exactly this
+ list, in this order, so curve point `i` is this list's point `i`. A
+ closed spline's curve has one point more than this list, duplicating
+ index 0 at the end.
+ */
+ public List GetPoints()
+ {
+ return GetFilteredSplinePoints();
+ }
+
+ /** The named entry from `defaultData` - the spline-wide value a point can
+ override with an entry of its own.
+ */
+ public T GetDefaultData( string name ) where T:SplinePointData
+ {
+ return defaultData.FindData( name );
+ }
+
+ /** The full resolution rule for a named float: the point's own entry, then
+ the spline's defaultData, then `fallback`.
+
+ This is what lets a layer ask for something like "width" without
+ requiring it to be authored anywhere - a spline with no width data at
+ all answers with the fallback, one with a single defaultData entry
+ answers with that everywhere, and a point with its own entry overrides
+ it locally.
+ */
+ public float ResolveFloatData( SplinePoint point, string name, float fallback )
+ {
+ var pointEntry = point == null ? null : point.GetData( name );
+
+ if ( pointEntry != null )
+ {
+ return pointEntry.value;
+ }
+
+ return defaultData.FindFloatData( name, fallback );
+ }
+
List GetFilteredSplinePoints()
{
var unfilteredSplinePoints = Nodes.GetDirectChildren( this );
diff --git a/Runtime/Procedural/Parametric/Spline/SplineArcLengthTable.cs b/Runtime/Procedural/Parametric/Spline/SplineArcLengthTable.cs
new file mode 100644
index 0000000..b1e4be4
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/SplineArcLengthTable.cs
@@ -0,0 +1,175 @@
+
+using Godot;
+using Rokojori;
+using System.Collections.Generic;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** Arc-length reparametrization of a SplineCurve.
+
+ A spline's own parameter is the point index, and a rational cubic bezier
+ does not travel at a constant rate along it - so stepping the point index
+ evenly gives rows that bunch up in the tight parts of the curve and spread
+ out in the straight ones. Anything that lays a texture, a mesh or a
+ placement along a spline wants to step by distance
+ instead, and there is no closed form for that: the only way is to sample
+ the curve, accumulate chord lengths and invert the table.
+
+ This class is that table. Create it once per curve state, then convert
+ freely in both directions.
+
+ `samplesPerSegment` is the accuracy knob. The table's length is always a
+ slight underestimate, because chords cut the corner off
+ the curve they approximate - 16 samples per segment is well under a
+ percent for anything that is not a hairpin.
+ */
+ public class SplineArcLengthTable
+ {
+ SplineCurve _curve;
+ int _samplesPerSegment;
+
+ /** Cumulative distance at sample i, which sits at point index
+ i / samplesPerSegment. Monotonically non-decreasing by construction,
+ which is what makes the binary search in PointIndexAtDistance valid.
+ */
+ float[] _distances;
+
+ public SplineCurve curve => _curve;
+
+ public float length => _distances.Length == 0 ? 0 : _distances[ _distances.Length - 1 ];
+
+ public int numSegments => Mathf.Max( 0, _curve.points.Count - 1 );
+
+ public static SplineArcLengthTable Create( SplineCurve curve, int samplesPerSegment = 16 )
+ {
+ var table = new SplineArcLengthTable();
+
+ table._curve = curve;
+ table._samplesPerSegment = Mathf.Max( 1, samplesPerSegment );
+ table.Bake();
+
+ return table;
+ }
+
+ void Bake()
+ {
+ var segments = numSegments;
+
+ if ( segments <= 0 )
+ {
+ _distances = new float[]{ 0 };
+
+ return;
+ }
+
+ var numSamples = segments * _samplesPerSegment + 1;
+
+ _distances = new float[ numSamples ];
+
+ var last = _curve.GetPositionByPointIndex( 0 );
+ var distance = 0f;
+
+ _distances[ 0 ] = 0;
+
+ for ( var i = 1; i < numSamples; i++ )
+ {
+ var pointIndex = i / (float) _samplesPerSegment;
+ var position = _curve.GetPositionByPointIndex( pointIndex );
+
+ distance += ( position - last ).Length();
+ _distances[ i ] = distance;
+
+ last = position;
+ }
+ }
+
+ /** Distance along the curve at a point index, interpolated between the two
+ surrounding samples.
+ */
+ public float DistanceAtPointIndex( float pointIndex )
+ {
+ var maxIndex = _distances.Length - 1;
+ var sample = Mathf.Clamp( pointIndex * _samplesPerSegment, 0, maxIndex );
+
+ var lower = Mathf.FloorToInt( sample );
+ var upper = Mathf.Min( lower + 1, maxIndex );
+
+ return Mathf.Lerp( _distances[ lower ], _distances[ upper ], sample - lower );
+ }
+
+ /** The point index a distance along the curve lands on - the inverse of
+ DistanceAtPointIndex, by binary search plus a linear step inside the
+ sample it lands in.
+
+ Distances outside the curve clamp to its ends rather than extrapolating.
+ */
+ public float PointIndexAtDistance( float distance )
+ {
+ var maxIndex = _distances.Length - 1;
+
+ if ( maxIndex <= 0 || distance <= 0 )
+ {
+ return 0;
+ }
+
+ if ( distance >= length )
+ {
+ return maxIndex / (float) _samplesPerSegment;
+ }
+
+ var low = 0;
+ var high = maxIndex;
+
+ // Invariant: _distances[ low ] <= distance < _distances[ high ].
+ while ( high - low > 1 )
+ {
+ var middle = ( low + high ) / 2;
+
+ if ( _distances[ middle ] <= distance )
+ {
+ low = middle;
+ }
+ else
+ {
+ high = middle;
+ }
+ }
+
+ var span = _distances[ high ] - _distances[ low ];
+
+ // Two coincident samples - a zero-length span has no inside to land in.
+ var fraction = span <= 0.000001f ? 0 : ( distance - _distances[ low ] ) / span;
+
+ return ( low + fraction ) / _samplesPerSegment;
+ }
+
+ public Vector3 PositionAtDistance( float distance )
+ {
+ return _curve.GetPositionByPointIndex( PointIndexAtDistance( distance ) );
+ }
+
+ /** The curve's direction at a distance, as a central difference over
+ `delta` meters. Falls back to a forward or backward difference at the
+ ends, and to zero on a degenerate curve.
+ */
+ public Vector3 DirectionAtDistance( float distance, float delta )
+ {
+ var total = length;
+
+ if ( total <= 0.000001f )
+ {
+ return Vector3.Zero;
+ }
+
+ var step = Mathf.Min( Mathf.Max( delta, 0.0001f ), total * 0.5f );
+
+ var before = Mathf.Max( 0, distance - step );
+ var after = Mathf.Min( total, distance + step );
+
+ var direction = PositionAtDistance( after ) - PositionAtDistance( before );
+
+ return direction.LengthSquared() < 0.000000001f ? Vector3.Zero : direction.Normalized();
+ }
+ }
+}
diff --git a/Runtime/Procedural/Parametric/Spline/SplineArcLengthTable.cs.uid b/Runtime/Procedural/Parametric/Spline/SplineArcLengthTable.cs.uid
new file mode 100644
index 0000000..c93f76c
--- /dev/null
+++ b/Runtime/Procedural/Parametric/Spline/SplineArcLengthTable.cs.uid
@@ -0,0 +1 @@
+uid://c14ylpe2b3ech
diff --git a/Runtime/Procedural/Parametric/Spline/SplinePoint.cs b/Runtime/Procedural/Parametric/Spline/SplinePoint.cs
index 1698713..2ba9a87 100644
--- a/Runtime/Procedural/Parametric/Spline/SplinePoint.cs
+++ b/Runtime/Procedural/Parametric/Spline/SplinePoint.cs
@@ -94,7 +94,7 @@ namespace Rokojori
#endif
-
+ [ExportGroup("")]
[Export]
public float twist = 0;
@@ -120,8 +120,30 @@ namespace Rokojori
[Export( PropertyHint.Range, "0,1")]
public float symmetricTangentLength = 0f;
+ [Export]
+ public SplinePointData[] data;
+ /* NAMED DATA
+ ------------------------------------------------------------------------
+ Only this point's own entries. A consumer normally wants
+ Spline.ResolveFloatData() instead, which falls back to the spline's
+ defaultData before it falls back to a constant - see SplinePointDataX.
+ */
+ public T GetData( string name ) where T:SplinePointData
+ {
+ return data.FindData( name );
+ }
+
+ public bool HasData( string name ) where T:SplinePointData
+ {
+ return data.HasData( name );
+ }
+
+ public float GetFloatData( string name, float fallback )
+ {
+ return data.FindFloatData( name, fallback );
+ }
}
}
\ No newline at end of file
diff --git a/Runtime/Rendering/Assets/Foliage/GPUFoliageShader.gdshader b/Runtime/Rendering/Assets/Foliage/GPUFoliageShader.gdshader
index f867ee9..64215da 100644
--- a/Runtime/Rendering/Assets/Foliage/GPUFoliageShader.gdshader
+++ b/Runtime/Rendering/Assets/Foliage/GPUFoliageShader.gdshader
@@ -18,13 +18,14 @@ uniform float heightOffset = 0;
uniform sampler2D heightMap;
uniform float minHeight = 0;
uniform float maxHeight = 100;
-
+uniform sampler2D normalMap;
uniform sampler2D rotationVariance;
uniform vec3 minRotation = vec3( 0, 0, 0 );
uniform vec3 maxRotation = vec3( 0, 1, 0 );
uniform vec2 rotationUVScale = vec2( 1.0, 1.0 );
uniform vec2 rotationUVOffset = vec2( 1.0, 1.0 );
+uniform float alignToNormal:hint_range(0.0, 1.0) = 1.0;
uniform sampler2D scaleVariance;
uniform float scaleVarianceContrastScale = 1.0;
@@ -58,6 +59,9 @@ uniform float discardTreshold = 0.01;
uniform float discardOffset = 1000000;
uniform sampler2D coverageMap;
+uniform float normalDiscardTreshold:hint_range(0.0,180.0) = 180;
+uniform float normalDiscardFade:hint_range(0.0,180.0) = 0;
+
uniform sampler2D colorMap;
uniform vec2 mapSize = vec2(1024,1024);
uniform vec2 mapCenter = vec2(0,0);
@@ -102,6 +106,8 @@ void process()
vec3 offset = (textureLod( positionVariance, tilingOffset( uv, positionUVScale, positionUVOffset ), 0 ).rgb - vec3(0.5,0.5,0.5) ) * maxPositionOffset;
position3D += offset + positionOffset;
vec3 rotSampled = textureLod( rotationVariance, tilingOffset( uv, rotationUVScale, rotationUVOffset ), 0 ).rgb;
+ // vec3 rotFromNormal = normalMapToEuler( normalMap, uv ) * alignToNormal;
+
vec3 rot = ( rotSampled * ( maxRotation - minRotation ) + minRotation ) * PI * 2.0;
// rot.y = round( 4.0 * rot.y / PI * 2.0 ) / 4.0 * PI * 2.0;
vec3 sca = textureLod( scaleVariance, tilingOffset( uv, scaleUVScale, scaleUVOffset ), 0 ).rgb;
@@ -136,5 +142,9 @@ void process()
position3D.y += discardOffset;
}
- TRANSFORM = TRS( position3D, rot, vec3( sca.x , sca.y, sca.z ) );
+ float normalAmount = getNormalMapAlignment( normalMap, uv, vec3( 0.0, 1.0, 0.0 ), normalDiscardTreshold, normalDiscardFade );
+
+ position3D.y += normalAmount > 0.0 ? 0.0 : discardOffset;
+
+ TRANSFORM = TRS( position3D, rot, sca );
}
diff --git a/Runtime/Shading/Library/ControlMap.gdshaderinc b/Runtime/Shading/Library/ControlMap.gdshaderinc
new file mode 100644
index 0000000..451a927
--- /dev/null
+++ b/Runtime/Shading/Library/ControlMap.gdshaderinc
@@ -0,0 +1,631 @@
+// #include "res://addons/rokojori_action_library/Runtime/Shading/Library/ControlMap.gdshaderinc"
+//
+// Terrain texture painting from a packed control map, in the shape Terrain3D
+// uses: one 32-bit integer per texel, carrying a base texture index, an overlay
+// index, the blend between them, a uv scale and rotation, and the hole and
+// navigable flags.
+//
+// WHY A PACKED INTEGER
+// ---------------------------------------------------------------------------
+// A weight map per texture does not scale - eight textures is eight channels,
+// and every fragment pays for all of them whether they are present or not. Two
+// indices plus one blend is a fixed cost for any number of textures, and the
+// indices are exact: texture 7 next to texture 3 has no third texture bleeding
+// into the seam.
+//
+// The price is that the data is not interpolable. Bit 14 of a
+// texture index means nothing in common with bit 14 of its neighbour, so the
+// control map can only ever be read with texelFetch - never with a filtered
+// texture() call, and never through a hardware blend. Everything below follows
+// from that one constraint:
+//
+// - reads are texelFetch, so the map must be a single-channel float format
+// ( R32F ) and the bits come back through floatBitsToUint
+// - the softness a bilinear filter would have given has to be rebuilt by
+// hand: 4 taps, decoded separately, and their shading results blended by
+// the bilinear weights ( ControlMap_getTaps )
+// - a per-texel uv scale and rotation means the uv is discontinuous between
+// texels, which breaks the implicit derivative a texture() call uses to
+// pick its mip. So every sample is textureGrad with the gradient computed
+// analytically from the world-space derivative.
+//
+// THE LAYOUT IS DATA, NOT CODE
+// ---------------------------------------------------------------------------
+// Terrain3D hard-codes its bit layout in macros ( DECODE_BASE is >>27 & 0x1F ).
+// Here every field is an ivec2 of ( bit offset, bit count ) in ControlMapSetup,
+// so the layout is decided by ControlMapFormat on the C# side and pushed in as
+// uniforms. That is what keeps the CPU packer and the GPU decoder from being
+// able to disagree - there is only one description of the layout, and both read
+// it. Narrowing a field to buy bits for another one is then an inspector edit
+// rather than a shader edit.
+//
+// A field with a bit count of 0 is absent and decodes to 0 / false, so a setup
+// that does not want holes simply does not allocate the bit.
+//
+// INCLUDES NOTHING, ON PURPOSE
+// ---------------------------------------------------------------------------
+// Godot's shader preprocessor does not guard against including a file twice,
+// and this is meant to be used alongside Terrain.gdshaderinc, which already
+// pulls in Math, Transform and Quaternion. So the two small helpers it needs
+// ( a 2D rotation and a hash ) are defined here under the ControlMap_ prefix
+// rather than taken from Transform/Noise.
+
+const float ControlMap_TAU = 6.283185307179586;
+
+
+/* THE SETUP
+ -------------------------------------------------------------------------
+ Everything the decoder needs that is not a sampler. Samplers are passed
+ separately because Godot's shading language will not accept them as struct
+ members.
+
+ A consumer fills this once per fragment from its own uniforms - see
+ _research/control-map/ControlMapPreview.gdshader - which keeps this file free
+ of a uniform block of its own and therefore mergeable into a terrain material
+ that already has one.
+*/
+struct ControlMapSetup
+{
+ // The map's placement in the world, same convention as HeightMapGenerator:
+ // centerXZ is the middle of the map, sizeXZ is how many meters it covers.
+ vec2 centerXZ;
+ vec2 sizeXZ;
+
+ // Bit layout. x = offset of the field's lowest bit, y = how many bits.
+ ivec2 baseField;
+ ivec2 overlayField;
+ ivec2 blendField;
+ ivec2 angleField;
+ ivec2 scaleField;
+ ivec2 holeField;
+ ivec2 navigableField;
+
+ // Value mapping for the scale field: multiplier = scaleMin + raw * scaleStep.
+ // Explicit rather than Terrain3D's modulo formula, because with a variable
+ // bit count there is no fixed table to index.
+ float scaleMin;
+ float scaleStep;
+
+ // How sharply the 4 taps and the base/overlay pair resolve against each
+ // other, using the albedo's alpha as a height map. 1 is a plain weighted
+ // average; higher lets the taller texture's detail win, so gravel pokes
+ // through sand instead of crossfading into it.
+ float blendSharpness;
+
+ // Off reads one texel per fragment instead of four. Four times cheaper and
+ // shows the control map's texel grid as hard edges - useful for seeing what
+ // is actually painted.
+ bool bilinear;
+
+ // Whether the per-texture parameter strip is assigned. A 1x1 strip is a
+ // legitimate one-texture set, so its size cannot be the test.
+ bool hasTextureParams;
+
+ // Used for every texture when hasTextureParams is false:
+ // ( meters per tile, detile amount, normal strength, roughness scale ).
+ vec4 defaultParams;
+};
+
+/** The four control texels around a fragment, decoded by the functions below.
+
+ baseTexel is the lower-left of the 2x2 neighbourhood; the four taps are at
+ ( 0, 0 ), ( 1, 0 ), ( 0, 1 ) and ( 1, 1 ) from it, which is the order the
+ weights are in. In nearest mode baseTexel is the one texel the fragment sits
+ in and only tap 0 has any weight.
+*/
+struct ControlMapTaps
+{
+ uvec4 controls;
+ vec4 weights;
+
+ ivec2 baseTexel;
+ ivec2 resolution;
+};
+
+
+/* PRIVATE HELPERS */
+
+vec2 ControlMap_rotate( vec2 v, float angle )
+{
+ float s = sin( angle );
+ float c = cos( angle );
+
+ return vec2( c * v.x - s * v.y, s * v.x + c * v.y );
+}
+
+float ControlMap_random( vec2 p )
+{
+ return fract( sin( dot( p, vec2( 12.9898, 78.233 ) ) ) * 43758.5453 );
+}
+
+
+/* COORDINATES
+ -------------------------------------------------------------------------
+ Same mapping as addTerrainOffset in Terrain.gdshaderinc, so a control map and
+ a height map authored over the same rect line up texel for texel.
+*/
+
+/** 0..1 across the map. */
+vec2 ControlMap_uv( ControlMapSetup setup, vec3 worldPosition )
+{
+ vec2 mapOffset = setup.centerXZ - setup.sizeXZ / 2.0;
+
+ return ( worldPosition.xz - mapOffset ) / setup.sizeXZ;
+}
+
+/** World XZ of a texel's center - the pivot a texel's uv rotation turns
+ around.
+*/
+vec2 ControlMap_texelCenterXZ( ControlMapSetup setup, ivec2 resolution, ivec2 texel )
+{
+ vec2 mapOffset = setup.centerXZ - setup.sizeXZ / 2.0;
+ vec2 texelSize = setup.sizeXZ / vec2( max( resolution, ivec2( 1 ) ) );
+
+ return mapOffset + ( vec2( texel ) + 0.5 ) * texelSize;
+}
+
+
+/* DECODING
+ -------------------------------------------------------------------------
+ One extraction function driven by the field descriptor, and a decoder per
+ field on top of it. Nothing here knows an offset or a width.
+*/
+
+/** The raw integer in a field. 0 for an absent field. */
+int ControlMap_extract( uint control, ivec2 field )
+{
+ if ( field.y <= 0 )
+ {
+ return 0;
+ }
+
+ // 1u << 32u is undefined, and a 32-bit field is the whole word anyway.
+ if ( field.y >= 32 )
+ {
+ return int( control );
+ }
+
+ uint mask = ( 1u << uint( field.y ) ) - 1u;
+
+ return int( ( control >> uint( field.x ) ) & mask );
+}
+
+/** A field as 0..1, with the field's maximum mapping to exactly 1. For a
+ quantity whose endpoints both matter, like the base/overlay blend.
+*/
+float ControlMap_extractUnit( uint control, ivec2 field )
+{
+ if ( field.y <= 0 )
+ {
+ return 0.0;
+ }
+
+ float maxValue = float( ( 1u << uint( min( field.y, 31 ) ) ) - 1u );
+
+ return float( ControlMap_extract( control, field ) ) / maxValue;
+}
+
+/** A field as 0..1 with the maximum mapping just short of 1, for a quantity
+ that wraps. An angle divided by its maximum would make the last step equal
+ to the first and waste it.
+*/
+float ControlMap_extractCyclic( uint control, ivec2 field )
+{
+ if ( field.y <= 0 )
+ {
+ return 0.0;
+ }
+
+ float steps = float( 1u << uint( min( field.y, 31 ) ) );
+
+ return float( ControlMap_extract( control, field ) ) / steps;
+}
+
+int ControlMap_baseIndex( uint control, ControlMapSetup setup )
+{
+ return ControlMap_extract( control, setup.baseField );
+}
+
+int ControlMap_overlayIndex( uint control, ControlMapSetup setup )
+{
+ return ControlMap_extract( control, setup.overlayField );
+}
+
+/** 0 is all base, 1 is all overlay. */
+float ControlMap_blend( uint control, ControlMapSetup setup )
+{
+ return ControlMap_extractUnit( control, setup.blendField );
+}
+
+/** The texel's uv rotation in radians. */
+float ControlMap_angle( uint control, ControlMapSetup setup )
+{
+ return ControlMap_extractCyclic( control, setup.angleField ) * ControlMap_TAU;
+}
+
+/** The texel's uv scale multiplier. */
+float ControlMap_scale( uint control, ControlMapSetup setup )
+{
+ if ( setup.scaleField.y <= 0 )
+ {
+ return 1.0;
+ }
+
+ float scale = setup.scaleMin + float( ControlMap_extract( control, setup.scaleField ) ) * setup.scaleStep;
+
+ return abs( scale ) < 0.0001 ? 1.0 : scale;
+}
+
+bool ControlMap_hole( uint control, ControlMapSetup setup )
+{
+ return ControlMap_extract( control, setup.holeField ) != 0;
+}
+
+bool ControlMap_navigable( uint control, ControlMapSetup setup )
+{
+ return ControlMap_extract( control, setup.navigableField ) != 0;
+}
+
+
+/* READING
+ -------------------------------------------------------------------------
+ texelFetch only. A filtered read of this map would average bit patterns,
+ which is meaningless - a base index of 3 and one of 4 average to 3.5, and
+ half of texture 3 is not half of texture 4.
+*/
+
+uint ControlMap_read( sampler2D controlMap, ivec2 texel )
+{
+ ivec2 maxTexel = textureSize( controlMap, 0 ) - 1;
+
+ return floatBitsToUint( texelFetch( controlMap, clamp( texel, ivec2( 0 ), maxTexel ), 0 ).r );
+}
+
+/** The one texel a world position sits in. The flags are read through this
+ rather than off the taps, because a bit cannot be blended: a fragment is
+ either in a hole or it is not.
+*/
+uint ControlMap_readAt( sampler2D controlMap, ControlMapSetup setup, vec3 worldPosition )
+{
+ ivec2 resolution = textureSize( controlMap, 0 );
+ vec2 uv = ControlMap_uv( setup, worldPosition );
+
+ return ControlMap_read( controlMap, ivec2( floor( uv * vec2( resolution ) ) ) );
+}
+
+/** The 2x2 neighbourhood and its bilinear weights.
+
+ The 0.5 bias is what makes the weights line up with what a hardware bilinear
+ filter would have done: a texture() call weights by the distance to the
+ surrounding texel centers, not to their corners.
+*/
+ControlMapTaps ControlMap_getTaps( sampler2D controlMap, ControlMapSetup setup, vec3 worldPosition )
+{
+ ControlMapTaps taps;
+
+ taps.resolution = textureSize( controlMap, 0 );
+
+ vec2 uv = ControlMap_uv( setup, worldPosition );
+ vec2 texel = uv * vec2( taps.resolution );
+
+ if ( ! setup.bilinear )
+ {
+ taps.baseTexel = ivec2( floor( texel ) );
+
+ uint control = ControlMap_read( controlMap, taps.baseTexel );
+
+ taps.controls = uvec4( control );
+ taps.weights = vec4( 1.0, 0.0, 0.0, 0.0 );
+
+ return taps;
+ }
+
+ vec2 biased = texel - 0.5;
+ vec2 corner = floor( biased );
+ vec2 f = biased - corner;
+
+ taps.baseTexel = ivec2( corner );
+
+ taps.controls = uvec4(
+ ControlMap_read( controlMap, taps.baseTexel ),
+ ControlMap_read( controlMap, taps.baseTexel + ivec2( 1, 0 ) ),
+ ControlMap_read( controlMap, taps.baseTexel + ivec2( 0, 1 ) ),
+ ControlMap_read( controlMap, taps.baseTexel + ivec2( 1, 1 ) )
+ );
+
+ taps.weights = vec4(
+ ( 1.0 - f.x ) * ( 1.0 - f.y ),
+ f.x * ( 1.0 - f.y ),
+ ( 1.0 - f.x ) * f.y,
+ f.x * f.y
+ );
+
+ return taps;
+}
+
+
+/* THE TEXTURE SET
+ -------------------------------------------------------------------------
+ Two sampler2DArrays - albedo with its height in alpha, and normal with its
+ roughness in alpha - plus a 1-row parameter strip holding the per-texture
+ settings that cannot be per texel.
+
+ The strip is a texture rather than a uniform array because a uniform array
+ would have to be declared with a fixed length, which is exactly the kind of
+ hard-coded ceiling the variable bit layout exists to avoid. One texelFetch
+ per tap costs nothing next to the albedo and normal samples it feeds.
+*/
+
+/** ( meters per tile, detile amount, normal strength, roughness scale ) */
+vec4 ControlMap_textureParams( sampler2D textureParams, ControlMapSetup setup, int id )
+{
+ if ( ! setup.hasTextureParams )
+ {
+ return setup.defaultParams;
+ }
+
+ int maxIndex = textureSize( textureParams, 0 ).x - 1;
+
+ return texelFetch( textureParams, ivec2( clamp( id, 0, maxIndex ), 0 ), 0 );
+}
+
+/** The uv one texture is sampled at, and the two gradients that go with it.
+
+ The rotation pivots on the texel's own center, so a texel's pattern turns in
+ place instead of swinging around the map's origin - at the far edge of a
+ 1000 m map, rotating around the origin would translate the pattern by
+ hundreds of meters and no two neighbouring texels would line up at all.
+
+ The gradients are analytic rather than left to the hardware. uv is an affine
+ function of worldPosition.xz here ( a scale and a rotation ), so its
+ jacobian is exactly that same scale and rotation applied to the world-space
+ derivative - no approximation. The hardware's own implicit derivative cannot
+ be used, because the uv jumps between texels wherever the scale or angle
+ changes, and the mip selected from that jump would be the coarsest one,
+ drawing a blurred line along every texel border.
+*/
+void ControlMap_textureUV(
+ ControlMapSetup setup,
+ uint control,
+ vec4 params,
+ vec3 worldPosition,
+ vec2 worldDDX,
+ vec2 worldDDY,
+ vec2 texelCenterXZ,
+ out vec2 uv,
+ out vec2 uvDDX,
+ out vec2 uvDDY
+)
+{
+ float metersPerTile = max( params.x, 0.0001 );
+
+ // Per-texture random rotation on top of the painted one. Without it a texture
+ // with any visible structure reads as a grid at tile scale; this is what
+ // Terrain3D calls detiling.
+ float detile = params.y * ControlMap_random( texelCenterXZ ) * ControlMap_TAU;
+ float angle = ControlMap_angle( control, setup ) + detile;
+
+ float k = ControlMap_scale( control, setup ) / metersPerTile;
+
+ uv = ControlMap_rotate( ( worldPosition.xz - texelCenterXZ ) * k, angle )
+ + texelCenterXZ / metersPerTile;
+
+ uvDDX = ControlMap_rotate( worldDDX * k, angle );
+ uvDDY = ControlMap_rotate( worldDDY * k, angle );
+}
+
+
+/* ACCUMULATION
+ -------------------------------------------------------------------------
+ Up to 8 albedo + 8 normal samples per fragment: 4 control taps, each with a
+ base and an overlay. That is the real cost of this technique and the reason
+ setup.bilinear exists as a switch.
+
+ Weighting is height-aware. A plain weighted average of two albedos is a
+ crossfade, which looks like a dissolve rather than like one ground meeting
+ another. Raising each weight by the albedo's alpha ( its height ) to a
+ sharpness power makes the taller texture win where it is tall, so the
+ transition follows the textures' own detail. This is the same family as
+ Terrain3D's exp2/log2 weighting, written out directly.
+*/
+void ControlMap_accumulate(
+ sampler2DArray albedoArray,
+ sampler2DArray normalArray,
+ sampler2D textureParams,
+ ControlMapSetup setup,
+ uint control,
+ int id,
+ float weight,
+ vec3 worldPosition,
+ vec2 worldDDX,
+ vec2 worldDDY,
+ vec2 texelCenterXZ,
+ inout vec3 albedo,
+ inout vec3 normal,
+ inout float roughness,
+ inout float total
+)
+{
+ if ( weight <= 0.0001 )
+ {
+ return;
+ }
+
+ int maxId = textureSize( albedoArray, 0 ).z - 1;
+ int safeId = clamp( id, 0, max( maxId, 0 ) );
+
+ vec4 params = ControlMap_textureParams( textureParams, setup, safeId );
+
+ vec2 uv;
+ vec2 uvDDX;
+ vec2 uvDDY;
+
+ ControlMap_textureUV(
+ setup, control, params, worldPosition, worldDDX, worldDDY, texelCenterXZ,
+ uv, uvDDX, uvDDY
+ );
+
+ vec3 arrayUV = vec3( uv, float( safeId ) );
+
+ vec4 albedoHeight = textureGrad( albedoArray, arrayUV, uvDDX, uvDDY );
+ vec4 normalRough = textureGrad( normalArray, arrayUV, uvDDX, uvDDY );
+
+ float height = albedoHeight.a;
+ float w = pow( max( weight * ( 0.001 + height ), 0.0 ), setup.blendSharpness );
+
+ // The normal map was authored in the texture's own space, so the uv rotation
+ // has to be undone on it or a rotated texel would light as though its bumps
+ // had turned with the pattern - which they did, but the light did not.
+ vec3 tangentNormal = normalRough.rgb * 2.0 - 1.0;
+ tangentNormal.xy = ControlMap_rotate( tangentNormal.xy, -ControlMap_angle( control, setup ) );
+ tangentNormal.xy *= params.z;
+
+ albedo += albedoHeight.rgb * w;
+ normal += normalize( tangentNormal ) * w;
+ roughness += normalRough.a * params.w * w;
+ total += w;
+}
+
+/** One control tap's contribution: its base texture, and its overlay when the
+ blend calls for one.
+*/
+void ControlMap_accumulateTap(
+ sampler2D controlMap,
+ sampler2DArray albedoArray,
+ sampler2DArray normalArray,
+ sampler2D textureParams,
+ ControlMapSetup setup,
+ ControlMapTaps taps,
+ uint control,
+ float weight,
+ ivec2 texelOffset,
+ vec3 worldPosition,
+ vec2 worldDDX,
+ vec2 worldDDY,
+ inout vec3 albedo,
+ inout vec3 normal,
+ inout float roughness,
+ inout float total
+)
+{
+ if ( weight <= 0.0001 )
+ {
+ return;
+ }
+
+ vec2 texelCenterXZ = ControlMap_texelCenterXZ(
+ setup, taps.resolution, taps.baseTexel + texelOffset
+ );
+
+ int baseId = ControlMap_baseIndex( control, setup );
+ int overlayId = ControlMap_overlayIndex( control, setup );
+ float blend = ControlMap_blend( control, setup );
+
+ ControlMap_accumulate(
+ albedoArray, normalArray, textureParams, setup, control, baseId,
+ weight * ( 1.0 - blend ), worldPosition, worldDDX, worldDDY, texelCenterXZ,
+ albedo, normal, roughness, total
+ );
+
+ // Skipped when the two indices are the same, which is the unpainted case -
+ // otherwise every texel would pay for a second identical sample.
+ if ( blend > 0.0001 && overlayId != baseId )
+ {
+ ControlMap_accumulate(
+ albedoArray, normalArray, textureParams, setup, control, overlayId,
+ weight * blend, worldPosition, worldDDX, worldDDY, texelCenterXZ,
+ albedo, normal, roughness, total
+ );
+ }
+}
+
+
+/* THE ENTRY POINT */
+
+/** Shades one fragment from the control map.
+
+ worldDDX / worldDDY are the world-space XZ derivatives of the position -
+ dFdxCoarse( worldPosition ).xz and its Y counterpart. They are parameters
+ rather than taken here because a derivative is only valid in the fragment
+ stage and only on a value that varies linearly across the triangle, so the
+ consumer has to be the one to take them.
+
+ normal comes back in the texture's tangent space, which for a heightfield
+ over world XZ is the same thing as world space with Y up - so it can be
+ combined with the terrain's own normal by the caller.
+
+ hole and navigable are read from the single texel the fragment sits in, not
+ blended: they are bits, and a half-set bit is not a thing.
+*/
+void ControlMap_shade(
+ sampler2D controlMap,
+ sampler2DArray albedoArray,
+ sampler2DArray normalArray,
+ sampler2D textureParams,
+ ControlMapSetup setup,
+ vec3 worldPosition,
+ vec2 worldDDX,
+ vec2 worldDDY,
+ out vec3 albedo,
+ out vec3 normal,
+ out float roughness,
+ out bool hole,
+ out bool navigable
+)
+{
+ albedo = vec3( 0.5 );
+ normal = vec3( 0.0, 0.0, 1.0 );
+ roughness = 1.0;
+
+ uint flagControl = ControlMap_readAt( controlMap, setup, worldPosition );
+
+ hole = ControlMap_hole( flagControl, setup );
+ navigable = ControlMap_navigable( flagControl, setup );
+
+ ControlMapTaps taps = ControlMap_getTaps( controlMap, setup, worldPosition );
+
+ vec3 albedoSum = vec3( 0.0 );
+ vec3 normalSum = vec3( 0.0 );
+ float roughnessSum = 0.0;
+ float total = 0.0;
+
+ // Unrolled rather than looped: indexing a uvec4 with a loop variable is
+ // dynamic vector indexing, which not every target compiles, and the texel
+ // offsets want to be literals anyway.
+ ControlMap_accumulateTap(
+ controlMap, albedoArray, normalArray, textureParams, setup, taps,
+ taps.controls.x, taps.weights.x, ivec2( 0, 0 ),
+ worldPosition, worldDDX, worldDDY, albedoSum, normalSum, roughnessSum, total
+ );
+
+ ControlMap_accumulateTap(
+ controlMap, albedoArray, normalArray, textureParams, setup, taps,
+ taps.controls.y, taps.weights.y, ivec2( 1, 0 ),
+ worldPosition, worldDDX, worldDDY, albedoSum, normalSum, roughnessSum, total
+ );
+
+ ControlMap_accumulateTap(
+ controlMap, albedoArray, normalArray, textureParams, setup, taps,
+ taps.controls.z, taps.weights.z, ivec2( 0, 1 ),
+ worldPosition, worldDDX, worldDDY, albedoSum, normalSum, roughnessSum, total
+ );
+
+ ControlMap_accumulateTap(
+ controlMap, albedoArray, normalArray, textureParams, setup, taps,
+ taps.controls.w, taps.weights.w, ivec2( 1, 1 ),
+ worldPosition, worldDDX, worldDDY, albedoSum, normalSum, roughnessSum, total
+ );
+
+ if ( total <= 0.0 )
+ {
+ return;
+ }
+
+ albedo = albedoSum / total;
+ roughness = clamp( roughnessSum / total, 0.0, 1.0 );
+
+ // Normalized rather than divided: the sum of weighted unit vectors points the
+ // right way regardless of the total, and a zero-length sum ( two opposed
+ // normals ) falls back to flat instead of to a NaN.
+ normal = length( normalSum ) < 0.0001 ? vec3( 0.0, 0.0, 1.0 ) : normalize( normalSum );
+}
diff --git a/Runtime/Shading/Library/ControlMap.gdshaderinc.uid b/Runtime/Shading/Library/ControlMap.gdshaderinc.uid
new file mode 100644
index 0000000..137a549
--- /dev/null
+++ b/Runtime/Shading/Library/ControlMap.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://bwdxgv2n1nyrl
diff --git a/Runtime/Shading/Library/Transform.gdshaderinc b/Runtime/Shading/Library/Transform.gdshaderinc
index 866e7cd..20f2c18 100644
--- a/Runtime/Shading/Library/Transform.gdshaderinc
+++ b/Runtime/Shading/Library/Transform.gdshaderinc
@@ -562,6 +562,42 @@ vec3 rotateEulerDegrees( vec3 position, vec3 euler )
return rotateEuler( position, euler * PI/180.0 );
}
+vec3 normalMapToEuler( sampler2D normal_map, vec2 uv)
+{
+ vec3 normal = texture( normal_map, uv ).rgb * 2.0 - 1.0;
+ normal = normalize( normal );
+
+ vec3 up = vec3( 0.0, 1.0, 0.0 );
+
+ vec3 axis = cross( up, normal );
+ float angle = acos( clamp( dot( up, normal ), -1.0, 1.0 ) );
+
+ if ( length(axis) < 0.00001 )
+ {
+ return normal.y > 0.0 ? vec3(0.0) : vec3( PI, 0.0, 0.0);
+ }
+
+ axis = normalize( axis );
+
+ return axis * angle;
+}
+
+float getNormalMapAlignment( sampler2D normal_map, vec2 uv, vec3 normalDirection, float tresholdDegrees, float fadeDegrees )
+{
+ vec3 normal = texture( normal_map, uv ).rgb * 2.0 - 1.0;
+ normal = normalize( normal );
+
+ float value = dot( normalDirection, normal );
+
+ float angle = degrees(acos(clamp(value, -1.0, 1.0)));
+
+ return 1.0 - smoothstep(
+ tresholdDegrees - fadeDegrees,
+ tresholdDegrees,
+ angle
+ );
+}
+
mat3 quaternionToRotationMatrix( vec4 q )
{
float x = q.x;
diff --git a/Runtime/Shading/Properties/Sampler2DArrayPropertyName.cs b/Runtime/Shading/Properties/Sampler2DArrayPropertyName.cs
new file mode 100644
index 0000000..64b45da
--- /dev/null
+++ b/Runtime/Shading/Properties/Sampler2DArrayPropertyName.cs
@@ -0,0 +1,41 @@
+using Godot;
+using System.Reflection;
+using System.Collections.Generic;
+
+using Rokojori.Extensions;
+namespace Rokojori
+{
+ /** The sampler2DArray counterpart to Sampler2DPropertyName.
+
+ Needed by the control-map terrain, which addresses its ground textures by
+ index rather than by slot - see Shading/Library/ControlMap.gdshaderinc.
+ */
+ [Tool]
+ [GlobalClass]
+ public partial class Sampler2DArrayPropertyName : ShaderPropertyName
+ {
+ public override RenderingServer.GlobalShaderParameterType GetParameterType()
+ {
+ // Sampler2Darray, not Sampler2DArray - the binding generator reads
+ // SAMPLER2DARRAY as a single token, so only the first letter is capital.
+ return RenderingServer.GlobalShaderParameterType.Sampler2Darray;
+ }
+
+ public void Set( Material material, Texture2DArray value )
+ {
+ _Set( material, value );
+ }
+
+ public Texture2DArray Get( Material material )
+ {
+ return _Get( material, null );
+ }
+
+ public static Sampler2DArrayPropertyName Create( string name )
+ {
+ var t = new Sampler2DArrayPropertyName();
+ t.propertyName = name;
+ return t;
+ }
+ }
+}
diff --git a/Runtime/Shading/Properties/Sampler2DArrayPropertyName.cs.uid b/Runtime/Shading/Properties/Sampler2DArrayPropertyName.cs.uid
new file mode 100644
index 0000000..84193c9
--- /dev/null
+++ b/Runtime/Shading/Properties/Sampler2DArrayPropertyName.cs.uid
@@ -0,0 +1 @@
+uid://bv3fw1a4ato4a
diff --git a/Runtime/VFX/FlareVFX/FlareVFXPresets/Red Orange Classic.tres b/Runtime/VFX/FlareVFX/FlareVFXPresets/Red Orange Classic.tres
index f8bddde..ea70aaa 100644
--- a/Runtime/VFX/FlareVFX/FlareVFXPresets/Red Orange Classic.tres
+++ b/Runtime/VFX/FlareVFX/FlareVFXPresets/Red Orange Classic.tres
@@ -56,15 +56,17 @@ curve = SubResource("Curve_8vqmt")
[sub_resource type="Resource" id="Resource_vwre4"]
script = ExtResource("4_1hsao")
layerName = "Backblend"
-opacity = 0.4631
+opacity = 0.2658
flareType = SubResource("Resource_1hsao")
opacityCurvePerLayer = SubResource("CurveTexture_lrl2b")
tint = Color(0.27730417, 0.27730417, 0.27730417, 1)
temparatureShift = -70.7505
saturationShift = 15.3846
lighntessShift = -10.0
-size = 50.0
-sizeScale = 0.8392
+size = 100.0
+worldSizeScale = 1.6551
+screenSizeScale = 0.21
+worldSize_vs_screenSize = 0.0684
blendMode = SubResource("Resource_jqj56")
renderPriority = -1
metadata/_custom_type_script = "uid://u3g0mlwlpd6s"
@@ -152,7 +154,7 @@ curve = SubResource("Curve_nt8a7")
[sub_resource type="Resource" id="Resource_ade2u"]
script = ExtResource("4_1hsao")
layerName = "Big Hexagons Ghosts"
-opacity = 0.0191
+opacity = 0.3929
numLayers = 8
flareType = SubResource("Resource_adfmi")
minHueRandom = -62.945
@@ -160,7 +162,7 @@ maxHueRandom = 74.9895
opacityCurvePerLayer = SubResource("CurveTexture_0y4dn")
saturationShift = 100.0
size = 8.986
-sizeScale = 0.3341
+sizeScale = 0.5518
worldSize_vs_screenSize = 0.5896
randomScaleMin = 0.9167
randomScaleMax = 1.6866
@@ -182,7 +184,7 @@ shapeType = 0
shapeScale = 1.0
shapeOpacityOuterBorder = 0.0692
shapeOpacityBorderOffset = -0.2994
-shapeOpacityBorderSpread = -0.0297
+shapeOpacityBorderSpread = 0.106
shapeOpacityDistribution = 5.2471
shapeColorMappingInnerBorder = -0.1913
innerTemparatureOffset = 30.4291
@@ -204,9 +206,8 @@ curve = SubResource("Curve_ymqbv")
[sub_resource type="Resource" id="Resource_jvxnt"]
script = ExtResource("4_1hsao")
-layerName = "Small Circles Ghosts"
-hide = true
-opacity = 0.2434
+layerName = "Ghosts"
+opacity = 0.01
numLayers = 50
flareType = SubResource("Resource_0y4dn")
minHueRandom = -45.0
@@ -215,9 +216,8 @@ opacityCurvePerLayer = SubResource("CurveTexture_7owh2")
tint = Color(0.54901963, 0.54901963, 0.54901963, 1)
saturationShift = -10.5882
lighntessShift = 12.3529
-size = 1.3905
-sizeScale = 0.4618
-worldSize_vs_screenSize = 0.5896
+size = 2.5
+worldSize_vs_screenSize = 0.5
randomScaleMin = 0.019
randomScaleMax = 1.572
screenOffsetStaticScale = 0.0
@@ -258,15 +258,15 @@ metadata/_custom_type_script = "uid://dk8t1ljh3jroo"
[sub_resource type="Resource" id="Resource_027rg"]
script = ExtResource("4_1hsao")
layerName = "Streaks"
-opacity = 0.1873
+opacity = 0.1044
numLayers = 3
flareType = SubResource("Resource_k3n7u")
overwriteColor = Color(2.385, 0.24872369, 0, 1)
opacityCurvePerLayer = SubResource("CurveTexture_ch4em")
temparatureShift = 34.56
-size = 100.0
-sizeScale = 0.0912
+size = 50.0
sizeYScale = 0.0365
+worldSize_vs_screenSize = 0.0
randomScaleMin = 0.424
randomScaleMax = 0.664
screenOffsetStaticScale = 0.0
@@ -334,7 +334,7 @@ curve = SubResource("Curve_008mr")
[sub_resource type="Resource" id="Resource_nt8a7"]
script = ExtResource("4_1hsao")
layerName = "Blend God Rays"
-opacity = 0.0141
+opacity = 0.0
flareType = SubResource("Resource_ui5ll")
overwriteColor = Color(1.8247963, 1.8247963, 1.8247963, 1)
overwriteAmount = 0.6638
@@ -392,11 +392,13 @@ curve = SubResource("Curve_4qtqc")
[sub_resource type="Resource" id="Resource_6kf7p"]
script = ExtResource("4_1hsao")
layerName = "Star"
-opacity = 0.1
+opacity = 0.3431
flareType = SubResource("Resource_yynim")
opacityCurvePerLayer = SubResource("CurveTexture_tn5uv")
-size = 20.0
+hueShift = 34.6823
+size = 50.0
sizeScale = 0.152
+worldSize_vs_screenSize = 0.1
rotationOverX = -20.0
chromaticAberation = SubResource("Resource_6y0r4")
metadata/_custom_type_script = "uid://u3g0mlwlpd6s"
@@ -430,15 +432,16 @@ curve = SubResource("Curve_fy8er")
[sub_resource type="Resource" id="Resource_8vqmt"]
script = ExtResource("4_1hsao")
layerName = "Big Glow"
-opacity = 0.02
+opacity = 0.01
flareType = SubResource("Resource_gwku0")
opacityCurvePerLayer = SubResource("CurveTexture_ls5mq")
temparatureShift = 8.4706
saturationShift = -68.8168
size = 1000.0
-sizeScale = 0.089
-sizeYScale = 0.1464
+sizeScale = 0.3241
+sizeYScale = 0.0269
screenSizeScale = 0.616
+worldSize_vs_screenSize = 0.25
fading = SubResource("Resource_4qtqc")
metadata/_custom_type_script = "uid://u3g0mlwlpd6s"
@@ -490,14 +493,15 @@ curve = SubResource("Curve_a5aan")
[sub_resource type="Resource" id="Resource_rjwbk"]
script = ExtResource("4_1hsao")
layerName = "Rainbow Glow"
-opacity = 0.0625
+opacity = 0.02
numLayers = 2
flareType = SubResource("Resource_msay1")
overwriteColor = Color(1.353256, 1.353256, 1.353256, 1)
overwriteAmount = 0.8494
opacityCurvePerLayer = SubResource("CurveTexture_6jyri")
-size = 50.0
+size = 100.0
sizeScale = 0.7135
+worldSize_vs_screenSize = 0.25
randomScaleMin = 0.3021
randomScaleMax = 2.0
overwriteExtension = true
@@ -586,7 +590,7 @@ curve = SubResource("Curve_6kf7p")
[sub_resource type="Resource" id="Resource_1jlm7"]
script = ExtResource("4_1hsao")
layerName = "Partial Rainbow Rings 1"
-opacity = 0.0223
+opacity = 0.005
numLayers = 3
flareType = SubResource("Resource_eguri")
overwriteColor = Color(3.4864159, 3.4864159, 3.4864159, 1)
@@ -594,7 +598,7 @@ overwriteAmount = 0.8476
opacityCurvePerLayer = SubResource("CurveTexture_6xf1f")
size = 20.0
sizeScale = 0.5576
-worldSize_vs_screenSize = 0.5
+worldSize_vs_screenSize = 0.25
randomScaleMin = 0.7532
randomScaleMax = 1.5185
overwriteExtension = true
@@ -633,7 +637,7 @@ curve = SubResource("Curve_lrl2b")
[sub_resource type="Resource" id="Resource_0xhst"]
script = ExtResource("4_1hsao")
layerName = "Partial Rainbow Rings 2"
-opacity = 0.0313
+opacity = 0.005
numLayers = 2
flareType = SubResource("Resource_eguri")
overwriteColor = Color(3.4864159, 3.4864159, 3.4864159, 1)
@@ -641,7 +645,7 @@ overwriteAmount = 0.869
opacityCurvePerLayer = SubResource("CurveTexture_e2s3r")
size = 20.0
sizeScale = 0.5837
-worldSize_vs_screenSize = 0.5
+worldSize_vs_screenSize = 0.25
randomScaleMin = 0.7532
randomScaleMax = 1.5185
overwriteExtension = true
@@ -658,7 +662,7 @@ metadata/_custom_type_script = "uid://u3g0mlwlpd6s"
[sub_resource type="Resource" id="Resource_5ghy1"]
script = ExtResource("4_1hsao")
layerName = "Partial Rainbow Rings 3"
-opacity = 0.0268
+opacity = 0.005
numLayers = 3
flareType = SubResource("Resource_eguri")
overwriteColor = Color(3.4864159, 3.4864159, 3.4864159, 1)
@@ -666,7 +670,7 @@ overwriteAmount = 0.9726
opacityCurvePerLayer = SubResource("CurveTexture_6xf1f")
size = 20.0
sizeScale = 0.526
-worldSize_vs_screenSize = 0.5
+worldSize_vs_screenSize = 0.25
randomScaleMin = 0.7532
randomScaleMax = 1.5185
overwriteExtension = true
@@ -743,7 +747,7 @@ curve = SubResource("Curve_6xf1f")
[sub_resource type="Resource" id="Resource_skhw3"]
script = ExtResource("4_1hsao")
layerName = "Rainbow God Rays"
-opacity = 0.0156
+opacity = 0.005
flareType = SubResource("Resource_sxovr")
overwriteColor = Color(2.8845115, 2.8845115, 2.8845115, 1)
overwriteAmount = 0.5914