rokojori_action_library/Runtime/Procedural/ControlMaps/ControlMapFormat.cs

433 lines
16 KiB
C#
Raw Normal View History

2026-11-12 14:28:42 +00:00
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 );
}
}
}