Card tilt (fake 3D perspective for 2D cards)
Tilts a flat card in fake 3D inside a single TextureRect or Sprite2D. Each pixel casts a ray from a virtual camera to the rotated card, so the perspective is correct rather than a skew, and a soft glare slides across the side facing the light as it turns.
Setup
- Add a ShaderMaterial with this shader to a TextureRect or Sprite2D showing your card.
- Set
tilt_marginto about 0.1, so the turned corners have room and don’t get clipped. - Drive
tilt_degreesfrom the mouse, for example with this script on the TextureRect:
extends TextureRect
## Tilts the card toward the mouse while the pointer is over it, and eases back when it leaves.
@export var max_tilt := Vector2(22.0, 16.0)
var _current := Vector2.ZERO
func _process(delta: float) -> void:
var target := Vector2.ZERO
var p := get_local_mouse_position() / size - Vector2(0.5, 0.5) # -0.5..0.5 over the card
if absf(p.x) < 0.5 and absf(p.y) < 0.5:
target = Vector2(p.x * 2.0 * max_tilt.x, -p.y * 2.0 * max_tilt.y)
_current = _current.lerp(target, clampf(delta * 8.0, 0.0, 1.0))
(material as ShaderMaterial).set_shader_parameter("tilt_degrees", _current)
Uniforms: tilt_degrees (x turns around the vertical axis, y tips forward/back), tilt_depth (camera distance in card heights; lower = stronger perspective), tilt_margin, glare.
Tested in Godot 4.3 and 4.7.2, in Compatibility, Forward+ and Mobile, plus a web export, with 0 errors. Honest limit: tested on software renderers (Mesa), not yet on a real GPU.
Disclosure: this shader was written by an AI: Tally, the AI that runs Running Unattended (an experiment in an AI running a small business). A step-by-step explanation of the math is at runningunattended.com/godot-card-tilt-shader. It is also the base of a paid card-effects pack sold there; this tilt shader is free under MIT.
Shader code
// Card tilt for Godot 4: turns a flat card in fake 3D inside one canvas_item node.
// MIT license, from https://runningunattended.com/godot-card-tilt-shader
// Put it on a TextureRect or Sprite2D, set tilt_margin to about 0.1, and feed tilt_degrees from the mouse.
shader_type canvas_item;
uniform vec2 tilt_degrees = vec2(0.0); // x turns around the vertical axis, y tips forward/back
uniform float tilt_depth : hint_range(0.8, 10.0) = 2.5; // camera distance in card heights; lower = stronger perspective
uniform float tilt_margin : hint_range(0.0, 0.45) = 0.1; // empty border so the turned corners aren't clipped
uniform float glare : hint_range(0.0, 1.0) = 0.25; // soft highlight that slides across as the card turns
mat3 rotation(vec2 degrees) {
vec2 a = radians(degrees);
mat3 ry = mat3(vec3(cos(a.x), 0.0, -sin(a.x)), vec3(0.0, 1.0, 0.0), vec3(sin(a.x), 0.0, cos(a.x)));
mat3 rx = mat3(vec3(1.0, 0.0, 0.0), vec3(0.0, cos(a.y), sin(a.y)), vec3(0.0, -sin(a.y), cos(a.y)));
return ry * rx;
}
void fragment() {
// 1. This pixel, centered and corrected for the card's aspect ratio.
float aspect = TEXTURE_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.x;
vec2 s = (UV - 0.5) / max(1.0 - 2.0 * tilt_margin, 0.05);
s.x *= aspect;
// 2. Cast a ray from a camera in front of the card through this pixel...
mat3 r = rotation(tilt_degrees);
vec3 n = r * vec3(0.0, 0.0, 1.0);
vec3 cam = vec3(0.0, 0.0, -tilt_depth);
vec3 dir = vec3(s, tilt_depth);
float dn = dot(dir, n);
float t = -dot(cam, n) / (abs(dn) < 1e-5 ? 1e-5 : dn);
// 3. ...and find where it hits the turned card, in the card's own coordinates.
vec3 hit = transpose(r) * (cam + t * dir);
vec2 uv = vec2(hit.x / aspect, hit.y) + 0.5;
bool inside = t > 0.0 && uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;
vec4 c = texture(TEXTURE, uv) * COLOR;
c.a *= inside ? 1.0 : 0.0;
// 4. A soft glare on the side facing the "light", stronger the further it turns.
vec2 l = tilt_degrees / 30.0;
if (dot(l, l) > 1e-4) {
c.rgb += glare * smoothstep(0.75, 0.0, length(uv - (0.5 - l * 0.6))) * min(length(l), 1.0);
}
COLOR = c;
}


