3D faker cylinder spin

This shader was an attempt to recreate a fake 3D cylinder effect. I noticed a similar effect in Deltarune and decided to recreate it in a CanvasItem shader for Godot.

Parameters

  • Spin Speed — Controls how fast the cylinder rotates.

  • Manual Rotation — Lets you set the rotation manually instead of using time.

  • Repeat Count — Controls how many times the texture wraps around the cylinder.

  • Curvature — Adjusts how rounded or flat the cylinder appears.

  • Shading Strength — Darkens the edges to create the illusion of depth.

  • Shadow Color — Defines the color used for edge shading.

  • Highlight Strength — Adds a subtle fake specular highlight to the center.

  • Cull Back Face — Hides the back side of the cylinder.

  • Show Back Through Transparency — Renders the back side wherever the front texture is transparent.

  • Back Darken — Controls how dark the back side appears.

How it works

The shader maps the sprite onto a virtual cylinder using trigonometry (asin, cos, and TAU). It then simulates depth with edge shading, a central highlight, optional back-face culling, and the ability to render the back side through transparent pixels, creating the illusion of a rotating 3D cylinder while remaining entirely 2D.

Shader code
shader_type canvas_item;

// ===========================================================
// FAKE 3D CYLINDER (2D) - Horizontal Rotation
// Apply this shader to a Sprite2D / TextureRect with the
// texture you want to wrap around the cylinder.
// ===========================================================

// Rotation speed (positive = one direction, negative = the other)
uniform float spin_speed : hint_range(-5.0, 5.0) = 1.0;

// Allows manual rotation control instead of using time.
// If "use_manual_rotation" is true, uses "manual_rotation"
// (0.0 to 1.0 = one full revolution)
uniform bool use_manual_rotation = false;
uniform float manual_rotation : hint_range(0.0, 1.0) = 0.0;

// How many times the texture wraps around the cylinder
// (1 = the entire image makes one full revolution)
uniform float repeat_count : hint_range(0.1, 10.0) = 1.0;

// How "thick" the cylinder appears (curvature).
// 1.0 = fully curved, 0.0 = almost flat
uniform float curvature : hint_range(0.0, 1.0) = 1.0;

// Side shading (darkens the edges to add volume)
uniform float shading_strength : hint_range(0.0, 1.0) = 0.4;
uniform vec4 shadow_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);

// Fake specular highlight in the center
uniform float highlight_strength : hint_range(0.0, 1.0) = 0.15;

// If true, hides the back side of the cylinder.
// Useful when the texture shouldn't wrap around
// (e.g. a logo only visible on the front)
uniform bool cull_back_face = false;

// --- Back side visible through transparent pixels ---
// Wherever the front of the sprite is transparent,
// the back side of the cylinder is rendered instead.
uniform bool show_back_through_transparency = true;
uniform float back_darken : hint_range(0.0, 1.0) = 0.7; // 0 = unchanged, 1 = almost black

void fragment() {
    // UV ranges from 0.0 to 1.0.
    // Convert X into the -1.0 to 1.0 range.
    float x = UV.x * 2.0 - 1.0;

    // Current rotation angle (in radians, one full revolution = TAU)
    float rotation;
    if (use_manual_rotation) {
        rotation = manual_rotation * TAU;
    } else {
        rotation = TIME * spin_speed;
    }

    // X represents the horizontal position on screen.
    // Convert it into an angle on the cylinder using asin(),
    // which creates the curved appearance by compressing the edges.
    float clamped_x = clamp(x, -0.999, 0.999);
    float base_angle = asin(clamped_x * curvature);
    float angle_on_cylinder = base_angle + rotation * repeat_count;

    // Convert the angle back into a wrapped U coordinate.
    float new_u = fract(angle_on_cylinder / TAU);

    // Surface depth (used for shading and optional back-face culling)
    float z = cos(base_angle);

    // Optionally discard the back side of the cylinder.
    if (cull_back_face) {
        float front_angle = mod(angle_on_cylinder, TAU);
        if (front_angle > PI * 0.5 && front_angle < PI * 1.5) {
            discard;
        }
    }

    vec2 final_uv = vec2(new_u, UV.y);
    vec4 tex_color = texture(TEXTURE, final_uv);

    // --- Volume shading (darkens the sides) ---
    float shade = mix(1.0 - shading_strength, 1.0, z);
    tex_color.rgb = mix(shadow_color.rgb, tex_color.rgb, shade);

    // --- Fake specular highlight ---
    float highlight = pow(max(z, 0.0), 4.0) * highlight_strength;
    tex_color.rgb += vec3(highlight);

    // --- Render the back side where the front is transparent ---
    if (show_back_through_transparency && tex_color.a < 1.0) {

        // Angle of the equivalent point on the opposite side
        // of the cylinder.
        float back_angle_on_cylinder = (PI - base_angle) + rotation * repeat_count;
        float back_u = fract(back_angle_on_cylinder / TAU);

        vec2 back_uv = vec2(back_u, UV.y);
        vec4 back_color = texture(TEXTURE, back_uv);

        // Darken the back side to make it feel internal/hidden.
        back_color.rgb *= (1.0 - back_darken);

        // Blend depending on the front alpha.
        tex_color.rgb = mix(back_color.rgb, tex_color.rgb, tex_color.a);
        tex_color.a = max(tex_color.a, back_color.a);
    }

    COLOR = tex_color;
}
Live Preview
Tags
3d, 3d fake, cylinder
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 miwls

Related shaders

guest

1 Comment
Oldest
Newest Most Voted
Dandy
5 days ago

Really cool psuedo 3d effect: alongside the aforementioned use of this in Deltarune, I recall seeing this same kind of effect show in a lot of Genesis and SNES titles (one that comes to mind off the top of my head is Mickey Mania) and this does a really good job at replicating that look! Good stuff!