Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 15 minutes
  • Examples run with godot 4.7.2-stable and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

sheet_frames.gd: frames, coordinates and a region GD · sheet_frames.gd
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()
A 128×64 sheet cut into frames, Godot 4.7.2 Python · sheet_session.py
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:

A 128×64 sheet as a 4 by 2 grid of 32×32 frames, numbered 0 to 7 along each row; frame 6 is column 2 of row 1.A 128×64 sheet, hframes 4, vframes 2frame = row × 4 + columnframe 0(0, 0)frame 1(1, 0)frame 2(2, 0)frame 3(3, 0)frame 4(0, 1)frame 5(1, 1)frame 6(2, 1)frame 7(3, 1)x 0x 32x 64x 96Each frame is 32×32. Frame 6 is column 2 of row 1,so the sprite cuts it from (64, 32).

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.

  1. 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.
  2. The bottom row holds frames 4 to 7, at frame_coords (0, 1) to (3, 1).
  3. 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.

Image Splitter (Grid & Carousel) Split a sheet into a 4 × 2 grid of separate frames to see which picture each frame number cuts.

Checkpoint

Check: Frames of a sprite sheet

  1. Question 1 of 2 A Sprite2D has hframes 6 and vframes 3. Which frame number shows frame_coords (4, 2), column 4 of row 2?

    Type a number.

    A hint

    Frames count along each row, then down.

    Show the answer: Question 1 of 2

    Answer: 16

    frame = row × hframes + column = 2 × 6 + 4 = 16, the same rule that made frame_coords (1, 1) frame 5 on the lesson's sheet of 4 columns.

  2. Question 2 of 2 A Sprite2D shows a 96×32 region of an atlas that holds 3 frames side by side. hframes is 3, and vframes is still 2 from the whole sheet. What does each frame show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The top or bottom half of a 32×32 frame, 32×16 pixels

    Set vframes to 1 for a region that holds one row, and the frames are whole again.

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:

play_frames.gd: three animations, 60 ticks GD · play_frames.gd
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)
Frames and signals of three animations, Godot 4.7.2 Python · play_session.py
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:

Three bars of frames over 60 ticks: the walk changes every 6 ticks, the attack stops on its last frame, the idle goes 0, 1, 2, 1, 0.Frames shown on 60 ticks, at 60 a secondEach box is one frame; the number under it, its tickswalk: 10 FPS, loops06162636061626360616attack: 10 FPS, no loop, frame 1 ×206113241idle: 5 FPS, ping-pong013112213112010tick 0tick 30tick 60

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.

  1. walk, 10 frames a second, looping: frames 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, each for 6 ticks.
  2. 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.
  3. 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

  1. Question 1 of 2 A game runs at 60 ticks a second (--fixed-fps 60). An AnimatedSprite2D animation plays at 12 frames a second. For how many ticks does a frame of duration 1 stay on screen?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 5 ticks

    60 / 12 = 5 ticks, as the walk at 10 frames a second stayed 60 / 10 = 6 ticks on each frame of the run.

  2. Question 2 of 2 An attack animation was left on loop by mistake. The script plays it, then waits with await sprite.animation_finished before going back to idle. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The hero stays in the attack for good, because a looping animation never emits animation_finished

    Turn the loop off for one-shot animations, and wait for animation_finished only from those.

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.

facing.gd: a row for each direction, a column for each moment GD · facing.gd
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()
Rows and columns of an eight-direction walk, Godot 4.7.2 Python · facing_session.py
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:

A circle cut into eight 45° sectors, rows 0 to 7 clockwise from right, with the boundary between rows 0 and 7 at −22.5°.Eight rows, one for each 45° of angle+y points down, so the rows go clockwise0right1down-right2down3down-left4left5up-left6up7up-right−22.5°−21.7° is still row 0; −23.5° is already row 7.

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.

  1. Row 0, right, covers −22.5° to 22.5°.
  2. 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.
  3. 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

  1. Question 1 of 2 The rows of an eight-direction sheet go clockwise from right: 0 right, 1 down-right … 7 up-right. Which row does the direction (-0.71, -0.71), at -135°, choose with wrapi(roundi(angle / (TAU / 8.0)), 0, 8)?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 5

    -135° is -3 eighths, and wrapi(-3, 0, 8) is 5, up-left, as the run printed.

  2. Question 2 of 2 A hero walks left. The player lets go of the stick, so the direction is Vector2.ZERO, and the code still computes the row from direction.angle(). Which way does the hero face?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Right, row 0, because the angle of a zero vector is 0

    Keep the facing in a variable and change it only while the direction is not zero.

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:

