Overview
PathRibbonMesh2D generates a ribbon-shaped 2D mesh along a sequence of points obtained by baking the specified Path2D.curve.
By using this script, you can create effects such as vines or tentacles extending along a path, as well as attack trail effects.
By changing the Z order mid-effect to adjust draw order, you can express interactions such as piercing into an object or wrapping around it.



path_ribbon_mesh_2d.gd (54.9 KB)
tentacle_texture.zip (570.3 KB)
tentacle_project.zip (2.2 MB)
Features
Rendering is performed by an automatically generated MeshInstance2D created as a child of this node. The script supports: specifying the visible section, width variation curves, UV scaling and rotation, miter handling for sharp corners, and stepwise switching of z_index based on position.
This script treats the generated MeshInstance2D nodes as script-managed objects, and recreates or destroys them when settings change or when split conditions change.
Assumptions and Requirements
This is designed for Godot 4.x 2D node setups.
The required conditions are as follows:
- A
Path2Dmust be assigned topath_node. path_node.curvemust be set as aCurve2D.- The baked point list must contain at least two points.
Visual appearance depends on ribbon_texture and blend_material. Even with no texture assigned, the mesh itself is generated, but the final appearance depends on the material implementation.
Setup
- Place a
Path2Dand edit itsCurve2Dto create the shape. - Attach this script to a
Node2D. - Assign the
Path2Dfrom step 1 topath_node. - Set
width. - If you want a visual look, set
ribbon_textureandblend_material. - As needed, configure the visible section, thickness curves, UV settings, sharp-corner handling, and Z keyframes.
Generated Node Specification
A MeshInstance2D is automatically generated as a child of this node. The generated node has the metadata _path_ribbon_mesh2d_generated and is managed by the script.
- If
use_center_split = true, two meshes (upper and lower) are generated. The node names areRibbonUpper_###andRibbonLower_###. They share the center line; the upper side expands upward from the center, and the lower side expands downward, building triangles accordingly. - If
use_center_split = false, a single mesh is generated. The node name isRibbonSingle_###. The ribbon surface is built by connecting the top and bottom edges.
At positions where z_index changes due to z_keyframes_percent_and_z, the segment is split and multiple MeshInstance2D nodes may be generated. Boundary points are shared to reduce visible seams.
Usage Examples
| Goal (Use case) | Settings | Result | Notes |
|---|---|---|---|
| Display the entire path | start_percent=0, progress_percent=100, slide_offset_percent=0 |
A ribbon is displayed over the entire path | If width_curve is a constant 1.0, the width is constant |
| Make the tip extend | Fix start_percent and animate progress_percent from 0→100 |
The end of the section extends | If progress_percent < start_percent, it is clamped to start_percent; no reverse-direction section is created |
| Keep length fixed and move only the position | Set visible length via progress_percent-start_percent, and change slide_offset_percent |
The section slides while maintaining visible length | The start position is constrained to 0..(100-VisibleLength) |
| Lock thickness variation to absolute positions along the path | thickness_domain_mode=FullPathAbsolute, set width_curve |
Thickness at the same distance position stays the same even if the visible section changes | Thickness phase stays fixed even with partial display or sliding |
| Make thickness variation follow the visible range | thickness_domain_mode=VisibleRangeNormalized, set width_curve |
Thickness is determined by normalizing the visible range to 0..1 | Changing the visible section also moves the thickness phase |
| Use different thickness ratios for upper/lower | use_center_split=true, and use different shapes for upper_width_curve / lower_width_curve |
Expansion differs above and below the center line | You can make only the upper side thin, or only the lower side thick |
| Repeat texture along the forward direction | Set ribbon_texture, increase uv_scale.x |
Tile count increases along U | Appearance depends on material and texture settings |
| Change texture angle | Set texture_rotation_degrees, and if needed uv_rotation_mode=TileLocalWrapRotation |
UVs rotate and the pattern angle changes | For use with tiling, tile-local rotation can be advantageous |
| Switch draw order by position | Set multiple Vector2(percent,z) entries in z_keyframes_percent_and_z |
z_index switches stepwise along the path |
The segment is split where Z changes, stabilizing draw order |
| Balance smoothness vs cost | Smaller bake_interval for smoothness; larger for lower cost |
Trade-off between curve fidelity and update cost | With many duplicate points, dedup can reduce vertex generation cost |
Property List
| Property | Type / Default | Description (Behavior / Calculation / Notes) |
|---|---|---|
path_node |
Path2D / (unset) |
Source Path2D for ribbon generation. If path_node == null, all generated meshes are hidden. If path_node.curve == null, it reports an error (push_error) and likewise becomes hidden. The script monitors curve reference replacement (e.g., replacing the Curve2D instance) and, upon detecting replacement, discards the bake cache and schedules a mesh update. |
width |
float / 40.0 |
Base width (pixels). It multiplies by the per-point “width factor” (width_curve, etc.), then halves it, and offsets vertices along the normal direction. For center split, separate offsets are computed for “center→upper” and “center→lower”. Negative values are not clamped, so operationally it should be treated as >= 0. |
uv_scale |
Vector2 / (1,1) |
Scale applied to UVs (texture coordinates). The application method depends on uv_scale_apply_mode. U is the forward direction along the line (0..1 within the visible range), and V is across the ribbon (0..1). For center split, the center line uses V=0.5 as the “center line”. |
bake_interval |
float / 1.0 |
Sets Curve2D.bake_interval before fetching curve.get_baked_points(). When the value changes or the curve is replaced, the bake cache (points and cumulative distances) is regenerated. Smaller values increase point count and smoothness but raise update cost (point processing + mesh generation). |
start_percent |
int / 0 |
Start of the visible section (0–100). Clamped to 0..100 in _process. During actual display calculation, it is adjusted so the range does not become “start > end” relative to progress_percent (see below). |
progress_percent |
int / 100 |
End of the visible section (0–100). Clamped to 0..100, and if progress_percent < start_percent, it is forced to match start_percent. That is, no reverse-direction section is created; at minimum it becomes a “length 0” section. |
slide_offset_percent |
int / 0 |
Moves only the start position while keeping the difference (progress_percent - start_percent, i.e., visible length). Internally: compute span_p = progress-start (0..100), then constrain s_p = start + slide_offset to 0..(100-span_p). End becomes e_p = s_p + span_p. This guarantees “fixed length, moving position.” |
sharp_bend_mode |
bool / false |
Switches corner handling. If false, it builds a center direction from blended forward/back direction vectors, and expands the ribbon using its normal (more moderate corner growth). If true, it blends forward/back normals to build a miter direction and computes join_scale (stretch factor). Sharp angles can stretch significantly, so miter_limit acts as an effective safety cap. |
ribbon_texture |
Texture2D / (unset) |
Assigned to each generated MeshInstance2D.texture. Additionally, if shader_texture_param_name is not empty and the material is a ShaderMaterial, the same texture is set as a shader parameter (see below). Even without a texture, the mesh is generated, but appearance depends on material settings. |
blend_material |
Material / null |
Source material applied to each generated MeshInstance2D.material. If null, no material is assigned. In the editor, it monitors the changed signal (when the referenced material is edited) and schedules re-application when changed. |
material_unique_per_instance |
bool / true |
If true, it creates a local material via blend_material.duplicate(false) and assigns it per instance. If false, it shares the blend_material reference as-is. Sharing means edits affect other objects; duplicating isolates effects but increases material count. |
shader_texture_param_name |
StringName / "" |
Used only when the locally applied material is a ShaderMaterial. If non-empty, it calls set_shader_parameter(shader_texture_param_name, ribbon_texture). If the shader does not define the uniform, setting it has no effect (or may warn), so the name must match the shader implementation. |
texture_rotation_degrees |
float / 0.0 |
Rotates UVs. If 0, rotation is skipped. The rotation center is (0.5, 0.5), and the method depends on uv_rotation_mode. Appearance can differ when using tiled UVs (U > 1, etc.), so select uv_rotation_mode as appropriate. |
uv_rotation_mode |
enum / LegacyWholeUVRotation |
UV rotation mode. LegacyWholeUVRotation rotates the entire UV space as-is (tiling boundaries may cause artifacts). TileLocalWrapRotation extracts the fractional part (0..1) using fposmod, rotates within the tile, then restores the integer tile index. This results in “rotate within each tile,” which works well with repeating textures. |
uv_scale_apply_mode |
enum / LegacyMultiplyVectorByUVScale |
UV scale mode. LegacyMultiplyVectorByUVScale applies Vector2(u, v) * uv_scale as a single multiplication. SeparateXYScale scales U and V separately by uv_scale.x / uv_scale.y. The center line (V=0.5) in center split is scaled similarly. |
miter_limit |
float / 2.5 |
When sharp_bend_mode = true, this is the upper bound of join_scale (offset multiplier) at corners. The factor is derived from dot products with the forward normal, but approaches infinity for very sharp angles, so this cap prevents extreme stretching. Higher values make corners sharper/longer; lower values suppress corner growth. |
width_curve |
Curve / Curve.new() |
Base curve for the width factor. For each point, it computes t_width and uses width_curve.sample(t_width) as a factor multiplied into width. If the sampled value is negative, it is clamped to 0. If the curve has no points at _ready, it automatically adds (0,1) and (1,1) so it becomes “constant width” by default. |
upper_width_curve |
Curve / Curve.new() |
Width factor curve for the upper side when center split (use_center_split = true). Upper offset distance is base_half * upper_factor * join_scale. If empty, it is initialized to constant 1.0 in _ready. |
lower_width_curve |
Curve / Curve.new() |
Width factor curve for the lower side when center split. Lower offset distance is base_half * lower_factor * join_scale. If empty, it is initialized to constant 1.0 in _ready. |
thickness_domain_mode |
enum / VisibleRangeNormalized |
Switches the basis for the sampling position t_width. With VisibleRangeNormalized, it normalizes the currently visible distance range [start_len, end_len] to 0..1 to compute t_width (changing the visible section changes t_width for the same absolute location). With FullPathAbsolute, it treats the full path length as 0..1 and uses t_width = dist / total_len (changing the visible section does not change t_width at the same distance). |
use_center_split |
bool / true |
Mesh generation mode. If true, it generates two meshes (Upper/Lower) sharing the center line. The upper builds triangles from “center→upper,” and the lower from “center→lower.” If false, it generates a single mesh (Single) connecting “upper→lower” to form the ribbon surface. When toggled, it rebuilds the generated node array and reconnects watchers (curve-change monitoring). |
bake_point_dedup_enabled |
bool / true |
Enables deduplication of baked points. In the Curve2D.get_baked_points() result, consecutive points are removed if their distance is <= bake_point_dedup_epsilon. Intended to reduce unnecessary work (vertex generation / index generation) for curves with many tiny duplicate points. |
bake_point_dedup_epsilon |
float / 0.0005 |
Distance threshold for deduplication. The implementation compares squared distance; it keeps a point only when (p - last).length_squared() > eps^2. If <= 0, it is effectively disabled. If set too large, the curve shape becomes coarse. |
profiling_enabled |
bool / false |
Enables profiling. Internally uses Time.get_ticks_usec() and accumulates total time and count per label. |
profiling_print_each_event |
bool / true |
When profiling_enabled = true, prints the measurement result for each event. Set to false if per-event output is unnecessary. |
profiling_print_threshold_usec |
int / 0 |
Threshold (usec) for printing events. <= 0 prints all; >= 1 prints only events at or above the threshold. Useful for extracting only heavy parts. |
profiling_dump_after_first_flush |
bool / true |
Prints a summary (total / count / average) after the first _flush_updates completes. Useful when only the initial run is needed. |
debug_print_range_stats |
bool / true |
On each mesh update, prints the visible range (%), distance range (start/end/total), U range, t_width range, and thickness_domain_mode. Intended for behavior verification. |
z_keyframes_percent_and_z |
Array[Vector2] / [(0,0),(100,0)] |
Z keyframes. Each element is treated as Vector2(percent, z). Processing: (1) clamp percent to 0..100 to create a working array (2) sort by percent ascending (if equal, sort by z ascending) (3) for an arbitrary percent, adopt the last key whose percent is <= that value (step) (4) z is rounded and converted to int, then assigned to MeshInstance2D.z_index. Where Z changes, the segment is split and boundary points are shared to reduce seams. |
Typical Operational Patterns
- Fixed ribbon showing the entire path
Setstart_percent = 0,progress_percent = 100,slide_offset_percent = 0, and makewidth_curvea constant 1.0 to produce a constant-width ribbon. - Extend a section over time
Fixstart_percentand animateprogress_percentfrom 0→100 to extend the visible section.progress_percent < start_percentis not allowed and will be clamped tostart_percent. - Keep visible length fixed and move only the position
The difference betweenstart_percentandprogress_percentbecomes the visible length. Withslide_offset_percent, only the start position moves while preserving that difference. The movable range is constrained to0..(100 - visible_length). - Choose whether thickness variation follows the visible section or stays fixed
Withthickness_domain_mode = VisibleRangeNormalized, sampling is based on the visible range normalized to 0..1, so changing the visible section also shifts the thickness phase. WithFullPathAbsolute, sampling uses the full path as 0..1, so the same distance position keeps the same thickness even if the visible section changes.