2D Retro Dithered Lighting & Fog-of-War
Disclaimer: it’s not my strongest technical topic; therefore, I know it can be done in more efficient ways. It does work well for my game use case. I tried to document as much as possible, and there’s also a demo project on my GitHub. Feel free to improve!
How does it work?
Samples the rendered scene and re-renders it according to how much light reaches each pixel. Light data is fed in every frame by LightingManager as screen-space arrays. The retro look comes from ordered dithering of the light value.
Add it to a full-screen ColorRect inside a CanvasLayer.
Set up the LightingManager, the lighting component, and the occluders as needed. Code below or inside the demo project!
I use this shader for a hard mode setting in my game levels, and it performs really well with lots of projectiles, particles, and all the juice a game needs. The second screenshot is from the demo project.
LightingManager
class_name LightingManager
extends CanvasLayer
## Drives the custom lighting / fog-of-war overlay.
## Drop this to a node anywhere. Give it a ColorRect child. Every frame
## this manager the scene for nodes in the LIGHT_GROUP / OBSTRUCTOR_GROUP, converts
## their world positions into viewport-pixel coordinates, and feeds them to the overlay shader.
const MAX_LIGHTS := 48
const MAX_OBSTRUCTORS := 24
const LIGHT_GROUP := "tdl_light"
const OBSTRUCTOR_GROUP := "tdl_obstructor"
@export_group("Look")
@export var ambient: float = 0.05
@export var darkness_color: Color = Color(0.02, 0.02, 0.06)
@export_range(1.0, 16.0) var dither_levels: float = 6.0
@export_range(0.0, 1.0) var softness: float = 0.30
@export_range(0.3, 3.0) var light_curve: float = 1.6
@export_range(1.0, 6.0) var dither_pixel_size: float = 2.0
@export_range(0.0, 1.0) var light_glow: float = 0.15
@export var occlusion_enabled: bool = true
@export_group("Wiring")
@export var color_rect: ColorRect
var _mat: ShaderMaterial
var _positions := PackedVector2Array()
var _radii := PackedFloat32Array()
var _colors := PackedVector3Array()
var _intensities := PackedFloat32Array()
var _o_positions := PackedVector2Array()
var _o_radii := PackedFloat32Array()
func _ready() -> void:
if color_rect == null:
color_rect = get_node_or_null("ColorRect")
if color_rect == null or color_rect.material == null:
push_warning("LightingManager: no ColorRect with a ShaderMaterial assigned.")
set_process(false)
return
_mat = color_rect.material as ShaderMaterial
_positions.resize(MAX_LIGHTS)
_radii.resize(MAX_LIGHTS)
_colors.resize(MAX_LIGHTS)
_intensities.resize(MAX_LIGHTS)
_o_positions.resize(MAX_OBSTRUCTORS)
_o_radii.resize(MAX_OBSTRUCTORS)
_apply_look()
func _apply_look() -> void:
_mat.set_shader_parameter("ambient", ambient)
_mat.set_shader_parameter("darkness_color", darkness_color)
_mat.set_shader_parameter("dither_levels", dither_levels)
_mat.set_shader_parameter("softness", softness)
_mat.set_shader_parameter("light_curve", light_curve)
_mat.set_shader_parameter("dither_pixel_size", dither_pixel_size)
_mat.set_shader_parameter("light_glow", light_glow)
_mat.set_shader_parameter("occlusion_enabled", occlusion_enabled)
func _process(_delta: float) -> void:
var vp := get_viewport()
if vp == null:
return
var ct := vp.get_canvas_transform()
var scale: float = ct.get_scale().x
var vp_size := vp.get_visible_rect().size
var count := 0
for n in get_tree().get_nodes_in_group(LIGHT_GROUP):
if count >= MAX_LIGHTS:
break
if not is_instance_valid(n) or not n.enabled:
continue
var node2d := n as Node2D
if node2d == null:
continue
var pos: Vector2 = ct * node2d.global_position
var sr: float = max(n.radius * scale, 1.0)
if pos.x + sr < 0.0 or pos.x - sr > vp_size.x \
or pos.y + sr < 0.0 or pos.y - sr > vp_size.y:
continue
_positions[count] = pos
_radii[count] = sr
_colors[count] = Vector3(n.color.r, n.color.g, n.color.b)
var flick: float = 1.0 - n.flicker * randf()
_intensities[count] = n.intensity * flick
count += 1
_mat.set_shader_parameter("light_count", count)
_mat.set_shader_parameter("light_positions", _positions)
_mat.set_shader_parameter("light_radii", _radii)
_mat.set_shader_parameter("light_colors", _colors)
_mat.set_shader_parameter("light_intensities", _intensities)
var o_count := 0
for n in get_tree().get_nodes_in_group(OBSTRUCTOR_GROUP):
if o_count >= MAX_OBSTRUCTORS:
break
if not is_instance_valid(n) or not n.enabled:
continue
var node2d := n as Node2D
if node2d == null:
continue
var pos: Vector2 = ct * node2d.global_position
var sr: float = max(n.radius * scale, 1.0)
# Cull off-screen obstructors (they can't shadow visible pixels).
if pos.x + sr < 0.0 or pos.x - sr > vp_size.x \
or pos.y + sr < 0.0 or pos.y - sr > vp_size.y:
continue
_o_positions[o_count] = pos
_o_radii[o_count] = sr
o_count += 1
_mat.set_shader_parameter("obstructor_count", o_count)
_mat.set_shader_parameter("obstructor_positions", _o_positions)
_mat.set_shader_parameter("obstructor_radii", _o_radii)
LightComponent
class_name LightComponent
extends Node2D
## Add as a child of anything that should illuminate the scene (the player, a
## lamp, a glowing pickup, a light shaft).
const LIGHT_GROUP := "tdl_light"
## Light reach in world pixels (matches game-world units).
@export var radius: float = 120.0
## Tint of the emitted glow. White keeps the scene's own colors.
@export var color: Color = Color(1.0, 1.0, 1.0)
## Brightness multiplier. ~1.0 = solid lit core; lower for dim sources.
@export var intensity: float = 1.0
## 0 = steady. >0 randomly dims the light each frame (torch/fire feel).
@export_range(0.0, 1.0) var flicker: float = 0.0
## Toggle without removing the node.
@export var enabled: bool = true
func _enter_tree() -> void:
add_to_group(LIGHT_GROUP)
func _exit_tree() -> void:
remove_from_group(LIGHT_GROUP)
LightObstructor
class_name LightObstructor
extends Node2D
## Add as a child of anything that should cast shadows / block light (walls,
## crates, big rocks).
const OBSTRUCTOR_GROUP := "tdl_obstructor"
## Blocking radius in world pixels.
@export var radius: float = 40.0
## Toggle without removing the node.
@export var enabled: bool = true
func _enter_tree() -> void:
add_to_group(OBSTRUCTOR_GROUP)
func _exit_tree() -> void:
remove_from_group(OBSTRUCTOR_GROUP)
Shader code
shader_type canvas_item;
render_mode unshaded;
const int MAX_LIGHTS = 48;
const int MAX_OBSTRUCTORS = 24;
uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_nearest;
uniform int light_count = 0;
uniform vec2 light_positions[MAX_LIGHTS];
uniform float light_radii[MAX_LIGHTS];
uniform vec3 light_colors[MAX_LIGHTS];
uniform float light_intensities[MAX_LIGHTS];
uniform int obstructor_count = 0;
uniform vec2 obstructor_positions[MAX_OBSTRUCTORS];
uniform float obstructor_radii[MAX_OBSTRUCTORS];
// Look & feel
uniform float ambient : hint_range(0.0, 1.0) = 0.05;
uniform vec3 darkness_color : source_color = vec3(0.02, 0.02, 0.06);
uniform float dither_levels : hint_range(1.0, 16.0) = 6.0;
uniform float softness : hint_range(0.0, 1.0) = 0.30;
uniform float light_curve : hint_range(0.3, 3.0) = 1.6;
uniform float dither_pixel_size : hint_range(1.0, 6.0) = 2.0;
uniform float light_glow : hint_range(0.0, 1.0) = 0.15;uniform bool occlusion_enabled = true;
float bayer2(vec2 a) {
a = floor(a);
return fract(a.x * 0.5 + a.y * a.y * 0.75);
}
float bayer4(vec2 a) {
return bayer2(0.5 * a) * 0.25 + bayer2(a);
}
float bayer8(vec2 a) {
return bayer4(0.5 * a) * 0.25 + bayer4(a);
}
float seg_dist(vec2 p, vec2 a, vec2 b) {
vec2 ab = b - a;
float t = clamp(dot(p - a, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0);
return distance(p, a + ab * t);
}
// Returns 1.0 if the light reaches frag, 0.0 if an obstructor blocks the ray.
float visibility(vec2 frag, vec2 lpos) {
if (!occlusion_enabled) {
return 1.0;
}
for (int i = 0; i < MAX_OBSTRUCTORS; i++) {
if (i >= obstructor_count) {
break;
}
if (distance(frag, obstructor_positions[i]) < obstructor_radii[i]) {
continue;
}
if (seg_dist(obstructor_positions[i], frag, lpos) < obstructor_radii[i]) {
return 0.0;
}
}
return 1.0;
}
void fragment() {
vec2 frag = FRAGCOORD.xy;
vec3 scene = texture(screen_tex, SCREEN_UV).rgb;
float lit = ambient;
vec3 glow = vec3(0.0);
for (int i = 0; i < MAX_LIGHTS; i++) {
if (i >= light_count) {
break;
}
float r = max(light_radii[i], 1.0);
float d = distance(frag, light_positions[i]);
float falloff = 1.0 - smoothstep(r * softness, r, d);
if (falloff <= 0.0) {
continue;
}
falloff *= visibility(frag, light_positions[i]);
float contrib = falloff * light_intensities[i];
lit += contrib;
glow += light_colors[i] * contrib;
}
lit = clamp(lit, 0.0, 1.0);
lit = pow(lit, light_curve);
vec2 dcoord = floor(FRAGCOORD.xy / max(dither_pixel_size, 1.0));
float bayer = bayer8(dcoord);
float banded = clamp(floor(lit * dither_levels + bayer) / dither_levels, 0.0, 1.0);
vec3 outc = mix(darkness_color, scene, banded);
outc += glow * banded * light_glow;
COLOR = vec4(outc, 1.0);
}

