Game Development with Godot & GDScript Module 7 – Building 2D worlds
Sprites, sprite sheets and pixel art
Cut sprite sheets with Sprite2D, play frame animations with AnimatedSprite2D, pick walk rows by direction and keep Godot 4.7 pixel art crisp.
What you will learn
- Choose any frame of a sprite sheet with hframes, vframes, frame and frame_coords
- Build frame animations with AnimatedSprite2D, with their speeds, durations, loop modes and signals
- Pick the row and column of an eight-direction walk cycle from the movement and the time
- Set up crisp pixel art with Nearest filtering, whole-pixel positions and Lossless import
Before you start
What you’ll learn · Before you start
What you will learn
- Choose any frame of a sprite sheet with hframes, vframes, frame and frame_coords
- Build frame animations with AnimatedSprite2D, with their speeds, durations, loop modes and signals
- Pick the row and column of an eight-direction walk cycle from the movement and the time
- Set up crisp pixel art with Nearest filtering, whole-pixel positions and Lossless import
Before you start
On this page
A 32×32 hero drawn three times larger in a new Godot 4.7 project comes out soft. The default Linear filter blends each pixel with its neighbours, so a stroke two pixels wide gains four pixels of partial ink that the artist never drew. Most sprite bugs are of this kind, small and silent: a frame cut from the wrong part of a sheet, an attack that never reports its end, a hero who turns to face right whenever the player stops.
This page cuts a sprite sheet into frames with Sprite2D, plays frame animations with AnimatedSprite2D, picks the row of an eight-direction walk from the way the player moves, and keeps pixel art sharp with the filter, whole-pixel positions and the import settings. Every number below is one that headless Godot 4.7.2 printed.
A sprite sheet is one texture cut into a grid of frames
A walking hero with 4 frames for each of 8 directions needs 32 pictures. Artists deliver them as one image, a
sprite sheet: One image that holds many frames of animation in a grid of equal cells. A Sprite2D shows one cell at a time with hframes, vframes and frame, which counts along each row and then down., and Godot cuts it. A Sprite2D draws one texture; give it hframes, the
number of columns, and vframes, the number of rows, and it divides the texture into equal cells and shows the one
numbered frame. Frames count along each row and then down, so frame is row × hframes + column, and
frame_coords names the same cell as (column, row). This run cuts a 128×64 sheet into 4 columns and 2 rows:
extends SceneTree
# A 128×64 sprite sheet: 4 columns and 2 rows of 32×32 frames, cut by Sprite2D's hframes and vframes.
func _initialize() -> void:
var sheet := ImageTexture.create_from_image(Image.create(128, 64, false, Image.FORMAT_RGBA8))
var hero := Sprite2D.new()
hero.texture = sheet
hero.hframes = 4
hero.vframes = 2
var size := hero.get_rect().size
print("Sheet 128×64, hframes 4, vframes 2: each frame is %d×%d" % [size.x, size.y])
for f in [0, 3, 4, 6]:
hero.frame = f
var c := hero.frame_coords
print("frame %d = column %d, row %d, cut from (%d, %d)" % [f, c.x, c.y, c.x * size.x, c.y * size.y])
hero.frame_coords = Vector2i(1, 1)
print("frame_coords (1, 1) is frame %d" % hero.frame)
# Only the bottom row of the sheet, as a region, still cut by hframes and vframes.
hero.region_enabled = true
hero.region_rect = Rect2(0, 32, 128, 32)
size = hero.get_rect().size
print("Region 128×32 with vframes still 2: each frame is %d×%d" % [size.x, size.y])
hero.vframes = 1
size = hero.get_rect().size
print("The same region with vframes 1: each frame is %d×%d" % [size.x, size.y])
hero.free()
quit() from session import files, run
files("project.godot", "sheet_frames.gd")
run("""
godot --headless --no-header --script sheet_frames.gd
""") Output
$ godot --headless --no-header --script sheet_frames.gd Sheet 128×64, hframes 4, vframes 2: each frame is 32×32 frame 0 = column 0, row 0, cut from (0, 0) frame 3 = column 3, row 0, cut from (96, 0) frame 4 = column 0, row 1, cut from (0, 32) frame 6 = column 2, row 1, cut from (64, 32) frame_coords (1, 1) is frame 5 Region 128×32 with vframes still 2: each frame is 32×16 The same region with vframes 1: each frame is 32×32
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sheet_session.py
Each frame came out 32×32: 128 / 4 by 64 / 2. Frame 6 is column 2 of row 1, so the sprite cuts it from the pixels at
(64, 32), and frame_coords (1, 1) is frame 5. Find both in the grid:
The frames of a 128×64 sheet with hframes 4 and vframes 2
Text description of the diagram
The sheet is drawn as a grid of 4 columns and 2 rows. Each cell is one 32×32 frame and shows its frame number and its frame_coords, the column and the row.
- The top row holds frames 0 to 3, at frame_coords (0, 0) to (3, 0). Their cells start at x 0, 32, 64 and 96.
- The bottom row holds frames 4 to 7, at frame_coords (0, 1) to (3, 1).
- Frame 6 is outlined: it is column 2 of row 1, so the sprite cuts it from the pixels at (64, 32). The rule is frame = row × 4 + column.
Often the sheet is only part of a bigger image, an atlas that also holds coins and HUD icons. Then region_enabled
and region_rect choose the rectangle to use, and hframes and vframes cut that rectangle instead of the whole
texture. The last two lines of the run show the trap: the sheet’s bottom row as a 128×32 region, with vframes still
2 from before, gave frames of 32×16, half a walking pose each. With vframes set to 1 they were whole again.
A region cut with the whole sheet's frame counts
hframes and vframes divide the region, not the texture. When you switch a sprite to a region, set them to the
columns and rows of that region: a one-row strip of 4 frames is hframes = 4, vframes = 1.
Checkpoint
Check: Frames of a sprite sheet
AnimatedSprite2D plays SpriteFrames at frames a second
A walk needs its frames to change in time. AnimatedSprite2D plays a SpriteFrames: The resource an AnimatedSprite2D plays: named animations, each with a speed in frames a second, a loop mode and a list of frames, every frame with a relative duration.
resource: named animations, each with a speed in frames a second, a loop mode and a list of frames, and each frame with
a relative duration, 1 by default. A new SpriteFrames already holds one animation, default, at 5 frames a second,
looping, as the first line of the next run shows. play() starts an animation, pause() keeps its place and stop()
goes back to its start. Two signals say where it is: animation_looped each time a looping animation starts over, and
animation_finished when one that does not loop reaches its end and pauses there.
This run plays three animations for one second, 60 ticks at --fixed-fps 60. A watcher node after the sprites reads
each of them at the end of every tick and lists the frames it showed as frame×ticks:
extends SceneTree
# Three AnimatedSprite2D nodes play for one second at 60 game frames a second (run with --fixed-fps 60).
# A watcher node after them starts them all on one tick, then reads every sprite at the end of each tick and lists
# the frames it showed, as frame×ticks, and when its signals came.
class Watcher extends Node:
var sprites: Array[AnimatedSprite2D] = []
var runs: Array[Array] = []
var events: Array[Array] = []
var ticks := -1
func _process(_delta: float) -> void:
ticks += 1
if ticks == 0:
for s in sprites:
s.play()
for i in sprites.size():
var frame := sprites[i].frame
var run: Array = runs[i]
if run.size() > 0 and run[-1][0] == frame:
run[-1][1] += 1
else:
run.append([frame, 1])
if i == 1 and frame == 1 and run[-1][1] >= 12:
print("attack, tick %d: frame 1 shown on %d ticks, frame_progress %.8f" % [ticks, run[-1][1], sprites[i].frame_progress])
if ticks < 59:
return
for i in sprites.size():
var s := sprites[i]
var parts: Array[String] = []
for r in runs[i]:
parts.append("%d×%d" % r)
print("%s, %d FPS: %s; %s" % [s.animation, s.sprite_frames.get_animation_speed(s.animation), " ".join(parts),
", ".join(events[i])])
print("After 60 ticks, 0 to 59: attack playing %s, idle playing backwards %s" % [sprites[1].is_playing(),
sprites[2].get_playing_speed() < 0])
get_tree().quit()
var watcher := Watcher.new()
func add_sprite(anim: StringName, fps: float, durations: Array[float], loop: SpriteFrames.LoopMode) -> void:
var frame_texture := ImageTexture.create_from_image(Image.create(16, 16, false, Image.FORMAT_RGBA8))
var frames := SpriteFrames.new()
frames.add_animation(anim)
frames.set_animation_speed(anim, fps)
frames.set_animation_loop_mode(anim, loop)
for d in durations:
frames.add_frame(anim, frame_texture, d)
var sprite := AnimatedSprite2D.new()
sprite.sprite_frames = frames
root.add_child(sprite)
var happened: Array[String] = []
sprite.animation_looped.connect(func() -> void: happened.append("looped on tick %d" % (watcher.ticks + 1)))
sprite.animation_finished.connect(func() -> void: happened.append("finished on tick %d" % (watcher.ticks + 1)))
watcher.sprites.append(sprite)
watcher.runs.append([])
watcher.events.append(happened)
sprite.animation = anim
func _initialize() -> void:
var fresh := SpriteFrames.new()
print("A new SpriteFrames: animations %s, %d FPS, looping %s" % [fresh.get_animation_names(),
fresh.get_animation_speed(&"default"), fresh.get_animation_loop(&"default")])
add_sprite(&"walk", 10.0, [1.0, 1.0, 1.0, 1.0], SpriteFrames.LOOP_LINEAR)
add_sprite(&"attack", 10.0, [1.0, 2.0, 1.0], SpriteFrames.LOOP_NONE)
add_sprite(&"idle", 5.0, [1.0, 1.0, 1.0], SpriteFrames.LOOP_PINGPONG)
root.add_child(watcher) from session import files, run
files("project.godot", "play_frames.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script play_frames.gd
""") Output
$ godot --headless --no-header --fixed-fps 60 --script play_frames.gd A new SpriteFrames: animations ["default"], 5 FPS, looping true attack, tick 17: frame 1 shown on 12 ticks, frame_progress 0.91666663 attack, tick 18: frame 1 shown on 13 ticks, frame_progress 0.99999994 walk, 10 FPS: 0×6 1×6 2×6 3×6 0×6 1×6 2×6 3×6 0×6 1×6; looped on tick 24, looped on tick 48 attack, 10 FPS: 0×6 1×13 2×41; finished on tick 25 idle, 5 FPS: 0×13 1×12 2×13 1×12 0×10; looped on tick 37 After 60 ticks, 0 to 59: attack playing false, idle playing backwards true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 play_session.py
Read the bars for what each sprite showed and for how long:
Which frame three AnimatedSprite2D nodes showed on each of 60 ticks
Text description of the diagram
Each animation is a bar along the 60 ticks of one second at 60 ticks a second. Each box in a bar is one frame, with its frame number inside and the ticks it stayed on screen under it.
- walk, 10 frames a second, looping: frames 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, each for 6 ticks.
- attack, 10 frames a second, not looping, its middle frame of duration 2: frame 0 for 6 ticks, frame 1 for 13 ticks (outlined), then frame 2, drawn dashed from the moment the animation finished and paused there, 41 ticks in all.
- idle, 5 frames a second, ping-pong: frames 0, 1, 2, 1 and 0 for 13, 12, 13, 12 and 10 ticks. The two 13-tick frames are outlined: 0.2 s should be 12 ticks.
The walk at 10 frames a second kept each frame for 6 ticks, 60 / 10, and looped on tick 24, after its four frames. The
attack does not loop: it stayed on its last frame, animation_finished came on tick 25, and is_playing() was false
afterwards. The idle went 0, 1, 2, 1, 0 and reversed on tick 37; ping-pong shows the frames at each end once per turn,
not twice.
New in Godot 4.7: ping-pong loops in SpriteFrames
set_animation_loop_mode() takes LOOP_NONE, LOOP_LINEAR or LOOP_PINGPONG. The SpriteFrames reference of
Godot 4.6 has only set_animation_loop(), on or off.
Three frames came out one tick too long. The attack’s middle frame has a duration of 2, so it should last 2 / 10 s =
0.2 s, 12 ticks, and it lasted 13; the idle’s frames 0 and 2, also 0.2 s each, lasted 13 as well, while its frame 1
lasted 12. The sprite adds each tick’s share to frame_progress, a 32-bit number in Godot’s source, and moves on when
that reaches 1. At these speeds each tick adds 1/12, and on the frame’s 13th tick the run read 0.99999994: twelve steps
of 1/12 fell just short of a whole frame. Whether a frame needs the extra tick depends on how the steps add up. One tick
in 60 does not change how an animation looks. Game logic that must happen on an exact tick, such as the frame on which
a sword can hit, should count ticks itself rather than read frame.
AnimationPlayer is the other way to animate a sheet: it keys a Sprite2D’s frame on a timeline, and the 2D sprite
animation page notes it can animate other properties, such as position, in the same animation. The next module
covers it.
Key idea
An animation’s speed sets how long its frames stay: at 60 ticks a second, 10 frames a second is 6 ticks a frame, and
a non-looping animation is the only kind that ends with animation_finished.
Checkpoint
Check: Frame animations and their signals
A walk cycle takes its row from the direction and its column from the clock
An eight-direction sheet has one row per direction, and the rows follow the angle of the movement. Vector2.angle()
gives that angle in radians, from −π to π, with 0 pointing right. Because +y points down in Godot, the reference’s own
example is that Vector2.DOWN.angle() is π / 2: positive angles turn clockwise, so a sheet whose rows go clockwise from
right lines up with them. Dividing the angle by an eighth of a turn, TAU / 8, gives a number from −4 to 4.
roundi() takes the nearest whole number, and wrapi(…, 0, 8) folds the negative ones, the directions that go up,
into rows 5 to 7. The column comes from the clock: at 8 frames a second, column = int(time × 8) % 4.
extends SceneTree
# The row of an eight-direction sheet for a movement direction. The rows go clockwise from right, because +y points
# down in Godot: 0 right, 1 down-right, 2 down, 3 down-left, 4 left, 5 up-left, 6 up, 7 up-right.
const ROWS := ["right", "down-right", "down", "down-left", "left", "up-left", "up", "up-right"]
func row_for(direction: Vector2) -> int:
return wrapi(roundi(direction.angle() / (TAU / 8.0)), 0, 8)
func _initialize() -> void:
var directions := [Vector2.RIGHT, Vector2(1, 1).normalized(), Vector2.DOWN, Vector2.LEFT,
Vector2(-1, -1).normalized(), Vector2(0.93, -0.37), Vector2(0.92, -0.40), Vector2.ZERO]
for d: Vector2 in directions:
var row := row_for(d)
print("(%5.2f, %5.2f): angle %6.1f°, %5.2f eighths, row %d (%s)" % [d.x, d.y, rad_to_deg(d.angle()),
d.angle() / (TAU / 8.0), row, ROWS[row]])
# The column follows the clock: 4 frames a row at 8 frames a second.
var columns: Array[String] = []
for t in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5]:
columns.append("%.1f s: %d" % [t, int(t * 8.0) % 4])
print("Columns at 8 FPS: ", ", ".join(columns))
print("Walking down-left at 0.3 s: row 3, column %d, frame %d" % [int(0.3 * 8.0) % 4, 3 * 4 + int(0.3 * 8.0) % 4])
var left := Vector2.LEFT.angle() / (TAU / 8.0)
print("Left, rounded: %.7f eighths is row %d; truncated with int(): row %d" % [left, wrapi(roundi(left), 0, 8),
int(left)])
quit() from session import files, run
files("project.godot", "facing.gd")
run("""
godot --headless --no-header --script facing.gd
""") Output
$ godot --headless --no-header --script facing.gd ( 1.00, 0.00): angle 0.0°, 0.00 eighths, row 0 (right) ( 0.71, 0.71): angle 45.0°, 1.00 eighths, row 1 (down-right) ( 0.00, 1.00): angle 90.0°, 2.00 eighths, row 2 (down) (-1.00, 0.00): angle 180.0°, 4.00 eighths, row 4 (left) (-0.71, -0.71): angle -135.0°, -3.00 eighths, row 5 (up-left) ( 0.93, -0.37): angle -21.7°, -0.48 eighths, row 0 (right) ( 0.92, -0.40): angle -23.5°, -0.52 eighths, row 7 (up-right) ( 0.00, 0.00): angle 0.0°, 0.00 eighths, row 0 (right) Columns at 8 FPS: 0.0 s: 0, 0.1 s: 0, 0.2 s: 1, 0.3 s: 2, 0.4 s: 3, 0.5 s: 0 Walking down-left at 0.3 s: row 3, column 2, frame 14 Left, rounded: 3.9999998 eighths is row 4; truncated with int(): row 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 facing_session.py
Each row covers 45°, and its boundaries sit halfway to the next row, so −21.7° is still row 0 and −23.5° already row 7. Find the dashed boundary:
The row each direction chooses, by its angle
Text description of the diagram
The circle is the set of directions a hero can move in. It is cut into eight sectors of 45°, one per row of the sprite sheet. Because +y points down in Godot, the rows go clockwise.
- Row 0, right, covers −22.5° to 22.5°.
- Going clockwise: row 1 down-right, row 2 down, row 3 down-left, row 4 left, row 5 up-left, row 6 up and row 7 up-right.
- The boundary between rows 0 and 7 is drawn dashed at −22.5°. In the run, a direction at −21.7° chose row 0 and one at −23.5° chose row 7.
Two lines of the run are traps. A zero direction, the player standing still, has the angle 0, and that is row 0: a
hero who stops while walking left turns to face right. Keep the facing in a variable and change it only while the
direction is not zero. The other trap is the last line. In this run Vector2.LEFT.angle() returned a hair under π,
so exactly left is 3.9999998 eighths: row 4 when rounded, row 3, down-left, when int() drops the decimals. The last
digit of such a result can differ from one system’s maths library to another; rounding gives row 4 either way.
int() instead of roundi()
int(angle / (TAU / 8)) cuts towards zero. Up, −2 eighths, gives row −2 and frame −5 in the practice below, and
exactly left gave row 3 in this run. Round first, then wrap: wrapi(roundi(angle / (TAU / 8.0)), 0, 8).
Checkpoint
Check: Rows from a direction, columns from the clock
Pixel art stays crisp when every texel is drawn as whole screen pixels
Pixel art is drawn small, 32×32 here, and shown several times larger. Each pixel of the texture, a texel: One pixel of a texture, as against a pixel of the screen. Drawn three times larger, each texel of a sprite covers 3×3 screen pixels., should become an equal square of screen pixels with hard edges. Two things break that: a filter that blends texels, and positions that fall between pixels.
The texture filter: How a texture is sampled when it is drawn larger or smaller. Nearest reads the closest texel and keeps pixels square; Linear blends the nearest four and smooths edges. In Godot 4 each CanvasItem sets it. decides how the GPU samples a texture drawn larger or smaller.
Nearest reads the closest texel for each screen pixel; Linear, the CanvasItem reference says, blends the nearest four,
which suits painted art and blurs pixel art. Image.resize() does the same two things on the CPU, so this run uses it
on a stroke two pixels wide drawn three times larger, and prints the ink, the alpha, of every pixel:
extends SceneTree
# What the two texture filters do to a row of pixel art drawn three times larger, and two more causes of soft or
# uneven pixels: the filter setting's numbers and a centred sprite of odd size.
# The row is a two-pixel stroke of ink: the alpha of its 4 pixels is 0, 255, 255, 0.
func alphas(img: Image) -> String:
var values: Array[String] = []
for x in img.get_width():
values.append("%3d" % roundi(img.get_pixel(x, 0).a * 255.0))
return " ".join(values)
func _initialize() -> void:
var art := Image.create(4, 1, false, Image.FORMAT_RGBA8)
art.set_pixel(1, 0, Color.BLACK)
art.set_pixel(2, 0, Color.BLACK)
print("4 pixels: ", alphas(art))
var nearest := art.duplicate() as Image
nearest.resize(12, 1, Image.INTERPOLATE_NEAREST)
print("12 pixels, nearest: ", alphas(nearest))
var bilinear := art.duplicate() as Image
bilinear.resize(12, 1, Image.INTERPOLATE_BILINEAR)
print("12 pixels, bilinear: ", alphas(bilinear))
print("Project default filter: %d (Viewport filters: Nearest %d, Linear %d)" % [ProjectSettings.get_setting(
"rendering/textures/canvas_textures/default_texture_filter"), Viewport.DEFAULT_CANVAS_ITEM_TEXTURE_FILTER_NEAREST,
Viewport.DEFAULT_CANVAS_ITEM_TEXTURE_FILTER_LINEAR])
print("CanvasItem filters: Inherit %d, Nearest %d, Linear %d" % [CanvasItem.TEXTURE_FILTER_PARENT_NODE,
CanvasItem.TEXTURE_FILTER_NEAREST, CanvasItem.TEXTURE_FILTER_LINEAR])
var coin := Sprite2D.new()
coin.texture = ImageTexture.create_from_image(Image.create(15, 15, false, Image.FORMAT_RGBA8))
coin.position = Vector2(100, 100)
print("15×15 coin at (100, 100), centred: top-left corner at %s" % (coin.position + coin.get_rect().position))
coin.centered = false
print("The same coin, centered = false: top-left corner at %s" % (coin.position + coin.get_rect().position))
print("snap_2d_transforms_to_pixel: %s" % ProjectSettings.get_setting("rendering/2d/snap/snap_2d_transforms_to_pixel"))
coin.free()
quit() from session import files, run
files("project.godot", "filter_rows.gd")
run("""
godot --headless --no-header --script filter_rows.gd
""") Output
$ godot --headless --no-header --script filter_rows.gd 4 pixels: 0 255 255 0 12 pixels, nearest: 0 0 0 255 255 255 255 255 255 0 0 0 12 pixels, bilinear: 0 0 84 169 255 255 255 255 170 85 0 0 Project default filter: 1 (Viewport filters: Nearest 0, Linear 1) CanvasItem filters: Inherit 0, Nearest 1, Linear 2 15×15 coin at (100, 100), centred: top-left corner at (92.5, 92.5) The same coin, centered = false: top-left corner at (100.0, 100.0) snap_2d_transforms_to_pixel: false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 filter_session.py
Compare the three rows of ink:
A two-pixel stroke drawn three times larger, with each filter
Text description of the diagram
Each row of boxes is a row of pixels, and the number in each box is its ink, the alpha from 0 to 255.
- The art: 4 pixels with the ink 0, 255, 255, 0, a stroke two pixels wide.
- Nearest, 12 pixels: 0 0 0, 255 six times, 0 0 0. Each texel became three equal pixels.
- Linear, 12 pixels: 0 0 84 169, 255 four times, 170 85 0 0. The blend made four pixels of partial ink that the art does not have, two at each edge of the stroke.
Nearest made three copies of each texel. The blend made 84, 85, 169 and 170: partial ink at both edges of the stroke,
the blur players see. In Godot 4 the filter is set on each CanvasItem, not when the image is imported. A node’s
texture_filter is Inherit by default, so it follows its parent, up to the viewport, which uses the project setting
rendering/textures/canvas_textures/default_texture_filter, Linear in a new project. Set that to Nearest for a
pixel-art game, and give texture_filter a value only on the nodes that should differ, such as a painted UI icon.
The run also printed a trap in the numbers. The project setting counts like the viewport’s filters, so Nearest is 0
and Linear is 1; on a CanvasItem, 0 means Inherit, so Nearest is 1 and Linear is 2. Copy the number 1 from one to the
other and you get the opposite filter; the named constants, such as CanvasItem.TEXTURE_FILTER_NEAREST, avoid it.
The second problem is half pixels. The 15×15 coin centred at (100, 100) has its top-left corner at (92.5, 92.5),
between screen pixels; the Sprite2D reference warns that centred textures can look deformed in pixel art for this
reason. centered = false puts the corner on (100, 100). For things that move, the project setting
rendering/2d/snap/snap_2d_transforms_to_pixel, off by default, rounds every CanvasItem’s position as it is drawn. Its
reference names the cost, less smooth movement, and advises against turning on vertex snapping as well. The window’s
scale needs whole numbers too: stretch mode viewport with the integer scale mode, as
the editor tour set up for a 640×360 game.
One setting for the game, exceptions on nodes
Set the project’s default filter to Nearest once, then set texture_filter to Linear on the parent of anything
painted, such as a menu of smooth icons; its children inherit it.
Checkpoint
Check: Filters and whole pixels
Import settings decide what reaches the GPU
Godot does not draw hero.png itself. When the file arrives in the project, the editor imports it into a texture under
.godot/imported/ and writes the settings it used into the .import file: The file Godot writes beside each imported asset, such as coin.svg.import, with the asset's import settings and its UID. It belongs in version control with the asset. More in “Project files, UIDs and version control” beside the PNG.
This session saves a PNG as an artist would deliver it, imports it, and reads five of those settings back:
extends SceneTree
# Saves a 32×32 sprite as hero.png in the project, as an artist's file would arrive.
func _initialize() -> void:
var img := Image.create(32, 32, false, Image.FORMAT_RGBA8)
img.fill_rect(Rect2i(8, 4, 16, 24), Color(0.95, 0.75, 0.2))
print("hero.png: ", error_string(img.save_png("res://hero.png")))
quit() from session import files, run
# The import settings Godot 4.7.2 gives a new PNG, read from the .import file it writes beside it.
files("project.godot", "make_png.gd")
run("""
godot --headless --no-header --script make_png.gd
godot --headless --import > /dev/null
grep -E '^(compress/mode|mipmaps/generate|process/fix_alpha_border|process/size_limit|detect_3d/compress_to)=' hero.png.import
""") Output
$ godot --headless --no-header --script make_png.gd hero.png: OK $ godot --headless --import > /dev/null $ grep -E '^(compress/mode|mipmaps/generate|process/fix_alpha_border|process/size_limit|detect_3d/compress_to)=' hero.png.import compress/mode=0 mipmaps/generate=false process/fix_alpha_border=true process/size_limit=0 detect_3d/compress_to=1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 import_session.py
compress/mode=0 is Lossless, which the Importing images page calls the default for 2D assets and the recommended
setting for pixel art: every pixel arrives as drawn. Mipmaps are off; the page advises turning them on in 2D only when
a project visibly benefits. Fix Alpha Border is on, so the transparent pixels around the art take the colours of their
neighbours and a blending filter pulls in no dark fringe. The line to watch is detect_3d/compress_to=1: if the texture
is ever used in 3D, Detect 3D switches it to VRAM Compressed, which the page says shows noticeable artifacts on
low-resolution textures, and the fix is to set Compress > Mode back to Lossless. Follow one PNG from the folder to the
window:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The file. An artist's 32×32 hero.png arrives in the project folder. The game does not draw the PNG itself; the editor imports it first, and the game loads the imported texture.
- The settings. The importer reads the settings beside the file. A new PNG got compress/mode 0, Lossless, mipmaps off and Fix Alpha Border on: the settings the Importing images page recommends for 2D and pixel art.
- The texture. Lossless keeps every pixel as drawn. If the texture is ever used in 3D, Detect 3D switches it to VRAM Compressed, which the page says shows artifacts on small textures: set it back to Lossless.
- The filter. The filter is not an import setting in Godot 4. Each CanvasItem has texture_filter, Inherit by default, which ends at the project setting: Linear in a new project, Nearest for pixel art.
- The window. Last, the stretch settings scale the picture to the window. With stretch mode viewport and scale mode integer, every texel becomes the same whole number of screen pixels.
Checkpoint
Check: Import settings for pixel art
Practise: an eight-direction walk cycle
Finish walker.gd, the frame picker of a top-down hero whose sheet has 8 rows of 4 columns, played at 8 frames a
second. As given, it cuts the angle with int() and forgets the facing when the player stops.
- In a Godot 4.7 project, save
walker.gdandcheck_walk.gdfrom below. - Change
frame_for()so that it rounds and wraps the row, and keeps the last row with column 0 while the direction is zero. - In a terminal in the project’s folder, run
godot --headless --no-header --script check_walk.gd.
extends RefCounted
# Picks the frame of an eight-direction walk sheet: 8 rows of 4 columns, played at 8 frames a second. The rows go
# clockwise from right: 0 right, 1 down-right, 2 down, 3 down-left, 4 left, 5 up-left, 6 up, 7 up-right.
# Standing still (a zero direction) must keep the last row and show column 0.
const COLUMNS := 4
const FPS := 8.0
var facing := 0
func frame_for(direction: Vector2, time: float) -> int:
var row := int(direction.angle() / (TAU / 8.0))
var column := int(time * FPS) % COLUMNS
return row * COLUMNS + column extends SceneTree
# Checks walker.gd of the practice. Run it in the project's folder:
# godot --headless --no-header --script check_walk.gd
# Eight calls in order, as a player walks, turns and stops; frame = row * 4 + column.
const CASES := [
[Vector2(1, 0), 0.0, 0, "right at 0.0 s"],
[Vector2(0.71, 0.71), 0.3, 6, "down-right at 0.3 s"],
[Vector2(0, -1), 0.4, 27, "up at 0.4 s"],
[Vector2(0.92, -0.40), 0.5, 28, "23.5 degrees above right at 0.5 s"],
[Vector2(-0.71, -0.71), 0.25, 22, "up-left at 0.25 s"],
[Vector2(0, 0), 0.6, 20, "standing still after up-left"],
[Vector2(0, 0), 0.9, 20, "still standing at 0.9 s"],
[Vector2(-1, 0), 1.2, 17, "left at 1.2 s"],
]
func _initialize() -> void:
var walker: RefCounted = load("res://walker.gd").new()
var right := 0
for c in CASES:
var got: int = walker.frame_for(c[0], c[1])
if got == c[2]:
right += 1
else:
print("WRONG %s: frame %d, expected %d" % [c[3], got, c[2]])
print("%d of %d frames right" % [right, CASES.size()])
quit(0 if right == CASES.size() else 1) The checker makes eight calls in order, as a player walks, turns and stops. Below, it runs three times: on the starting walker, on a finished walker, and on a tempting walker that rounds and wraps but forgets the facing:
from session import files, run
# The checker on walker.gd as it was given, on a finished one, and on a tempting one that rounds and wraps the angle
# but forgets the row while the player stands still.
files("practice/project.godot=given/project.godot", "practice/check_walk.gd=given/check_walk.gd",
"practice/walker.gd=given/walker.gd",
"practice/project.godot=right/project.godot", "practice/check_walk.gd=right/check_walk.gd",
"practice/solution/walker.gd=right/walker.gd",
"practice/project.godot=tempting/project.godot", "practice/check_walk.gd=tempting/check_walk.gd",
"practice/tempting/walker.gd=tempting/walker.gd")
run("""
cd given
!godot --headless --no-header --script check_walk.gd
cd ../right
godot --headless --no-header --script check_walk.gd
cd ../tempting
!godot --headless --no-header --script check_walk.gd
""") Output
$ cd given $ godot --headless --no-header --script check_walk.gd WRONG up at 0.4 s: frame -5, expected 27 WRONG 23.5 degrees above right at 0.5 s: frame 0, expected 28 WRONG up-left at 0.25 s: frame -10, expected 22 WRONG standing still after up-left: frame 0, expected 20 WRONG still standing at 0.9 s: frame 3, expected 20 WRONG left at 1.2 s: frame 13, expected 17 2 of 8 frames right [exit status 1] $ cd ../right $ godot --headless --no-header --script check_walk.gd 8 of 8 frames right $ cd ../tempting $ godot --headless --no-header --script check_walk.gd WRONG standing still after up-left: frame 0, expected 20 WRONG still standing at 0.9 s: frame 3, expected 20 6 of 8 frames right [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The given walker got 2 of 8: up became frame −5, up-left −10, and exactly left row 3. The tempting walker got 6 of 8. When the player stopped after walking up-left, it showed frame 0, the first frame of the right-facing row, and at 0.9 s frame 3, a walking pose of a hero standing still. The finished walker got all 8. Then test yourself on the five parts of this page, starting with the one you know least:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: when do you use Sprite2D, and when AnimatedSprite2D?
Sprite2D draws one texture, or one cell of a sprite sheet chosen with hframes, vframes and frame, or one
rectangle of an atlas with region_rect. It plays nothing by itself, so it suits still objects and frames that code
or an AnimationPlayer chooses. AnimatedSprite2D plays a SpriteFrames resource: named animations with a speed in frames
a second, frame durations and a loop mode, with the signals animation_looped and animation_finished. It suits
characters with walk, idle and attack cycles. In a Godot 4.7.2 run, a walk at 10 frames a second kept each frame for 6
of 60 ticks. For exact timing, such as a hit on one tick, count ticks in code, because the sprite’s 0.2 s frames lasted
12 or 13 ticks.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A hero's attack plays on an AnimatedSprite2D, and the script awaits animation_finished before going back to idle. Testers report that the hero sometimes freezes in the attack pose, mostly when a hit or a turn interrupts the attack. How do you find the cause, and how do you make the return to idle reliable?
-
Fresher to mid
With a gamepad, the hero's walk flickers between the right and down-right rows when the stick is held near the diagonal, and the walk cycle restarts at every flicker. On a keyboard it looks fine. Explain both symptoms and fix them without making turns feel slow.
Key takeaways
- A Sprite2D cuts its texture into
hframes×vframesequal cells and showsframe, numbered along each row and then down; with a region, the counts cut the region, not the texture. - AnimatedSprite2D plays SpriteFrames animations at their own speed, so at 60 ticks a second a 10-frames-a-second walk
shows each frame for 6 ticks, and only an animation that does not loop emits
animation_finished. - The row of an eight-direction walk comes from
wrapi(roundi(angle / (TAU / 8.0)), 0, 8)and the column from the clock, and a zero direction must keep the last row. - Pixel art stays crisp with the Nearest filter, set on the project or on each CanvasItem, positions on whole pixels and whole-number scaling; Linear blends texels into ink the artist never drew.
- A new PNG imports Lossless, without mipmaps and with Fix Alpha Border, which suits pixel art, but using the texture in 3D can switch it to VRAM Compressed.
Terms in this lesson
- sprite sheet
- One image that holds many frames of animation in a grid of equal cells. A Sprite2D shows one cell at a time with hframes, vframes and frame, which counts along each row and then down.
- SpriteFrames
- The resource an AnimatedSprite2D plays: named animations, each with a speed in frames a second, a loop mode and a list of frames, every frame with a relative duration.
- texel
- One pixel of a texture, as against a pixel of the screen. Drawn three times larger, each texel of a sprite covers 3×3 screen pixels.
- texture filter
- How a texture is sampled when it is drawn larger or smaller. Nearest reads the closest texel and keeps pixels square; Linear blends the nearest four and smooths edges. In Godot 4 each CanvasItem sets it.
References
- 2D sprite animation (Godot Engine)
- Sprite2D (class reference) (Godot Engine)
- AnimatedSprite2D (class reference) (Godot Engine)
- SpriteFrames (class reference) (Godot Engine)
- SpriteFrames (class reference, Godot 4.6) (Godot Engine)
- Godot 4.7.2 source: animated_sprite_2d.h (frame_progress is a real_t) (Godot Engine)
- Vector2 (class reference): angle() (Godot Engine)
- @GlobalScope (class reference): roundi() and wrapi() (Godot Engine)
- CanvasItem (class reference): texture_filter (Godot Engine)
- ProjectSettings (class reference): default texture filter and 2D snapping (Godot Engine)
- Image (class reference): resize() and its interpolation modes (Godot Engine)
- Godot 4.7.2 source: resource_importer_texture.cpp (Detect 3D and its compress_to values) (Godot Engine)
- Importing images (Godot Engine)
- Multiple resolutions (Godot Engine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress