Terrain Editor

This commit is contained in:
Rokojori 2026-11-12 15:28:42 +01:00
parent ae581c9a77
commit 125a853e6d
46 changed files with 4962 additions and 54 deletions

View File

@ -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
}
}

View File

@ -0,0 +1 @@
uid://cm2junhdqbjuo

View File

@ -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
}
}

View File

@ -0,0 +1 @@
uid://da0bgh7152kh6

View File

@ -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 );
}
}
}

View File

@ -0,0 +1 @@
uid://qj55rfbltk7o

View File

@ -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" );
}
}

View File

@ -0,0 +1 @@
uid://b8tcfqwwnqfhd

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
uid://05mpp6hmlyo3

View File

@ -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 );
}
}
}

View File

@ -0,0 +1 @@
uid://56hhxxntlat1

View File

@ -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;
}
}
}

View File

@ -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
------------------------------------------------------------------------

View File

@ -38,16 +38,37 @@ namespace Rokojori
get => _blendMode; set { _blendMode = value; SetDirty(); }
}
float _height = 10;
float _heightHandle = 10;
/** In meters. The height to add or subtract for Add/Subtract - the node's Y
is ignored there - and the amplitude on top of the node's Y for Replace,
which a flat shape like the circle simply does not use.
*/
[Export]
public float heightHandle
{
get => _heightHandle;
set { _heightHandle = value; SetDirty(); }
}
float _height = 0;
public float height
{
get => _height; set { _height = value; SetDirty(); }
get => _heightHandle * _heightScale;
}
float _heightScale = 1;
/** In meters. The height to add or subtract for Add/Subtract - the node's Y
is ignored there - and the amplitude on top of the node's Y for Replace,
which a flat shape like the circle simply does not use.
*/
[Export]
public float heightScale
{
get => _heightScale;
set { _heightScale = value; SetDirty(); }
}
/** Writes the two normalized height uniforms for this layer's blend mode.
@ -126,7 +147,7 @@ namespace Rokojori
if ( usesHeightHandle )
{
var top = ToGizmoLocal( new Vector3( 0, height, 0 ) );
var top = ToGizmoLocal( new Vector3( 0, heightHandle, 0 ) );
// The stem is what makes the sign readable - a negative height hangs
// below the node instead of looking like a small positive one.
@ -164,14 +185,14 @@ namespace Rokojori
here rather than on the live position, so an edge drag that moves the
node does not drag the plane out from under itself.
*/
protected Vector3 dragStartPosition => _dragStartPosition;
protected Vector3 dragStartPosition => _dragStartPosition;
public Variant GetHandleValue( EditorNode3DGizmo gizmo, int handleId, bool secondary )
{
_dragStartHeight = height;
_dragStartHeight = _heightHandle;
_dragStartPosition = GlobalPosition;
return handleId == HandleIdHeight ? Variant.From( height ) : GetShapeHandleValue( handleId );
return handleId == HandleIdHeight ? Variant.From( _heightHandle ) : GetShapeHandleValue( handleId );
}
public void SetHandle( EditorNode3DGizmo gizmo, int id, bool secondary, Camera3D camera, Vector2 point )
@ -182,7 +203,7 @@ namespace Rokojori
if ( ProjectOntoAxis( camera, point, _dragStartPosition, Vector3.Up, out along ) )
{
height = along;
heightHandle = along;
}
}
else
@ -206,7 +227,7 @@ namespace Rokojori
if ( id == HandleIdHeight )
{
height = _dragStartHeight;
heightHandle = _dragStartHeight;
}
else
{

View File

@ -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
}
}

View File

@ -0,0 +1 @@
uid://dgpxbuhsqfn3u

View File

@ -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 );
}
}
}

View File

@ -0,0 +1 @@
uid://20sndvur2mgj

View File

@ -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 ) );
}
}
}

View File

@ -0,0 +1 @@
uid://dcyraxachyqi8

View File

@ -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 );
}

View File

@ -0,0 +1 @@
uid://cxx4pwxqrdrxe

View File

@ -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 ) );
}
}
}

View File

@ -0,0 +1 @@
uid://b351cuyfhytbo

View File

@ -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
{

View File

@ -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 );
}

View File

@ -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;
}

View File

@ -0,0 +1 @@
uid://bn4kkkileju7p

View File

@ -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;
}

View File

@ -0,0 +1 @@
uid://bhykfdc60h5fk

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
uid://d16nlsrm24tkn

View File

@ -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;
}

View File

@ -0,0 +1 @@
uid://dvo63swkcj63f

View File

@ -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 );

View File

@ -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();
}
}
}

View File

@ -0,0 +1 @@
uid://c14ylpe2b3ech

View File

@ -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 );
}
}
}

View File

@ -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 );
}

View File

@ -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 );
}

View File

@ -0,0 +1 @@
uid://bwdxgv2n1nyrl

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
uid://bv3fw1a4ato4a

View File

@ -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