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

2D lights, shadows and custom drawing

Light a Godot 4.7 2D scene with CanvasModulate, PointLight2D and occluders, draw widgets with _draw() and queue_redraw(), and paint textures at run time.

  • Intermediate
  • 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

  • Set up 2D lighting with CanvasModulate, PointLight2D and DirectionalLight2D
  • Configure occluders to cast shadows by matching shadow_enabled and the light masks
  • Draw shapes and widgets with _draw() and queue_redraw()
  • Update textures at run time with an Image or a DrawableTexture2D

Before you start

What you’ll learn · Before you start

What you will learn

  • Set up 2D lighting with CanvasModulate, PointLight2D and DirectionalLight2D
  • Configure occluders to cast shadows by matching shadow_enabled and the light masks
  • Draw shapes and widgets with _draw() and queue_redraw()
  • Update textures at run time with an Image or a DrawableTexture2D

Before you start

On this page

A torch that lights nothing, a crate that casts no shadow, a health ring that never changes: the three most common bugs of 2D lighting and custom drawing have one thing in common. Nothing reports an error. A PointLight2D without a texture is skipped by the renderer, a light without shadow_enabled ignores every occluder, and a node that changes its data without queue_redraw() keeps showing its old drawing.

This page lights a night scene with CanvasModulate, a torch and a moon, follows the masks that decide what each light and each shadow reaches, counts when _draw() runs, draws a health ring from its own geometry and paints a wall at run time on the CPU and on the GPU. Headless Godot draws nothing, so the runs below report what the renderer will use, with the numbers that Godot 4.7.2 printed.

CanvasModulate darkens the scene, and lights add colour back

A 2D scene starts fully lit. A CanvasModulate: A node that multiplies the colour of everything on its canvas by one colour. A night scene uses a dark one, and 2D lights then add colour back where they reach. node tints everything on its canvas, which makes night: the 2D lights and shadows page uses it for the colour of places no light reaches. Lights then add colour back. A PointLight2D shines from a position, and its texture is its shape and its size; a DirectionalLight2D shines parallel rays over the whole scene, like the moon, and its reference says its position is ignored. Both have a colour, an energy and a blend mode, Add by default.

In Add mode, the canvas shader of Godot 4.7.2 gives a pixel its sprite colour times the CanvasModulate colour, plus the sprite colour times each light’s texture, colour and energy. Follow one grey pixel of 0.8 through a night of 0.2:

Interactive · stage by stage How one pixel of a lit sprite gets its colour Step through a grey pixel of 0.8 in a night scene, first in the dark, then under a torch.
How one pixel of a lit sprite gets its colour, step 5 of 5. Showing The sprite's pixel, Times CanvasModulate (0.8 × 0.2 = 0.16), Plus each light (edge: 0.16 + 0.2 = 0.36), The screen (energy 2: 1.76, shown 1.0). In focus: The screen.The sprite's pixelgrey 0.8Times CanvasModulate0.8 × 0.2 = 0.16Plus each lightedge: 0.16 + 0.2 = 0.36The screenenergy 2: 1.76, shown 1.0

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

Every step as text
  1. The pixel. A sprite's pixel starts with the colour of its texture times its modulate, a grey of 0.8 here, in every channel.
  2. The night. CanvasModulate multiplies every pixel of its canvas. A night of 0.2 makes the grey 0.8 × 0.2 = 0.16: the colour of every place no light reaches.
  3. A torch. A light in Add mode adds the pixel's own colour times the light's texture, colour and energy. At the centre of a white torch of energy 1, the texture is 1: 0.16 + 0.8 × 1 = 0.96.
  4. The edge of its reach. Near the edge of the torch the texture fades, say to 0.25: 0.16 + 0.8 × 0.25 = 0.36. Beyond the texture the light adds nothing.
  5. Too much energy. With energy 2 the centre gets 0.16 + 1.6 = 1.76, and a screen without HDR output shows 1.0, white: the torch burns out the colours of everything it touches.

Where the pixel is Light texture Energy Colour shown
In the dark 0 1 0.8 × 0.2 = 0.16
At the torch’s centre 1 1 0.16 + 0.8 = 0.96
Near the edge of its reach 0.25 1 0.16 + 0.2 = 0.36
At the centre, energy 2 1 2 1.76, shown as 1.0 without HDR output

The light adds the pixel’s own colour, so a dark pixel stays darker than a bright one under the same torch, and a light never adds anything beyond its texture. That texture is not optional. In the renderer’s source, a point light takes part only when it is enabled and has a valid texture, and its reach is that texture’s rectangle times texture_scale. A new PointLight2D has none, so it lights nothing. The 2D lights page makes one of your own from a GradientTexture2D: a radial fill from opaque white at the centre to transparent white at the edge, which is the torch of the next run.

A point light without a texture

An enabled PointLight2D with energy 1 and no texture lights nothing, and Godot says nothing about it. Give it a texture, such as a radial GradientTexture2D with a gradient from opaque to transparent white, and set its size with texture_scale.

Color Converter Pick a night colour and copy its hex code, such as #33334d; Color.html() turns that code into about (0.2, 0.2, 0.3), the run's night.

Checkpoint