filter_rows.gd: the two filters, their numbers and half pixels GD · filter_rows.gd
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()
Nearest and bilinear, filter numbers and a centred coin, Godot 4.7.2 Python · filter_session.py
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 4-pixel stroke drawn 12 pixels wide: Nearest repeats 0 0 0 255 255 255…; Linear adds 84, 169, 170 and 85 at the edges.A stroke of 4 pixels, drawn 3 times largerThe number in each box is its ink (alpha)4 pixels02552550nearest, 12 pixels000255255255255255255000linear, 12 pixels00841692552552552551708500Linear makes 84, 85, 169 and 170: ink the artistnever drew, at both edges of the stroke.

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.

  1. The art: 4 pixels with the ink 0, 255, 255, 0, a stroke two pixels wide.
  2. Nearest, 12 pixels: 0 0 0, 255 six times, 0 0 0. Each texel became three equal pixels.
  3. 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

  1. Question 1 of 2 In a new Godot 4.7 project, a 32×32 pixel-art hero drawn at 3 times its size looks soft. Which setting is the first to change?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: rendering/textures/canvas_textures/default_texture_filter, from Linear to Nearest

    Nearest takes one texel for each screen pixel, so the edges stay hard.

  2. Question 2 of 2 A teammate wants Linear on one sprite and writes texture_filter = 1, the number of Linear in default_texture_filter. Which filter does the sprite use?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nearest, because on a CanvasItem 0 is Inherit and 1 is Nearest

    Use the named constants, CanvasItem.TEXTURE_FILTER_LINEAR here, and the numbers cannot be swapped.

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:

make_png.gd: a 32×32 sprite saved as hero.png GD · make_png.gd
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()
The import settings of a new PNG, Godot 4.7.2 Python · import_session.py
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:

Interactive · stage by stage From hero.png to the pixels on screen Step through one sprite. Each stage is a place where a setting can keep its pixels sharp or blur them.
From hero.png to the pixels on screen, step 5 of 5. Showing hero.png in the project, The importer (Lossless, no mipmaps), An imported texture (every pixel as drawn), A Sprite2D draws it (Nearest: square texels), The window (whole-number scale). In focus: The window.hero.png in the projectthe file the artist drewThe importerLossless, no mipmapsAn imported textureevery pixel as drawnA Sprite2D draws itNearest: square texelsThe windowwhole-number scale

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Pixel Art Maker Draw a 32×32 sprite and its walk frames, then export the PNG and import it into a project of your own.

Checkpoint

Check: Import settings for pixel art

  1. One question A pixel-art tree texture is also used on a sprite in a 3D scene. After a reimport its pixels look blotchy everywhere. What happened, and what is the fix?

    Choose one answer.

    Show the answer: One question

    Answer: Detect 3D switched it to VRAM Compressed; set Compress > Mode back to Lossless

    Lossless keeps every pixel as drawn; VRAM compression trades exact pixels for memory.

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.

  1. In a Godot 4.7 project, save walker.gd and check_walk.gd from below.
  2. Change frame_for() so that it rounds and wraps the row, and keeps the last row with column 0 while the direction is zero.
  3. In a terminal in the project’s folder, run godot --headless --no-header --script check_walk.gd.
walker.gd: the frame picker as given GD · practice/walker.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
check_walk.gd: the checker of this task GD · practice/check_walk.gd
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:

