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;
}
Live Preview
Tags
2d, ball, pixel-art, rolling, rotation, spin
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from thatisuday

Related shaders

guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Lemonsqueezy
Lemonsqueezy
20 days ago

brilliant!