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