The checker on three walkers, Godot 4.7.2 Python · check_session.py
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.

  1. Question 1 of 15 What do the hframes and vframes properties of a Sprite2D set?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: The number of columns and rows its texture is cut into

    With hframes 4 and vframes 2, a 128×64 texture becomes 8 frames of 32×32.

  2. Question 2 of 15 A 160×96 sprite sheet holds frames of 32×32. Which hframes and vframes cut it?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: hframes 5, vframes 3

    Divide the width by the frame width for hframes, and the height by the frame height for vframes.

  3. Question 3 of 15 Coins, the hero and HUD icons share one 512×512 atlas. The hero's 4 walk frames sit side by side in a 128×32 strip whose top-left corner is (0, 96). How does one Sprite2D show walk frame 2?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: region_enabled on, region_rect (0, 96, 128, 32), hframes 4, vframes 1, frame 2

    A region chooses the part of the atlas; hframes and vframes then cut only that part.

  4. Question 4 of 15 A new SpriteFrames resource holds one animation, default. At how many frames a second does it play until you change it?

    Type a number.

    Show the answer: Question 4 of 15

    Answer: 5 frames a second

    A new SpriteFrames starts with default at 5 frames a second, looping; the lesson's walk set 10.

  5. Question 5 of 15 In an animation at 10 frames a second, one frame has a relative duration of 3. For how many seconds does it stay on screen at normal speed?

    Type a number.

    Show the answer: Question 5 of 15

    Answer: 0.3 seconds (anything from 0.299 to 0.301 counts)

    The SpriteFrames reference gives the absolute duration as the relative duration divided by the speed: 3 / 10 = 0.3 s, three times as long as a frame of duration 1.

  6. Question 6 of 15 At 60 ticks a second, the attack's middle frame, 2 / 10 s, should stay 12 ticks. It stayed 13. Why?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Its progress is a 32-bit number, and twelve steps of 1/12 left it at 0.99999994, just short of a whole frame

    For logic that must happen on an exact tick, count ticks yourself rather than reading the animation's frame.

  7. Question 7 of 15 With the rows clockwise from right, 0 right … 7 up-right, which row does the direction Vector2(0, 1) choose?

    Type a number.

    Show the answer: Question 7 of 15

    Answer: 2

    +y points down in Godot, so (0, 1) is down, at 90°: 2 eighths, row 2.

  8. Question 8 of 15 A walk row has 4 columns, played at 8 frames a second, and the column is int(time × 8) % 4. Which column does the hero show 0.6 s into the walk?

    Type a number.

    Show the answer: Question 8 of 15

    Answer: 0

    int(0.6 × 8) = int(4.8) = 4, and 4 % 4 = 0: the cycle has started again, as the run's columns went 0, 0, 1, 2, 3, 0 from 0.0 s to 0.5 s.

  9. Question 9 of 15 At which angle does wrapi(roundi(angle / (TAU / 8.0)), 0, 8) change from row 0, right, to row 1, down-right?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: 22.5°

    The run put -21.7° in row 0 and -23.5° in row 7: the boundary below right sits at -22.5°, and the one above at 22.5°.

  10. Question 10 of 15 Why compute the row with wrapi(roundi(…), 0, 8) rather than int(angle / (TAU / 8.0))?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: int() cuts towards zero, so upward angles give negative rows, and exactly left, 3.9999998 eighths in the run, gave row 3

    Round to the nearest eighth, then fold the negative eighths, the upward directions, into rows 5 to 7.

  11. Question 11 of 15 Which texture filter draws every texel of a sprite as a hard-edged square when it is shown larger?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: Nearest

    The CanvasItem reference says Nearest makes the texture look pixelated up close, which is the point of pixel art.

  12. Question 12 of 15 A 15×15 coin is centred at (100, 100). Where is its top-left corner, and what does that mean for pixel art?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: (92.5, 92.5), between screen pixels, so its edges can look uneven; centered = false or transform snapping fixes it

    Odd-sized sprites that are centred sit half a pixel off; the Sprite2D reference warns about it.

  13. Question 13 of 15 A pixel-art game also shows painted UI icons that must stay smooth when scaled. Which setup gives both?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Project default Nearest, and texture_filter Linear on the icons' nodes or a parent of them

    Set the common case project-wide and the exceptions on the nodes that need them.

  14. Question 14 of 15 Where is the texture filter of a 2D sprite set in Godot 4?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: On each CanvasItem, with a project setting as the default

    Import settings decide what pixels reach the GPU; the filter decides how they are drawn.

  15. Question 15 of 15 Which compress mode did Godot 4.7.2 give a new PNG, and why does it suit pixel art?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Lossless, compress/mode 0, because every pixel arrives exactly as drawn

    Keep Lossless for pixel art, and check it again after a texture has been used in 3D.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A Sprite2D cuts its texture into hframes × vframes equal cells and shows frame, 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.