Check: CanvasModulate and 2D lights

  1. Question 1 of 3 A sprite's pixel is a grey of 0.6. The scene's CanvasModulate is 0.5, and no light reaches the pixel. What value does it show?

    Type a number.

    Show the answer: Question 1 of 3

    Answer: 0.3 (anything from 0.299 to 0.301 counts)

    CanvasModulate multiplies every pixel of its canvas: 0.6 × 0.5 = 0.3, as the lesson's 0.8 became 0.16 under a night of 0.2.

  2. Question 2 of 3 The same pixel, 0.6 under a CanvasModulate of 0.5, now sits at the centre of a white PointLight2D of energy 1 in the default Add mode, where its texture is 1. What value does it show?

    Type a number.

    Show the answer: Question 2 of 3

    Answer: 0.9 (anything from 0.899 to 0.901 counts)

    Add mode adds the pixel's own colour times the light: 0.3 + 0.6 × 1 × 1 = 0.9.

  3. Question 3 of 3 A PointLight2D is enabled, white, energy 1, and lights nothing at all. What do you check first?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Its texture; a PointLight2D without one reaches nothing

    The texture is the light's shape and size: the run's torch reached the 256×256 square of its gradient texture.

A shadow needs an occluder, a switch and a shared mask bit

Masks decide which light touches what. A PointLight2D lights an item only when the item’s light_mask shares a bit with the light’s range_item_cull_mask, and only within its reach; a DirectionalLight2D, its reference says, ignores light masks and lights every item. Shadows add three conditions. The light needs shadow_enabled, which is off by default. The blocker needs a LightOccluder2D: A node whose OccluderPolygon2D blocks 2D light. It casts shadows from lights that have shadow_enabled on and a shadow_item_cull_mask sharing a bit with its occluder_light_mask. with a polygon. And the occluder’s occluder_light_mask must share a bit with the light’s shadow_item_cull_mask. The renderer’s source makes exactly these bit tests, and for a PointLight2D one more: its shadows darken an item only when the item’s light_mask also shares a bit with shadow_item_cull_mask. This run builds a night scene with a torch, a lamp and a moon, and reports what each reaches by the same rules:

light_check.gd: a night scene and a report of its lights GD · light_check.gd
extends SceneTree

# A night scene built in code, and a report of what each light can reach. Headless Godot draws nothing, so the report
# applies the tests the renderer makes: a PointLight2D needs a texture and reaches the rectangle of that texture times
# texture_scale; it lights an item whose light_mask shares a bit with its range_item_cull_mask; a DirectionalLight2D
# lights every item; an occluder casts a light's shadows when the light has shadow_enabled, the occluder's
# occluder_light_mask shares a bit with the light's shadow_item_cull_mask and, for a PointLight2D, the occluder's
# rectangle meets the light's reach.
var items: Array[Sprite2D] = []
var occluders: Array[LightOccluder2D] = []
var lights: Array[Light2D] = []


func sprite(item_name: String, at: Vector2, size: Vector2i, mask: int) -> void:
	var s := Sprite2D.new()
	s.name = item_name
	s.texture = ImageTexture.create_from_image(Image.create(size.x, size.y, false, Image.FORMAT_RGBA8))
	s.position = at
	s.light_mask = mask
	root.add_child(s)
	items.append(s)


func occluder(occluder_name: String, at: Vector2, half: float, mask: int) -> void:
	var o := LightOccluder2D.new()
	o.name = occluder_name
	o.position = at
	o.occluder = OccluderPolygon2D.new()
	o.occluder.polygon = PackedVector2Array([Vector2(-half, -half), Vector2(half, -half), Vector2(half, half),
		Vector2(-half, half)])
	o.occluder_light_mask = mask
	root.add_child(o)
	occluders.append(o)


func occluder_rect(o: LightOccluder2D) -> Rect2:
	var points := o.occluder.polygon
	var rect := Rect2(o.position + points[0], Vector2.ZERO)
	for p in points:
		rect = rect.expand(o.position + p)
	return rect


func reach(light: PointLight2D) -> Rect2:
	if light.texture == null:
		return Rect2()
	var size := light.texture.get_size() * light.texture_scale
	return Rect2(light.global_position + light.offset - size / 2.0, size)


func names_of(list: Array[String]) -> String:
	return "none" if list.is_empty() else ", ".join(list)


