Terrain Editor
This commit is contained in:
parent
ae581c9a77
commit
125a853e6d
|
|
@ -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 <marked>is</marked> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cm2junhdqbjuo
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://da0bgh7152kh6
|
||||
|
|
@ -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 <marked>only</marked> 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.
|
||||
|
||||
<marked>Only bit sizes are authored.</marked> 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 <marked>derived</marked> 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 <marked>both</marked>
|
||||
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
|
||||
<marked>count</marked> 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://qj55rfbltk7o
|
||||
|
|
@ -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" );
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b8tcfqwwnqfhd
|
||||
|
|
@ -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.
|
||||
|
||||
<marked>This is the authoring half of a painting system that does not exist
|
||||
yet.</marked> 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, <marked>in index order</marked> - 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<Resource> _hooked = new List<Resource>();
|
||||
readonly List<Resource> _wanted = new List<Resource>();
|
||||
|
||||
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<Image>();
|
||||
var normalImages = new Godot.Collections.Array<Image>();
|
||||
|
||||
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 <marked>own</marked> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://05mpp6hmlyo3
|
||||
|
|
@ -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 <marked>is</marked> 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.
|
||||
|
||||
<marked>Channel packing.</marked> 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://56hhxxntlat1
|
||||
|
|
@ -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<HeightMapDrawLayer> 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<Texture2D> _blitSources
|
||||
= new Godot.Collections.Array<Texture2D>(){ null, null };
|
||||
= new Godot.Collections.Array<Texture2D>(){ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@ namespace Rokojori
|
|||
*/
|
||||
public abstract void RenderToHeightMap( HeightMapGenerator generator );
|
||||
|
||||
/** Called on every visible layer <marked>before</marked> 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
|
||||
------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -168,10 +189,10 @@ namespace Rokojori
|
|||
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 <marked>real geometry</marked> 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 <marked>highest</marked> 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.
|
||||
|
||||
<marked>Width</marked> 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.
|
||||
|
||||
<marked>Height</marked> 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 <marked>spline's own interpolated Y</marked>, 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.
|
||||
|
||||
<marked>Textures</marked> 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 <marked>coverage</marked>, 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 <marked>whole map</marked>
|
||||
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<Vector3> lines, List<Vector3> handles, List<int> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dgpxbuhsqfn3u
|
||||
|
|
@ -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 <marked>Max</marked> 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.
|
||||
|
||||
<marked>One viewport, one draw, any number of layers.</marked> 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.
|
||||
|
||||
<marked>It only draws when something changed.</marked> 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 <marked>one frame behind</marked>. 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<MeshInstance3D> _instances = new List<MeshInstance3D>();
|
||||
|
||||
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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://20sndvur2mgj
|
||||
|
|
@ -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.
|
||||
|
||||
<marked>Stations are spaced by arc length</marked>, 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 <marked>atlas texel coordinates</marked>: 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 <marked>curve's</marked> 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<Vector3> _vertices = new List<Vector3>();
|
||||
readonly List<Vector2> _uvs = new List<Vector2>();
|
||||
readonly List<Vector2> _uv2s = new List<Vector2>();
|
||||
readonly List<int> _indices = new List<int>();
|
||||
|
||||
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 <marked>along</marked> 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 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dcyraxachyqi8
|
||||
|
|
@ -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 <marked>depth test</marked>, 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 <marked>whole</marked>
|
||||
// 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 );
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cxx4pwxqrdrxe
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using Godot;
|
||||
using System;
|
||||
|
||||
using Rokojori.Extensions;
|
||||
namespace Rokojori
|
||||
{
|
||||
public class HeightMapRibbonShader
|
||||
{
|
||||
public static readonly CachedResource<Shader> shader = new CachedResource<Shader>(
|
||||
"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<Vector4> region;
|
||||
|
||||
public HeightMapRibbonMaterial()
|
||||
{
|
||||
Shader = HeightMapRibbonShader.shader.Get();
|
||||
|
||||
region = new CustomMaterialProperty<Vector4>( this, HeightMapRibbonShader.region );
|
||||
}
|
||||
|
||||
public void SetRegion( Rect2I rect )
|
||||
{
|
||||
region.Set( new Vector4( rect.Position.X, rect.Position.Y, rect.Size.X, rect.Size.Y ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b351cuyfhytbo
|
||||
|
|
@ -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 <marked>level</marked> 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<int> imageChannel;
|
||||
public readonly CustomMaterialProperty<bool> imageTiling;
|
||||
|
||||
public readonly CustomMaterialProperty<Vector2> ribbonRegionUVMin;
|
||||
public readonly CustomMaterialProperty<Vector2> ribbonRegionUVSize;
|
||||
public readonly CustomMaterialProperty<Vector2> ribbonBufferSize;
|
||||
public readonly CustomMaterialProperty<int> ribbonSlots;
|
||||
public readonly CustomMaterialProperty<float> ribbonEdgeFalloff;
|
||||
public readonly CustomMaterialProperty<float> ribbonFadeIn;
|
||||
public readonly CustomMaterialProperty<float> ribbonFadeOut;
|
||||
public readonly CustomMaterialProperty<bool> ribbonSrgbDecode;
|
||||
|
||||
public readonly CustomMaterialProperty<int> maskChannel;
|
||||
public readonly CustomMaterialProperty<Vector2> maskCenter;
|
||||
public readonly CustomMaterialProperty<Vector2> maskAxisX;
|
||||
|
|
@ -111,6 +130,15 @@ namespace Rokojori
|
|||
imageChannel = new CustomMaterialProperty<int>( this, HeightMapShapeShader.imageChannel );
|
||||
imageTiling = new CustomMaterialProperty<bool>( this, HeightMapShapeShader.imageTiling );
|
||||
|
||||
ribbonRegionUVMin = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.ribbonRegionUVMin );
|
||||
ribbonRegionUVSize = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.ribbonRegionUVSize );
|
||||
ribbonBufferSize = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.ribbonBufferSize );
|
||||
ribbonSlots = new CustomMaterialProperty<int>( this, HeightMapShapeShader.ribbonSlots );
|
||||
ribbonEdgeFalloff = new CustomMaterialProperty<float>( this, HeightMapShapeShader.ribbonEdgeFalloff );
|
||||
ribbonFadeIn = new CustomMaterialProperty<float>( this, HeightMapShapeShader.ribbonFadeIn );
|
||||
ribbonFadeOut = new CustomMaterialProperty<float>( this, HeightMapShapeShader.ribbonFadeOut );
|
||||
ribbonSrgbDecode = new CustomMaterialProperty<bool>( this, HeightMapShapeShader.ribbonSrgbDecode );
|
||||
|
||||
maskChannel = new CustomMaterialProperty<int>( this, HeightMapShapeShader.maskChannel );
|
||||
maskCenter = new CustomMaterialProperty<Vector2>( this, HeightMapShapeShader.maskCenter );
|
||||
maskAxisX = new CustomMaterialProperty<Vector2>( 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 );
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bn4kkkileju7p
|
||||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bhykfdc60h5fk
|
||||
|
|
@ -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 <marked>once for the
|
||||
whole spline</marked> 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<T>( 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<T>( this SplinePointData[] data, string name ) where T:SplinePointData
|
||||
{
|
||||
return data.FindData<T>( 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<SplinePointFloatData>( name );
|
||||
|
||||
return entry == null ? fallback : entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://d16nlsrm24tkn
|
||||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dvo63swkcj63f
|
||||
|
|
@ -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<SplinePoint> 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<T>( string name ) where T:SplinePointData
|
||||
{
|
||||
return defaultData.FindData<T>( 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<SplinePointFloatData>( name );
|
||||
|
||||
if ( pointEntry != null )
|
||||
{
|
||||
return pointEntry.value;
|
||||
}
|
||||
|
||||
return defaultData.FindFloatData( name, fallback );
|
||||
}
|
||||
|
||||
List<SplinePoint> GetFilteredSplinePoints()
|
||||
{
|
||||
var unfilteredSplinePoints = Nodes.GetDirectChildren<SplinePoint>( this );
|
||||
|
|
|
|||
|
|
@ -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 <marked>distance</marked>
|
||||
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 <marked>under</marked>estimate, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c14ylpe2b3ech
|
||||
|
|
@ -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<T>( string name ) where T:SplinePointData
|
||||
{
|
||||
return data.FindData<T>( name );
|
||||
}
|
||||
|
||||
public bool HasData<T>( string name ) where T:SplinePointData
|
||||
{
|
||||
return data.HasData<T>( name );
|
||||
}
|
||||
|
||||
public float GetFloatData( string name, float fallback )
|
||||
{
|
||||
return data.FindFloatData( name, fallback );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 );
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <marked>not interpolable</marked>. 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 <marked>centers</marked>, 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 );
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bwdxgv2n1nyrl
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Texture2DArray>( material, null );
|
||||
}
|
||||
|
||||
public static Sampler2DArrayPropertyName Create( string name )
|
||||
{
|
||||
var t = new Sampler2DArrayPropertyName();
|
||||
t.propertyName = name;
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bv3fw1a4ato4a
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue