2D Ball Spin/Rolling/Rotation effect (using a strip)
Adds a single curved stripe to a 2D sprite to fake spherical rolling.
The stripe is projected onto a sphere and rotated perpendicular to movement, creating a rolling illusion without rotating the sprite.
Designed for Sprite2D and region-enabled sprites / atlases. Motion is controlled from script.
@export var roll_scale := 0.02
@export var max_roll_speed := 30.0
@export var fade_start_speed := 1.0
@export var fade_full_speed := 100.0
var roll_phase := 0.0
var roll_dir := Vector2.RIGHT
func _physics_process(delta):
var v := linear_velocity
var speed := v.length()
# Update direction safely
if speed > 0.001:
roll_dir = v.normalized()
# Visual roll speed
var roll_speed = min(speed * roll_scale, max_roll_speed)
roll_phase -= roll_speed * delta
# Stripe visibility (0..1)
var stripe_strength := smoothstep(
fade_start_speed,
fade_full_speed,
speed
)
sprite.material.set_shader_parameter("roll_dir", roll_dir)
sprite.material.set_shader_parameter("roll_phase", roll_phase)
sprite.material.set_shader_parameter("stripe_strength", stripe_strength)
Shader code
shader_type canvas_item;
/* ---- inputs controlled from script ---- */
// normalized direction of motion (must be normalized in script)
uniform vec2 roll_dir = vec2(1.0, 0.0);
// rotation phase of the stripes around the ball
uniform float roll_phase = 0.0;
// visibility of the stripes (0 = hidden, 1 = fully visible)
uniform float stripe_strength : hint_range(0.0, 1.0) = 0.0;
/* ---- stripe appearance ---- */
// stripe color (alpha also affects strength)
uniform vec4 stripe_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// thickness of each stripe in sphere space
uniform float stripe_width : hint_range(0.001, 0.2) = 0.1;
// overall brightness multiplier (0..1)
uniform float stripe_brightness : hint_range(0.0, 1.0) = 1.0;
// controls how curved the stripes appear on the sphere
uniform float tilt : hint_range(0.0, 1.5) = 1.5;
// number of evenly spaced stripes
uniform int stripe_count : hint_range(1, 12) = 2;
/* ---- helpers ---- */
// rotates vector v around axis by angle (radians)
vec3 rotate_axis_angle(vec3 v, vec3 axis, float angle) {
axis = normalize(axis);
float s = sin(angle);
float c = cos(angle);
return v * c
+ cross(axis, v) * s
+ axis * dot(axis, v) * (1.0 - c);
}
void fragment() {
// region-safe uv mapping (works with Sprite2D region / atlas)
vec2 region_pos = REGION_RECT.xy;
vec2 region_size = REGION_RECT.zw;
vec2 uv_local = (UV - region_pos) / region_size;
vec2 uv_region = region_pos + uv_local * region_size;
// sample base texture color
vec4 base = texture(TEXTURE, uv_region);
// convert uv to centered sphere space (-1..1)
vec2 p = uv_local * 2.0 - 1.0;
float r2 = dot(p, p);
// draw stripes only on visible pixels inside the ball
if (
base.a > 0.1 &&
r2 <= 1.0 &&
stripe_strength > 0.0 &&
stripe_brightness > 0.0 &&
stripe_color.a > 0.0
) {
// compute sphere depth (hemisphere)
float z = sqrt(1.0 - r2);
vec3 pos = vec3(p.x, p.y, z);
// orientation based on motion direction
vec2 dir2 = normalize(roll_dir);
vec2 perp2 = vec2(-dir2.y, dir2.x);
// define a great-circle plane (edge-to-edge)
vec3 plane_n = normalize(vec3(-dir2.x, -dir2.y, tilt));
vec3 roll_axis = normalize(vec3(perp2.x, perp2.y, 0.0));
// evenly spaced stripes
float stripe_sum = 0.0;
float spacing = TAU / float(stripe_count);
for (int i = 0; i < stripe_count; i++) {
float phase_offset = roll_phase + float(i) * spacing;
vec3 plane_n_rot =
rotate_axis_angle(plane_n, roll_axis, phase_offset);
float d = abs(dot(pos, plane_n_rot));
float aa = fwidth(d) + 1e-6;
float stripe =
1.0 - smoothstep(stripe_width,
stripe_width + aa,
d);
stripe_sum += stripe;
}
// clamp to avoid overbrightening
float stripe_mask = clamp(stripe_sum, 0.0, 1.0);
// final blend factor
float blend =
stripe_mask *
stripe_strength *
stripe_brightness *
stripe_color.a;
// blend stripe color onto base color
base.rgb = mix(base.rgb, stripe_color.rgb, blend);
}
COLOR = base;
}

brilliant!