func _initialize() -> void:
	var night := CanvasModulate.new()
	night.color = Color(0.2, 0.2, 0.3)
	root.add_child(night)
	sprite("hero", Vector2(180, 160), Vector2i(16, 24), 1)
	sprite("crate", Vector2(260, 170), Vector2i(32, 32), 1)
	sprite("sign", Vector2(150, 110), Vector2i(24, 16), 2)
	sprite("tree", Vector2(520, 150), Vector2i(32, 64), 1)
	occluder("crate_occluder", Vector2(260, 170), 16, 1)
	occluder("pillar_occluder", Vector2(330, 120), 8, 2)
	# The torch's light: a radial gradient from opaque white at the centre to transparent white at the edge, as the
	# 2D lights page describes. Without a gradient, a GradientTexture2D holds no picture at all.
	var glow := GradientTexture2D.new()
	glow.width = 256
	glow.height = 256
	glow.gradient = Gradient.new()
	glow.gradient.colors = PackedColorArray([Color.WHITE, Color(1, 1, 1, 0)])
	glow.fill = GradientTexture2D.FILL_RADIAL
	glow.fill_from = Vector2(0.5, 0.5)
	glow.fill_to = Vector2(0.5, 0.0)
	var torch := PointLight2D.new()
	torch.name = "torch"
	torch.texture = glow
	torch.position = Vector2(200, 150)
	torch.shadow_enabled = true
	var lamp := PointLight2D.new()
	lamp.name = "lamp"
	lamp.position = Vector2(520, 120)
	var moon := DirectionalLight2D.new()
	moon.name = "moon"
	moon.shadow_enabled = true
	moon.shadow_item_cull_mask = 2
	for l: Light2D in [torch, lamp, moon]:
		root.add_child(l)
		lights.append(l)
	print("CanvasModulate %s" % night.color)
	print("A new light: range_item_cull_mask %d, shadow_item_cull_mask %d, shadow_enabled %s" % [
		lamp.range_item_cull_mask, lamp.shadow_item_cull_mask, lamp.shadow_enabled])
	print("The torch's texture: %d×%d, from %s at the centre to %s at the edge" % [glow.width, glow.height,
		glow.gradient.sample(0.0), glow.gradient.sample(1.0)])
	for light in lights:
		var lit: Array[String] = []
		var shadows: Array[String] = []
		var why_not: Array[String] = []
		var no_shadow: Array[String] = []
		var area := Rect2()
		if light is PointLight2D:
			area = reach(light)
			if area.size == Vector2.ZERO:
				print("%s: a PointLight2D without a texture reaches nothing" % light.name)
				continue
		for s in items:
			if light is DirectionalLight2D:
				lit.append(s.name)
			elif not area.intersects(Rect2(s.position - s.texture.get_size() / 2.0, s.texture.get_size())):
				why_not.append("%s is out of reach" % s.name)
			elif s.light_mask & light.range_item_cull_mask == 0:
				why_not.append("%s has light_mask %d" % [s.name, s.light_mask])
			else:
				lit.append(s.name)
		for o in occluders:
			if not light.shadow_enabled:
				no_shadow.append("%s: the light's shadow_enabled is off" % o.name)
			elif light is PointLight2D and not area.intersects(occluder_rect(o)):
				no_shadow.append("%s is out of reach" % o.name)
			elif o.occluder_light_mask & light.shadow_item_cull_mask == 0:
				no_shadow.append("%s has occluder_light_mask %d" % [o.name, o.occluder_light_mask])
			else:
				shadows.append(o.name)
		var where := "everywhere" if light is DirectionalLight2D else "%d×%d around %s" % [area.size.x, area.size.y,
			area.get_center()]
		print("%s, reach %s" % [light.name, where])
		print("  lights %s%s" % [names_of(lit), "" if why_not.is_empty() else " (not: %s)" % ", ".join(why_not)])
		print("  shadows from %s%s" % [names_of(shadows),
			"" if no_shadow.is_empty() else " (not: %s)" % ", ".join(no_shadow)])
	quit()
What three lights reach, and which occluders cast their shadows, Godot 4.7.2 Python · light_session.py
from session import files, run

files("project.godot", "light_check.gd")
run("""
godot --headless --no-header --script light_check.gd
""")

Output

$ godot --headless --no-header --script light_check.gd
CanvasModulate (0.2, 0.2, 0.3, 1.0)
A new light: range_item_cull_mask 1, shadow_item_cull_mask 1, shadow_enabled false
The torch's texture: 256×256, from (1.0, 1.0, 1.0, 1.0) at the centre to (1.0, 1.0, 1.0, 0.0) at the edge
torch, reach 256×256 around (200.0, 150.0)
  lights hero, crate (not: sign has light_mask 2, tree is out of reach)
  shadows from crate_occluder (not: pillar_occluder has occluder_light_mask 2)
lamp: a PointLight2D without a texture reaches nothing
moon, reach everywhere
  lights hero, crate, sign, tree
  shadows from pillar_occluder (not: crate_occluder has occluder_light_mask 1)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 light_session.py

The torch reached the 256×256 square of its gradient texture and lit the hero and the crate. It skipped the sign, whose light_mask 2 shares no bit with its range_item_cull_mask 1, and the tree, outside its reach. Its shadows came from the crate’s occluder only: the pillar’s occluder reaches into the square, but its occluder_light_mask is 2. The lamp had no texture and reached nothing. The moon lit all four items and cast only the pillar’s shadow, because its shadow_item_cull_mask is 2. See where each sits:

A night scene: the torch reaches a 256×256 square holding the hero, the crate and the sign; the tree and the lamp lie outside it.The night scene of the runDashed: the torch reaches its 256×256 texture123456torch1 hero: lit by the torch2 crate: lit; its occluder casts the grey shadow3 sign: light_mask 2, so the torch skips it4 tree: outside the torch's reach5 pillar: occluder mask 2, the moon's shadow only6 lamp: no texture, so no light at allThe moon, a DirectionalLight2D, lights 1 to 4.

What each light of the run's night scene reaches

Text description of the diagram

The dashed square is the torch's reach, its 256×256 light texture around (200, 150). Numbers mark the items.

  1. The hero, inside the square, is lit by the torch.
  2. The crate is lit too, and its occluder casts the torch's shadow: the grey wedge behind it, away from the torch.
  3. The sign is inside the square but has light_mask 2, which the torch's range_item_cull_mask of 1 does not include, so the torch leaves it dark.
  4. The tree is outside the torch's reach.
  5. The pillar is an occluder that overlaps the edge of the torch's square, with occluder_light_mask 2: it casts shadows only for a light whose shadow_item_cull_mask includes 2, the moon.
  6. The lamp is a PointLight2D without a texture, so it lights nothing.

