PathRibbonMesh2D Overview (including tentacle texture)

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.
Videotogif (1)
Videotogif
tentacle_thighten

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 Path2D must be assigned to path_node.
  • path_node.curve must be set as a Curve2D.
  • 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

  1. Place a Path2D and edit its Curve2D to create the shape.
  2. Attach this script to a Node2D.
  3. Assign the Path2D from step 1 to path_node.
  4. Set width.
  5. If you want a visual look, set ribbon_texture and blend_material.
  6. 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 are RibbonUpper_### and RibbonLower_###. 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 is RibbonSingle_###. 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
    Set start_percent = 0, progress_percent = 100, slide_offset_percent = 0, and make width_curve a constant 1.0 to produce a constant-width ribbon.
  • Extend a section over time
    Fix start_percent and animate progress_percent from 0→100 to extend the visible section. progress_percent < start_percent is not allowed and will be clamped to start_percent.
  • Keep visible length fixed and move only the position
    The difference between start_percent and progress_percent becomes the visible length. With slide_offset_percent, only the start position moves while preserving that difference. The movable range is constrained to 0..(100 - visible_length).
  • Choose whether thickness variation follows the visible section or stays fixed
    With thickness_domain_mode = VisibleRangeNormalized, sampling is based on the visible range normalized to 0..1, so changing the visible section also shifts the thickness phase. With FullPathAbsolute, sampling uses the full path as 0..1, so the same distance position keeps the same thickness even if the visible section changes.
4 Likes

very cool effect!

1 Like