Spiral / curved text as a Texture or Sprite — possible in Godot 4?

Godot Version

Godot4.3

Question

Hi everyone! I’m building a top-down puzzle game that has a “mystical tome” UI screen, and I need to display flavor text arranged in a spiral pattern — text that starts large in the center and winds outward (or inward) like a snail shell. Think old spellbook aesthetics.

I’ve been experimenting with the Path2D approach to place characters along a curve, and it works reasonably well for a simple arc, but spirals are a lot trickier because the radius keeps changing and the spacing between characters needs to scale with it. I haven’t found a clean way to drive that dynamically.

As a workaround, I actually tried generating the spiral text as a static image using an online tool — MockoFun’s curved text generator — which lets you produce spiral and arc text and export it as a PNG. It honestly looks great and I could just import the result as a Sprite2D for now, but I’d really love a runtime solution so the text content can change depending on what spell/page the player is reading.

A few specific things I’m stuck on:

  1. Is there a way to define a spiral curve in Path2D (parametrically, not by hand), so I can feed it into something like the TextLine + sample_baked_with_rotation approach?

  2. Should I be looking at a SubViewport + shader combo to warp a regular label into a spiral after the fact?

  3. Or is generating the text to a texture at runtime (via RenderingServer or similar) and applying a spiral distortion shader actually the most practical path here?

Any nudge in the right direction would be hugely appreciated. The MockoFun static image is my fallback but I’d love the text to be dynamic! :folded_hands:

hmm .. Possibly overkill but what you could do is define a number of labels in a UI control for each character, rotate them as needed to create the curve.

Create an array of the Labels (or just use getchildren on a node of them, as same thing anyway)

And then you could feed each character into each one as you build up the word, however you would probably then end up with the same issues as the path, where you would need to adjust according to how many letters. Might be easier than path though.

If the curve is always a spiral - go with the approach 2. It’s the simplest one to set up, and lets you do all kinds of additional realtime effects.

1 Like

I started making a plugin that handles curved text. I got the Node2D-based Path2D version working, but I was never able to get a Control-based Label version working.

Here’s the code for the Path2D one:

@icon("res://addons/dragonforge_curved_text/assets/textures/icons/curved_text.png")
@tool
## Creates curved text as a Node2D object. Note that if Label Settings is used
## to customize the font, the text will disappear until a font is assigned under
## Font. Changes to Shadow and Stacked Effects will not show as they are only
## supported by [Label] and [RichTextLabel] nodes. If you want this
## functionality, you will have to use a [CurvedTextLabel] node instead.
class_name CurvedText2D extends Path2D

@export var text: String:
	set(value):
		if text != value:
			text = value
			queue_redraw()
@export var label_settings: LabelSettings:
	set(value):
		if is_instance_valid(label_settings) and label_settings.changed.is_connected(queue_redraw):
			label_settings.changed.disconnect(queue_redraw)

		label_settings = value

		if is_instance_valid(label_settings):
			label_settings.changed.connect(queue_redraw)

var _line = TextLine.new()


func _draw() -> void:
	# Get the font, font size and color from the ThemeDB
	var font = ThemeDB.fallback_font
	var font_size = ThemeDB.fallback_font_size
	var font_color = Color.WHITE
	var outline_size = 0
	var outline_color = Color.WHITE

	# If the label_settings is valid, then use the values from it
	if is_instance_valid(label_settings):
		font = label_settings.font
		font_size = label_settings.font_size
		font_color = label_settings.font_color
		outline_size = label_settings.outline_size
		outline_color = label_settings.outline_color

	# Clear the line and add the new string
	_line.clear()
	_line.add_string(text, font, font_size)
	# Get the primary TextServer
	var text_server = TextServerManager.get_primary_interface()
	# And get the glyph information from the line
	var glyphs = text_server.shaped_text_get_glyphs(_line.get_rid())

	var offset = 0.0
	for glyph_data in glyphs:
		# Sample the curve with rotation at the offset
		var curve_transform = curve.sample_baked_with_rotation(offset)
		# set the draw matrix to that transform
		draw_set_transform_matrix(curve_transform)
		# draw the glyph
		text_server.font_draw_glyph(glyph_data["font_rid"], get_canvas_item(), font_size, Vector2.ZERO, glyph_data["index"], font_color)
		text_server.font_draw_glyph_outline(glyph_data["font_rid"], get_canvas_item(), font_size, outline_size, Vector2.ZERO, glyph_data["index"], outline_color)
		
		# add the advance to the offset
		offset += glyph_data.get("advance", 0.0)