The moon, a DirectionalLight2D, ignores light masks and lights all four items.

Occluders that cast nothing

New lights have shadow_enabled off, so occluders do nothing until you turn it on. When a shadow is still missing, compare the occluder’s occluder_light_mask with the light’s shadow_item_cull_mask: they need one bit in common.

Checkpoint

Check: Light masks, occluders and shadows

  1. Question 1 of 2 A torch, range_item_cull_mask 1, lights the hero and a crate but not a sign right beside them. The sign has light_mask 2. Which change lights the sign?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Give the torch's range_item_cull_mask bit 2 as well, or the sign's light_mask bit 1

    Light masks let some items ignore some lights, such as a sign that glows on its own.

  2. Question 2 of 2 A crate has a LightOccluder2D with a square polygon and occluder_light_mask 1. The torch lights the crate, but the crate casts no shadow. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The torch's shadow_enabled is still false, its default

    Check the chain in order: the light's shadow_enabled, the occluder's polygon, then the masks.

A custom drawing runs once, then again only when asked

Custom drawing is a node that draws itself with code: override _draw() and call draw_line(), draw_rect(), draw_arc() and the other draw methods there. The Custom drawing in 2D page says _draw() is called once and its commands are then cached; queue_redraw() asks for a new draw, and the CanvasItem reference adds that it happens only once per frame, however often it was asked for, and only while the node is visible. This run counts the draws of a health ring over ten frames while the script changes it:

draw_counts.gd: a ring that counts its draws GD · draw_counts.gd
extends SceneTree

# When a node's _draw() runs: a health ring counts its own draws on each of ten frames, while the script changes its
# health three times on frame 3, moves it on frame 5, hides it and changes its health on frame 6, and shows it again
# on frame 8.
class Ring extends Node2D:
	var draws := 0
	var health := 1.0:
		set(value):
			health = clampf(value, 0.0, 1.0)
			queue_redraw()

	func _draw() -> void:
		draws += 1
		draw_arc(Vector2.ZERO, 20.0, -PI / 2.0, -PI / 2.0 + TAU * health, 48, Color.LIME_GREEN, 4.0)

var ring := Ring.new()
var frame := 0
var counts: Array[String] = []


func _initialize() -> void:
	root.add_child(ring)


func _process(_delta: float) -> bool:
	frame += 1
	match frame:
		3:
			ring.health = 0.75
			ring.health = 0.5
			ring.health = 0.25
		5:
			ring.position = Vector2(50, 0)
		6:
			ring.visible = false
			ring.health = 0.1
		8:
			ring.visible = true
	counts.append("%d:%d" % [frame, ring.draws])
	if frame < 10:
		return false
	print("frame:draws so far  ", " ".join(counts))
	return true
Draws of a health ring over ten frames, Godot 4.7.2 Python · draw_session.py
from session import files, run

