rokojori_action_library/Runtime/Shading/Library/ControlMap.gdshaderinc

632 lines
21 KiB
Plaintext

// #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 );
}