I used it in my game Katamari Mech Spacey for the main screen logo. Since it’s a Node2D element, it doesn’t change when the screen resizes. It is localized in a dozen languages though, and I decided to just make it so that the curve reflected what I wanted generally and settled for the best I could get.

The problem with programmatically generating the spiral (which you could totally do) is too many points or too few points causes the letters to deform in unexpected ways - based on the font, size of the font, and the length of each character.

So unless you want it looking weird, or you want to spend a LOT of time testing, you’re better off making something that looks good and leaving it alone IMO.

I dug up one of my older shaders that wraps the UV space into a spiral. Just plugged in a viewport texture containing a label. It’s the best solution if you don’t mind a bit of glyph deformation. Even that may be preferable to blitting glyph by glyph. While the latter keeps each glyph intact, it kinda screws the kerning up, resulting in overly poorer typographic rhythm than just wrapping the entire line as a single image.

2 Likes

Got some shader code to go with that awesomeness?

It’s a bit messy with some hardcoded nudges. Will have to tidy it up before posting.

There. Use it wisely :smile:

1 Like

LOL You a**hole.

You’re really gonna make me retype the whole thing?

Not just you. Everyone else who wants it :smile:

Still beats extorting if from some unholy LLM in terms of time wasted. In fact I think it’d be quite hard to figure out a prompt that can make a bot dump a shader like this.

shader_type canvas_item;

uniform float radius_gain_per_revolution = 0.2;
uniform float revolutions_start = 1.0;
uniform float revolutions_end = 3.0;
uniform float width: hint_range(0.0, 1.0) = 1.0;
uniform float radius_power = 1.0;
uniform float stretch_u = 1.0;
uniform float oblique_u: hint_range(-0.25, 0.25) = 0.0;  
uniform float phase = 0.0;

void spiral(inout vec2 uv, inout float alpha_mask){
	vec2 pixel_rad_vec = uv - 0.5;
	float pixel_radius = length(pixel_rad_vec);
	float angle = atan(pixel_rad_vec.y, pixel_rad_vec.x) + phase;
	float angle_normalized = angle / TAU;

	float offset = pow(pixel_radius, radius_power) + angle_normalized * radius_gain_per_revolution;
	float dist_field_normalized = mod(offset, radius_gain_per_revolution) / radius_gain_per_revolution;
	float revolutions = floor(offset / radius_gain_per_revolution) - angle_normalized + 1.0;

	float mask_u = step(revolutions, revolutions_end) * step(revolutions_start, revolutions);
	float mask_v = 1.0 - 2.0 * step(width, dist_field_normalized);
	alpha_mask = mask_u * mask_v;

	uv.y = dist_field_normalized / width;
	uv.x = (revolutions - revolutions_start + uv.y * oblique_u) / stretch_u;
	uv.x *= sqrt(1.0 + (revolutions - revolutions_start)); // linearize - improvization, do better
}

void fragment(){
	vec2 uv = UV;
	float alpha_mask;
	spiral(uv, COLOR.a);
	// uv preview
	float preview_checker = float((step(0.5, fract(uv.x * 7.0)) + step(0.5, fract(uv.y * 1.0))) == 1.0);
	COLOR.rgb = vec3(uv, 0.0) - preview_checker * 0.25;
}

1 Like