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.
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
Checkpoint
Check: CanvasModulate and 2D lights
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:
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() 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:
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.
- The hero, inside the square, is lit by the torch.
- The crate is lit too, and its occluder casts the torch's shadow: the grey wedge behind it, away from the torch.
- 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.
- The tree is outside the torch's reach.
- 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.
- 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
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:
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 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:
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.
- Frame 1: the ring entered the tree and drew once.
- 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.
- Frame 5: the ring moved; it did not draw again, because a move reuses the drawing.
- Frames 6 and 7: the ring was hidden and its health changed; nothing was drawn.
- 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
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:
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() 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:
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.
- The arc starts at the top, at −90°, the point (0, −20).
- It runs clockwise, because +y points down in Godot, for 360° × 0.4 = 144°.
- 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
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:
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() 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
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.
- In a Godot 4.7 project, save
minimap.gdandcheck_minimap.gdfrom below. - Fix the squares, and make
set_player()redraw the map when the player’s cell changes, and only then. - In a terminal in the project’s folder, run
godot --headless --no-header --script check_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]) 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:
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.
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.
-
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.
-
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?
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_maskshares a bit with itsrange_item_cull_mask; a DirectionalLight2D lights everything. - A shadow needs the light’s
shadow_enabled, an occluder polygon and anoccluder_light_maskthat shares a bit with the light’sshadow_item_cull_mask. _draw()runs when the node first draws and then once per frame afterqueue_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 withblit_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
- 2D lights and shadows (Godot Engine)
- Light2D (class reference) (Godot Engine)
- PointLight2D (class reference) (Godot Engine)
- DirectionalLight2D (class reference) (Godot Engine)
- LightOccluder2D (class reference) (Godot Engine)
- CanvasModulate (class reference) (Godot Engine)
- Godot 4.7.2 source: canvas.glsl (how 2D lights add to a pixel) (Godot Engine)
- Godot 4.7.2 source: renderer_viewport.cpp (point lights need a texture) (Godot Engine)
- Godot 4.7.2 source: renderer_canvas_render_rd.cpp (light and shadow masks) (Godot Engine)
- Custom drawing in 2D (Godot Engine)
- CanvasItem (class reference): _draw(), queue_redraw() and the draw methods (Godot Engine)
- Using DrawableTextures (Godot Engine)
- DrawableTexture2D (class reference) (Godot Engine)
- ImageTexture (class reference): update() (Godot Engine)
- Godot 4.7 release notes: DrawableTexture2D (Godot Engine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress