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);
}
Live Preview
Tags
dither, fog of war, retro
The shader code and all code snippets in this post are under MIT license and can be used freely. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

guest

0 Comments
Oldest
Newest Most Voted