rokojori_action_library/Runtime/Procedural/HeightMaps/Shaders/HeightMapShape.gdshader

341 lines
13 KiB
Plaintext
Raw Normal View History

// Content pass of a HeightMapGeneratorLayer's shape.
//
// Writes one shape's height contribution into the compositor's scratch buffer
// with blending disabled. The scratch buffer is then blended onto the height map
// by a plain BlitMaterial, which is where Add / Subtract / Replace actually
// happen - so this shader never reads the map and no ping-pong buffer is
// involved.
//
// Height lives in the R channel (G and B carry the same value so the map is
// readable as a greyscale preview), matching addTerrainOffset in
// Shading/Library/Terrain.gdshaderinc, which samples .r and maps 0..1 onto
// terrainMinHeight..terrainMaxHeight.
//
// Everything is evaluated in world XZ, not UV: HeightMapGenerator hands in the
// world-space rect this blit covers, so a shape that the map's edge clipped is
// still drawn at its true position and size. ( The TextureStack had a bug here
// once - clipping the rect while keeping rect-local UV silently rescaled the
// content. Deriving world space from the clipped rect cannot have that problem. )
//
// Every shape produces two values, because a falloff means two different things
// depending on the blend mode:
// amount - how much of the layer's height this texel gets, 0..1
// coverage - how much this texel is affected at all, 0..1
// Under Add/Subtract coverage scales the contribution; under Replace it is the
// alpha the Mix blend crossfades with.
shader_type texture_blit;
render_mode blend_disabled;
2026-11-12 14:28:42 +00:00
#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 );
2026-11-12 14:28:42 +00:00
// HeightMapShapeType - 0 Circle, 1 Image, 2 Ribbon.
uniform int shape_type = 0;
// HeightMapBlendMode - 0 Add, 1 Subtract, 2 Replace.
uniform int shape_blend_mode = 0;
// Normalized height, split into two uniforms so one formula covers both
// readings: the stored level is shape_value_base + shape_value_scale * amount.
//
// Add/Subtract leave base at 0 and put the layer's delta in scale. Replace puts
// the node's own world height in base, and scale is the amplitude layered on
// top of it - which is 0 for a shape with no per-texel amount of its own, like
// the circle, and the image layer's height for one that has.
uniform float shape_value_base = 0.0;
uniform float shape_value_scale = 0.0;
// Circle.
uniform vec2 shape_center = vec2( 0.0, 0.0 );
uniform float shape_inner_radius = 0.0;
uniform float shape_outer_radius = 1.0;
uniform bool shape_smooth_falloff = true;
2026-11-12 14:28:42 +00:00
// 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.
//
// repeat_enable because a tiling layer relies on the sampler wrapping - which is
// also why nothing here calls fract(), that would put a filtering seam on every
// tile border. The non-tiling path emulates clamp-to-edge in UV instead.
uniform sampler2D shape_image : hint_blit_source0, filter_linear, repeat_enable;
uniform vec2 shape_axis_x = vec2( 1.0, 0.0 );
uniform vec2 shape_axis_z = vec2( 0.0, 1.0 );
uniform vec2 shape_size = vec2( 1.0, 1.0 );
uniform bool shape_image_tiling = false;
// HeightMapChannel - 0 R, 1 G, 2 B, 3 A, 4 RA, 5 GA, 6 BA.
uniform int shape_image_channel = 0;
// An extra multiplier on coverage, from source 1. An absent mask is bound to the
// shared white 1x1 stand-in, so "no mask" needs no flag - it just multiplies by
// 1 - and the mask is always clamped, so outside its own rect it extends its
// border value rather than disappearing.
uniform sampler2D shape_mask : hint_blit_source1, filter_linear, repeat_enable;
uniform int shape_mask_channel = 3;
uniform vec2 shape_mask_center = vec2( 0.0, 0.0 );
uniform vec2 shape_mask_axis_x = vec2( 1.0, 0.0 );
uniform vec2 shape_mask_axis_z = vec2( 0.0, 1.0 );
uniform vec2 shape_mask_size = vec2( 1.0, 1.0 );
/** 1 inside the inner radius, falling to 0 at the outer radius. */
void heightMapShape_circle( vec2 worldXZ, out float amount, out float coverage )
{
float distance = length( worldXZ - shape_center );
float inner = min( shape_inner_radius, shape_outer_radius );
float outer = max( shape_inner_radius, shape_outer_radius );
float weight;
// A zero-width band is a hard edge, not a division by zero.
if ( outer <= inner )
{
weight = distance <= outer ? 1.0 : 0.0;
}
else
{
float t = clamp( ( distance - inner ) / ( outer - inner ), 0.0, 1.0 );
weight = 1.0 - t;
if ( shape_smooth_falloff )
{
weight = weight * weight * ( 3.0 - 2.0 * weight );
}
}
amount = weight;
// The circle has no per-texel height of its own: under Replace it writes one
// flat level and the falloff is the crossfade, so it belongs in coverage;
// under Add/Subtract the falloff already scales the height through amount and
// coverage has to stay out of the way, or it would be applied twice.
coverage = shape_blend_mode == 2 ? weight : 1.0;
}
float heightMapShape_channel( vec4 color, int channel )
{
if ( channel == 0 || channel == 4 ) { return color.r; }
if ( channel == 1 || channel == 5 ) { return color.g; }
if ( channel == 2 || channel == 6 ) { return color.b; }
return color.a;
}
/** 0..1 across a rotated world-space XZ rect.
Image row 0 lands on the rect's -Z edge, so the texture reads upright when
the map is looked at from above.
*/
vec2 heightMapShape_rectUV( vec2 worldXZ, vec2 center, vec2 axisX, vec2 axisZ, vec2 size )
{
vec2 delta = worldXZ - center;
vec2 safeSize = max( size, vec2( 0.0001, 0.0001 ) );
return vec2( dot( delta, axisX ), dot( delta, axisZ ) ) / safeSize + 0.5;
}
vec2 heightMapShape_clampToEdge( sampler2D source, vec2 uv )
{
vec2 halfTexel = 0.5 / vec2( textureSize( source, 0 ) );
return clamp( uv, halfTexel, 1.0 - halfTexel );
}
/** The layer's texture, sampled in the rotated rect's own space. */
void heightMapShape_image( vec2 worldXZ, out float amount, out float coverage )
{
amount = 0.0;
coverage = 0.0;
vec2 uv = heightMapShape_rectUV(
worldXZ, shape_center, shape_axis_x, shape_axis_z, shape_size
);
if ( ! shape_image_tiling )
{
if ( uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 )
{
return;
}
// The sampler repeats for the tiling case, so clamp-to-edge has to be
// emulated here or a linear tap on the border would bleed the opposite edge
// of the texture in.
uv = heightMapShape_clampToEdge( shape_image, uv );
}
vec4 color = texture( shape_image, uv );
amount = heightMapShape_channel( color, shape_image_channel );
coverage = shape_image_channel >= 4 ? color.a : 1.0;
}
2026-11-12 14:28:42 +00:00
/** 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.
*/
float heightMapShape_mask( vec2 worldXZ )
{
vec2 uv = heightMapShape_rectUV(
worldXZ, shape_mask_center, shape_mask_axis_x, shape_mask_axis_z, shape_mask_size
);
vec4 color = texture( shape_mask, heightMapShape_clampToEdge( shape_mask, uv ) );
float value = heightMapShape_channel( color, shape_mask_channel );
return shape_mask_channel >= 4 ? value * color.a : value;
}
void blit()
{
vec2 worldXZ = shape_rect_world_min + UV * shape_rect_world_size;
float amount = 0.0;
float coverage = 0.0;
2026-11-12 14:28:42 +00:00
// 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;
2026-11-12 14:28:42 +00:00
case 2: heightMapShape_ribbon( UV, amount, coverage, level ); break;
}
// Applied to coverage rather than to the height, so a masked-out region falls
// back to what is underneath under Replace instead of being flattened to the
// layer's base level.
coverage *= heightMapShape_mask( worldXZ );
if ( shape_blend_mode == 2 )
{
// 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.
2026-11-12 14:28:42 +00:00
COLOR0 = vec4( vec3( level + shape_value_scale * amount ), coverage );
}
else
{
// Add / Subtract: alpha has to be 1, because the hardware blend multiplies
// the source by its own alpha before summing. Coverage is applied here
// instead.
COLOR0 = vec4( vec3( shape_value_scale * amount * coverage ), 1.0 );
}
}