files("project.godot", "draw_counts.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script draw_counts.gd
""")

Output

$ godot --headless --no-header --fixed-fps 60 --script draw_counts.gd
frame:draws so far  1:1 2:1 3:1 4:2 5:2 6:2 7:2 8:2 9:3 10:3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 draw_session.py

The ring drew once on entering the tree. Three changes of its health on frame 3, each with its own queue_redraw(), led to a single draw, which the count shows from frame 4. Moving the node on frame 5 drew nothing: the cached drawing moves with the node. Hidden on frame 6 with its health changed again, it drew nothing until it was shown on frame 8, and the count rose on frame 9. Three draws in ten frames:

Ten frames of a health ring: one draw on entering the tree, one after three changes on frame 3, one after it was shown again.When _draw() ran, frame by frameFilled: the count first shows a new draw111213242526272839310▲ 3: health set three times▲ 5: moved, not redrawn▲ 6: hidden, health set▲ 8: shown again; the count rises on 9Inside the squares: draws so far. Dashed: hidden.

The frames on which a node's _draw() ran

Text description of the diagram

Each square is one of ten frames, with the number of draws counted so far inside. A filled square is the first frame whose count shows a new draw; dashed squares are frames on which the ring was hidden. Triangles mark what the script did.

  1. Frame 1: the ring entered the tree and drew once.
  2. Frame 3: its health changed three times, and each change called queue_redraw(); the ring drew once, and the count shows it from frame 4.
  3. Frame 5: the ring moved; it did not draw again, because a move reuses the drawing.
  4. Frames 6 and 7: the ring was hidden and its health changed; nothing was drawn.
  5. Frame 8: the ring was shown again and drew once more; the count shows it on frame 9: three draws in ten frames.

The rule for your own nodes follows: keep what you draw in variables, call queue_redraw() from the setters of those variables, and draw only in _draw(). A node that calls queue_redraw() from _process() draws on every frame, which the Custom drawing page reserves for drawings that really change every frame.

Key idea

A drawing is kept until you ask for a new one: queue_redraw() when the data changes gives exactly one draw, however often it was called in the frame, and moving the node needs none.

Checkpoint

Check: When _draw() runs

  1. Question 1 of 2 In one frame, a health ring's health changes three times, and each change calls queue_redraw(). How many times does its _draw() run?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 1 times

    The reference says the draw happens once per frame however many times queue_redraw() was called; the run's ring went from 1 draw to 2 after three changes.

  2. Question 2 of 2 A node draws a marker in _draw() and its position changes every frame. How often does _draw() run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only when something calls queue_redraw(); moving reuses what was drawn

    Redraw when what you draw changes, not when the node moves.

A health ring is an arc from the top, drawn in the node’s own space

The draw methods take positions in the node’s local space: The coordinates of one node, with its own origin, turned and scaled with it. A child's position is given in its parent's local space; to_local() and to_global() move a point between a node's local space and the world. More in “Transforms and coordinate spaces”, the CanvasItem reference says, so a widget is drawn around (0, 0) and placed by moving the node. Angles are in radians from the right-hand side, and because +y points down, growing angles turn clockwise on screen; draw_arc() runs clockwise from a smaller start angle to a larger end angle. A health ring therefore starts at −π / 2, the top, and runs for TAU × health. This version works out the arc’s points itself and joins them with draw_polyline(), so the shape can be checked without a screen:

health_ring.gd: a health ring and the points it draws GD · health_ring.gd
extends SceneTree

# A health ring drawn with _draw(): an arc of radius 20 that starts at the top and runs clockwise, as long as the
# health is full. ring_points() works out the points that draw_polyline() joins, so a test can check the shape
# without a screen.
class HealthRing extends Node2D:
	const RADIUS := 20.0
	const SEGMENTS := 48 # segments of a full ring

	var health := 1.0:
		set(value):
			health = clampf(value, 0.0, 1.0)
			queue_redraw()

	static func ring_points(fraction: float) -> PackedVector2Array:
		var points := PackedVector2Array()
		var count := maxi(2, ceili(SEGMENTS * fraction) + 1)
		var start := -PI / 2.0
		var end := start + TAU * clampf(fraction, 0.0, 1.0)
		for i in count:
			var angle := lerpf(start, end, float(i) / (count - 1))
			points.append(Vector2(cos(angle), sin(angle)) * RADIUS)
		return points

	func _draw() -> void:
		draw_circle(Vector2.ZERO, RADIUS, Color(0, 0, 0, 0.4), false, 4.0)
		if health > 0.0:
			draw_polyline(ring_points(health), Color.LIME_GREEN, 4.0, true)


func _initialize() -> void:
	for f in [1.0, 0.75, 0.4, 0.0]:
		var p := HealthRing.ring_points(f)
		print("health %3d%%: from %.0f° to %.0f°, %d points, first %s, last %s" % [f * 100, -90.0, -90.0 + 360.0 * f,
			p.size(), p[0].round(), p[p.size() - 1].round()])
	var ring := HealthRing.new()
	root.add_child(ring)
	ring.health = 0.4
	print("A HealthRing with health %.1f draws %d points" % [ring.health, HealthRing.ring_points(ring.health).size()])
	quit()
The points of a health ring at four levels of health, Godot 4.7.2 Python · ring_session.py
from session import files, run

files("project.godot", "health_ring.gd")
run("""
godot --headless --no-header --script health_ring.gd
""")

Output

$ godot --headless --no-header --script health_ring.gd
health 100%: from -90° to 270°, 49 points, first (0.0, -20.0), last (0.0, -20.0)
health  75%: from -90° to 180°, 37 points, first (0.0, -20.0), last (-20.0, 0.0)
health  40%: from -90° to 54°, 21 points, first (0.0, -20.0), last (12.0, 16.0)
health   0%: from -90° to -90°, 2 points, first (0.0, -20.0), last (0.0, -20.0)
A HealthRing with health 0.4 draws 21 points

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ring_session.py

At 75 % the arc ends at 180°, (−20, 0), the left side; at 40 % it ends at 54°, (12, 16), below and to the right of the centre, after 21 points. A full ring starts and ends at (0, −20), the top. Trace the 40 % arc:

A health ring at 40 %: a thick arc from the top of the circle, at −90°, clockwise to 54°, ending at (12, 16), drawn through 21 points.A health ring at 40 %+y points down, so growing angles turn clockwisestart −90°: (0, −20)end 54°: (12, 16)21 points360° × 0.4 = 144° of arc, from the top, clockwise.

The arc of a health ring at 40 %

Text description of the diagram

A grey circle of radius 20, drawn four times larger, is the empty ring. The blue arc is the health.

  1. The arc starts at the top, at −90°, the point (0, −20).
  2. It runs clockwise, because +y points down in Godot, for 360° × 0.4 = 144°.
  3. It ends at 54°, the point (12, 16), after 21 points: the full ring's 48 segments times 0.4, rounded up, plus one.

Points proportional to the arc

draw_arc() takes a point count. Scale it with the arc, here 49 points for a full ring and 21 for 40 %, so that a short arc is as smooth as a long one without drawing more points than it needs.

Checkpoint

Check: Arcs, angles and local space

  1. Question 1 of 2 A health ring starts at the top, -90°, and runs clockwise for 360° times the health. At what angle, in degrees, does it end at 25 %?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0 degrees

    -90 + 360 × 0.25 = 0°, which is the right-hand side of the ring, (20, 0) for a radius of 20.

  2. Question 2 of 2 The ring's arc goes from -90° to 54°. Why does it run clockwise on the screen?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because +y points down in Godot, so growing angles turn clockwise

    In Godot's 2D space, angle 0 points right and 90° points down.

Painting at run time: an Image on the CPU or a DrawableTexture2D on the GPU

Some games let players paint: graffiti on a wall, footprints in snow, a map that fills in as it is explored. The CPU way keeps the pixels in an Image, paints them with blend_rect() or fill_rect(), and sends the whole Image to its ImageTexture with update(), which the reference recommends over set_image() for frequent changes. The GPU way is new in Godot 4.7: a DrawableTexture2D: A texture, new in Godot 4.7, that is painted on the GPU: setup() gives it a size and a format, and blit_rect() copies another texture into a rectangle of it., which setup() creates at a size and format and blit_rect() paints by copying a texture into a rectangle of it, on the GPU. This run paints a 128×64 wall both ways:

paint_wall.gd: a wall painted on the CPU and on the GPU GD · paint_wall.gd
extends SceneTree

# Paint on a wall at run time, two ways. On the CPU: an Image, painted with blend_rect() and sent to its ImageTexture
# with update(). On the GPU: a DrawableTexture2D (new in Godot 4.7), painted with blit_rect(). Headless Godot renders
# nothing, so only the Image's pixels can be read back here.
func _initialize() -> void:
	var wall := Image.create(128, 64, false, Image.FORMAT_RGBA8)
	wall.fill(Color(0.55, 0.5, 0.45))
	var plain := wall.get_pixel(0, 0)
	var texture := ImageTexture.create_from_image(wall)
	var brush := Image.create(8, 8, false, Image.FORMAT_RGBA8)
	brush.fill(Color(0.9, 0.1, 0.4, 0.5))
	for spot in [Vector2i(10, 10), Vector2i(14, 12), Vector2i(60, 30)]:
		wall.blend_rect(brush, Rect2i(0, 0, 8, 8), spot)
	texture.update(wall)
	var painted := 0
	for y in wall.get_height():
		for x in wall.get_width():
			if wall.get_pixel(x, y) != plain:
				painted += 1
	print("Image: three dabs of an 8×8 brush changed %d of %d pixels" % [painted, wall.get_width() * wall.get_height()])
	print("  one dab at (11, 11): %s; two dabs at (15, 13): %s" % [wall.get_pixel(11, 11), wall.get_pixel(15, 13)])
	print("Each update() sends the whole %d×%d image: %d bytes" % [wall.get_width(), wall.get_height(),
		wall.get_data_size()])

	var canvas := DrawableTexture2D.new()
	canvas.setup(128, 64, DrawableTexture2D.DRAWABLE_FORMAT_RGBA8, Color(0.55, 0.5, 0.45))
	var dab := ImageTexture.create_from_image(brush)
	canvas.blit_rect(Rect2i(10, 10, 8, 8), dab)
	await process_frame
	print("DrawableTexture2D: %d×%d after setup() and blit_rect(); get_image() headless: %s" % [canvas.get_width(),
		canvas.get_height(), canvas.get_image()])
	quit()
Three dabs on an Image, one on a DrawableTexture2D, Godot 4.7.2 Python · paint_session.py
from session import files, run

files("project.godot", "paint_wall.gd")
run("""
godot --headless --no-header --script paint_wall.gd
""")

Output

$ godot --headless --no-header --script paint_wall.gd
Image: three dabs of an 8×8 brush changed 168 of 8192 pixels
  one dab at (11, 11): (0.7216, 0.298, 0.4235, 1.0); two dabs at (15, 13): (0.8078, 0.1961, 0.4118, 1.0)
Each update() sends the whole 128×64 image: 32768 bytes
DrawableTexture2D: 128×64 after setup() and blit_rect(); get_image() headless: <Object#null>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 paint_session.py

Three dabs of a half-transparent 8×8 brush changed 168 of the wall’s 8,192 pixels, and the two that overlap blended twice: (0.81, 0.20, 0.41) where one dab gave (0.72, 0.30, 0.42). Every update() sends the whole image, 32,768 bytes for this wall. The DrawableTexture2D took the same size and the blit, but its get_image() returned null: headless Godot renders nothing, and this texture’s pixels exist only where a renderer paints them.

Compared Image and ImageTexture DrawableTexture2D
Where the pixels live In memory, readable at once On the GPU
How it paints blend_rect(), fill_rect(), then update() blit_rect() with a source texture
Cost of a change The whole image, 32,768 bytes for 128×64 No upload of the image
Suits Small images, rare changes, pixels the game reads Large surfaces painted continuously

New in Godot 4.7: DrawableTexture2D

The class reference marks setup() and blit_rect() as experimental: they may change or be removed in a later version, so keep the painting code in one place that is easy to update.

Checkpoint

Check: Painting textures at run time

  1. Question 1 of 2 A 256×256 Image in RGBA8 is painted and sent to its ImageTexture with update() on every frame. How many bytes does each update send?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 262144 bytes

    256 × 256 pixels × 4 bytes = 262,144 bytes, a frame; the run's 128×64 wall was 32,768.

  2. Question 2 of 2 A game lets players spray graffiti on 1024×1024 walls and must also save each wall as a PNG. Which approach fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Paint on the GPU with DrawableTexture2D for speed, and read the pixels back only when saving

    CPU images suit small or rare changes; GPU textures suit large surfaces painted continuously.

Practise: a minimap drawn with _draw()

Finish minimap.gd, a node that draws a 4×4 square for every wall of a level given as rows of text, and one for the player. minimap_squares() lists what _draw() draws, so that the checker can test it without a screen. As given, it swaps x and y, and moving the player never redraws the map.

  1. In a Godot 4.7 project, save minimap.gd and check_minimap.gd from below.
  2. Fix the squares, and make set_player() redraw the map when the player’s cell changes, and only then.
  3. In a terminal in the project’s folder, run godot --headless --no-header --script check_minimap.gd.
minimap.gd: the minimap as given GD · practice/minimap.gd
extends Node2D

# A minimap drawn with _draw(): a 4×4 square for every wall of the level (# in its rows of text) and one for the
# player. minimap_squares() lists what _draw() draws, so a test can check it without a screen. The map must change on
# screen when the player moves, and must not redraw while nothing changes.
const CELL := 4
const WALL_COLOUR := Color(0.6, 0.6, 0.65)
const PLAYER_COLOUR := Color(0.2, 0.9, 0.4)

var level: Array[String] = []
var player := Vector2i.ZERO
var draws := 0


func set_player(cell: Vector2i) -> void:
	player = cell


func minimap_squares() -> Array:
	var squares := []
	for y in level.size():
		for x in level[y].length():
			if level[y][x] == "#":
				squares.append([Rect2(y * CELL, x * CELL, CELL, CELL), WALL_COLOUR])
	squares.append([Rect2(Vector2(player * CELL), Vector2(CELL, CELL)), PLAYER_COLOUR])
	return squares


func _draw() -> void:
	draws += 1
	for square in minimap_squares():
		draw_rect(square[0], square[1])
check_minimap.gd: the checker of this task GD · practice/check_minimap.gd
extends SceneTree

# Checks minimap.gd of the practice. Run it in the project's folder:
#   godot --headless --no-header --script check_minimap.gd
# It draws the minimap of a 6×3 level, checks two of its squares, then moves the player on frame 5 and counts the
# minimap's draws over 20 frames.
const LEVEL: Array[String] = ["######", "#....#", "##..##"]

var map: Node2D
var frame := 0
var right := 0
var draws_before_move := 0


func check(ok: bool, what: String) -> void:
	if ok:
		right += 1
	else:
		print("WRONG ", what)


func _initialize() -> void:
	map = load("res://minimap.gd").new()
	map.level = LEVEL
	map.set_player(Vector2i(2, 1))
	root.add_child(map)
	var squares: Array = map.minimap_squares()
	check(squares.size() == 13, "the minimap has %d squares, expected 12 walls and the player" % squares.size())
	var rects := squares.map(func(s: Array) -> Rect2: return s[0])
	check(rects.has(Rect2(20, 4, 4, 4)), "no wall square for cell (5, 1) at (20, 4)")
	check(rects.has(Rect2(8, 4, 4, 4)), "no player square for cell (2, 1) at (8, 4)")


func _process(_delta: float) -> bool:
	frame += 1
	if frame == 4:
		draws_before_move = map.draws
	if frame == 5:
		map.set_player(Vector2i(3, 1))
	if frame < 20:
		return false
	check(draws_before_move == 1, "draws before the player moved: %d, expected 1" % draws_before_move)
	check(map.draws == 2, "draws in 20 frames: %d, expected 2, one at the start and one after the move" % map.draws)
	print("%d of 5 checks passed" % right)
	quit(0 if right == 5 else 1)
	return false

The checker tests two squares of a 6×3 level, then moves the player on frame 5 and counts the draws over 20 frames. It ran on the given minimap, a finished one, and a tempting one that draws the right squares but calls queue_redraw() on every frame:

The checker on three minimaps, Godot 4.7.2 Python · check_session.py
from session import files, run

# The checker on minimap.gd as it was given, on a finished one, and on a tempting one that draws the right squares
# but asks for a redraw on every frame.
files("practice/project.godot=given/project.godot", "practice/check_minimap.gd=given/check_minimap.gd",
      "practice/minimap.gd=given/minimap.gd",
      "practice/project.godot=right/project.godot", "practice/check_minimap.gd=right/check_minimap.gd",
      "practice/solution/minimap.gd=right/minimap.gd",
      "practice/project.godot=tempting/project.godot", "practice/check_minimap.gd=tempting/check_minimap.gd",
      "practice/tempting/minimap.gd=tempting/minimap.gd")
run("""
cd given
!godot --headless --no-header --script check_minimap.gd
cd ../right
godot --headless --no-header --script check_minimap.gd
cd ../tempting
!godot --headless --no-header --script check_minimap.gd
""")

Output

$ cd given
$ godot --headless --no-header --script check_minimap.gd
WRONG no wall square for cell (5, 1) at (20, 4)
WRONG draws in 20 frames: 1, expected 2, one at the start and one after the move
3 of 5 checks passed
[exit status 1]
$ cd ../right
$ godot --headless --no-header --script check_minimap.gd
5 of 5 checks passed
$ cd ../tempting
$ godot --headless --no-header --script check_minimap.gd
WRONG draws before the player moved: 4, expected 1
WRONG draws in 20 frames: 20, expected 2, one at the start and one after the move
3 of 5 checks passed
[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 minimap put the wall of cell (5, 1) at (4, 20) instead of (20, 4), and drew only once in 20 frames, so its player dot never moved. The tempting one showed the right picture with 20 draws in 20 frames, 4 of them before the player had moved at all. The finished one drew twice: once at the start, once after the move. To close the module, work through the self-check below, which opens with the part of this page you know least:

Practice that adapts to you

Practise this lesson

11 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 11 What does a CanvasModulate node do in a lit 2D scene?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: It multiplies every pixel of its canvas by its colour, the light of places no lamp reaches

    Without a CanvasModulate, lights can only brighten a scene that is already fully lit.

  2. Question 2 of 11 A torch of energy 2 makes everything near it white. With a pixel of 0.8 under a night of 0.2, why?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: 0.16 + 0.8 × 2 = 1.76 is above 1, and a screen without HDR output shows it as 1.0, white

    Keep energy low enough that the brightest lit pixels stay under 1, and use colour for warmth.

  3. Question 3 of 11 Which 2D light ignores the light_mask of the items it lights?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: DirectionalLight2D

    A DirectionalLight2D still respects shadow masks, as the moon showed: only the pillar, mask 2, cast its shadow.

  4. Question 4 of 11 A pillar should cast shadows from the moon, a DirectionalLight2D, but not from the torch. How do you set it up?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Pillar occluder_light_mask 2, moon shadow_item_cull_mask with bit 2, torch shadow_item_cull_mask without it

    Masks let each light choose its own casters.

  5. Question 5 of 11 A torch PointLight2D casts shadows from crates. To let only pillars, occluder_light_mask 2, cast its shadows, a developer sets its shadow_item_cull_mask to 2. Now no shadow shows at all on the floor, whose light_mask is 1. Why?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: For a PointLight2D, shadow_item_cull_mask also picks the items its shadows darken, and the floor has no bit 2

    Either add bit 2 to the floor's light_mask, or leave the torch's mask at 1, give the pillars' occluders bit 1 and take it off the crates' occluders.

  6. Question 6 of 11 Where do draw_line(), draw_rect() and the other draw_* methods of a CanvasItem work?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: Inside _draw(), which Godot calls when the node must draw

    Keep the state in variables, draw it in _draw(), and call queue_redraw() when the state changes.

  7. Question 7 of 11 A hidden node's health changes, and its setter calls queue_redraw(). When does _draw() run?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: After the node is shown again

    Hidden nodes cost no drawing, and they catch up when they appear.

  8. Question 8 of 11 ring_points() uses 48 segments for a full ring and ceili(48 × fraction) + 1 points for part of one. How many points does it make at 50 %?

    Type a number.

    Show the answer: Question 8 of 11

    Answer: 25 points

    48 × 0.5 = 24 segments, joined by 25 points; at 40 % the run made 21.

  9. Question 9 of 11 A health ring is drawn with draw_polyline(points) in _draw(), its points around (0, 0). Where does it appear?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Around the node's own position, because draw_* methods work in the node's local space

    Draw around (0, 0) and move the node; draw_set_transform() adds a transform of its own when needed.

  10. Question 10 of 11 Which texture class, new in Godot 4.7, is painted on the GPU with blit_rect()?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: DrawableTexture2D

    setup() gives it a size and format; blit_rect() copies a texture into a rectangle of it.

  11. Question 11 of 11 In the run, the Image showed its painted pixels, but the DrawableTexture2D's get_image() returned null. Why?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: Headless Godot renders nothing, and a DrawableTexture2D's pixels live on the GPU

    Test the logic of GPU painting, such as where the dabs go, as data; check the pixels in a game window.

Interview questions

Warm-up: what does queue_redraw() do, and when must you call it?

queue_redraw() asks Godot to run a CanvasItem’s _draw() again. The drawing commands of _draw() are kept and reused, so a custom-drawn node shows the same picture until something calls it: moving the node needs no redraw, but changing the data it draws does. Godot draws once per frame however often it was called, and only while the node is visible. In a Godot 4.7.2 run, three changes of a health ring in one frame gave a single draw, a move drew nothing, and changes made while the ring was hidden were drawn only once it was shown again. Call it from the setters of the values _draw() uses, not from _process().

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    In a dungeon, a torch PointLight2D lights the room, but the crates and pillars cast no shadows, and a wall sign right next to the torch stays dark. List what you check, in order, and the fix for each.

    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

    A minimap of a 200×200 tile level is drawn in _draw() with one draw_rect() per wall cell, and the profiler shows it costing several milliseconds every frame. The level changes only when a door opens. What is wrong, and how do you make the minimap cheap?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • CanvasModulate tints a whole canvas, the colour of the dark, and a light in Add mode adds the pixel’s own colour times its texture, colour and energy, so energy 2 at a light’s centre can burn colours out to white.
  • A PointLight2D needs a texture and lights only items inside it whose light_mask shares a bit with its range_item_cull_mask; a DirectionalLight2D lights everything.
  • A shadow needs the light’s shadow_enabled, an occluder polygon and an occluder_light_mask that shares a bit with the light’s shadow_item_cull_mask.
  • _draw() runs when the node first draws and then once per frame after queue_redraw(), only while visible, and its positions are in the node’s local space, with angles turning clockwise.
  • An Image paints on the CPU and uploads the whole image with each update(); a DrawableTexture2D, new in 4.7, paints on the GPU with blit_rect().

Terms in this lesson

CanvasModulate
A node that multiplies the colour of everything on its canvas by one colour. A night scene uses a dark one, and 2D lights then add colour back where they reach.
DrawableTexture2D
A texture, new in Godot 4.7, that is painted on the GPU: setup() gives it a size and a format, and blit_rect() copies another texture into a rectangle of it.
LightOccluder2D
A node whose OccluderPolygon2D blocks 2D light. It casts shadows from lights that have shadow_enabled on and a shadow_item_cull_mask sharing a bit with its occluder_light_mask.

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.