2D Lighting w/Limited Color Palette

Godot Version

4.3.stable

Question

I’m working on a 2D project that uses a limited 16-color palette that can be swapped out in different areas of the game to create different moods (e.g. one area uses mostly blues, one is mostly reds). I’m looking to add some simple lighting effects while retaining the limited palette, which is turning out to be a bit more difficult than I expected. I’m still relatively new to shaders, so maybe someone will be able to offer an approach I hadn’t considered.

  • Our sprites are greyscale, and we use the brightness of each pixel (really just the red channel) to sample a palette to get the resulting color. Up to this point, we have used constant-interpolation Gradient Resources with 16 stops for our palettes as it made editing colors in-engine easy.
  • To facilitate lighting, I changed the palette from a Gradient Resource to an image where the x-axis is the base color, and the y-axis is the light level—basically, each color in the palette has its own stepped lighting ramp. All the colors in the lighting ramp are still from the palette, so my hope was to be able to do something in a fragment shader like this: COLOR.rgb = texture(palette, vec2(COLOR.r, light_level)).rgb
  • I tried using Light2D Nodes, and I was able to get it looking correct using the light pass in my shader (Approach 1 below), but when multiple lights overlapped, the resulting color would end up outside the palette, since the LIGHT values were being added together. I also tried using mix mode but ran into similar issues. What would be great would be a way to pass a varying from the light pass back to the fragment shader or something so I could add up the light from all light sources first, then sample the correct color from the palette, but that doesn’t seem possible, unless I’m missing something…
  • I also tried using a custom solution based partially on this tutorial (Approach 2 below). I have a ColorRect overlaid on the whole screen that basically acts as a post-processing filter to apply lighting. This does properly allow for overlapping lights and for the palette to be retained. There are some limitations to this, though: Godot’s built-in 2D lighting system is completely ignored, instead using a custom light script that interacts with the shader through a LightingManager singleton. Due to this, lights can only be circular in shape and many of the features of the Light2D Nodes aren’t available (this is mostly OK). Normal maps can’t be used since I don’t have access to the data of individual sprites, just the screen color. It’s also just a bit of a finnicky system and needs some infrastructure in the editor for it to not be a pain to work with, which is something I can do, but I’d rather avoid reinventing stuff if it isn’t necessary.

Ultimately, I’d love to be able to just use the regular Light2D Nodes. If anyone knows a way to use those while keeping to a palette that’d be a huge help.

Code and images below:

Approach 1: Sprite shader — the sprites are shaded using Light2D Nodes and a custom shader.
Sprite shader

shader_type canvas_item;

// Increment for sampling palette (1/16 -- it is a 16x16 grid)
const float INCREMENT = 0.0625;

uniform sampler2D palette : filter_nearest;

varying float source_r;

void fragment() {
	// Sample palette using input red value
	source_r = COLOR.r;
	COLOR.rgb = texture(palette, vec2(source_r, 0.0)).rgb;
}

void light() {
	float light_atten = LIGHT_COLOR.a * LIGHT_ENERGY * (LIGHT_COLOR.r * 2.0 - 1.0);
	light_atten = clamp(light_atten, -1.0, 1.0);
	// Sample light ramp from palette
	float light_uv_center = INCREMENT * 8.5;
	float light_ramp_pos = light_uv_center + (light_atten * INCREMENT * 7.0);
	vec3 result_color = texture(palette, vec2(source_r, light_ramp_pos)).rgb;
	// Additive light blending
	LIGHT = vec4(result_color - COLOR.rgb, 1.0);
}

Result of above shader (the overlapping areas’ colors are not in the palette)

Using mix mode instead (LIGHT = vec4(result_color, 1.0); — the light value is replaced within the light’s bounds. I understand this happens because I’m mixing with an alpha value of 1, but if I used a different value, then the color would be incorrect throughout the lit parts… maybe I’m missing something obvious??)

Palette, base colors are on the top row, with lighting ramps below each color
default_palette_lighting

Approach 2: Post-processing — unlike approach 1, the sprites here remain greyscale but have a ColorRect overlaid to handle the lighting and palette sampling for the whole screen.
Lighting post-processing shader

shader_type canvas_item;

// Increment for sampling palette (1/16 -- it is a 16x16 grid)
const float PALETTE_INC = 0.0625;
// The vertical size of the lighting ramp (15/16 -- the top row is base colors)
const float LIGHTING_SIZE = 0.9375;

uniform sampler2D screen_texture : hint_screen_texture, filter_nearest;
uniform sampler2D palette : filter_nearest;
uniform float ambient_light : hint_range(-1.0, 1.0, 0.0625) = 0.0;
uniform vec4 lights[32]; // (x, y, radius, strength)
uniform int light_count;

varying vec2 world_position;

// Get strength of light at a position
float point_lights(vec2 world_pos) {
	float light_level = 0.0;
	for (int i = 0; i < light_count; i++) {
		vec4 light = lights[i];
		float light_dist = distance(world_pos, light.xy);
		float light_radius = light.z;
		float light_strength = light.w;
		// Kind of hacky
		float attenuation = 10.0 / (1.0 + light_dist);
		attenuation *= step(light_dist, light_radius);
		attenuation *= light_strength;
		light_level += attenuation;
	}
	return clamp(light_level * 0.5, -1.0, 1.0);
}

void vertex() {
	world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0, 1.0)).xy;
}

void fragment() {
	vec2 screen_pos = SCREEN_UV;
	vec4 screen_color = texture(screen_texture, screen_pos);
	
	// Light level is from 0-1
	float light_level = ambient_light * 0.5 + 0.5;
	light_level += point_lights(world_position);
	light_level = clamp(light_level, 0.0, 1.0);
	
	// Sample palette by screen color (for base color) and light level.
	vec2 palette_uv = vec2(screen_color.r, 
		PALETTE_INC + (light_level * LIGHTING_SIZE)
	);
	COLOR = texture(palette, palette_uv);
}

Result of above shader

Personally, the thing I think you’re missing is it still looks pretty damn awesome. My thought is you may be too close to the project and have a myopic view. (Can’t see the forest for the trees.) I would step back and let someone with fresh eyes play the levels with the lights as is and see if they even notice it. Because while I think it’s awesome you have this artistic vision, I have a feeling only you are going to notice this as an issue.

I just finished a game jam where I made a 3D game look like a 2D side-scroller on a CRT. My play tester told me the CRT effect was WAY too much. So I dialed it down. I got feedback from 66 people in the jam, and most people loved the effect, but a few people said it was too distracting. Point is, what I thought of as “realistic” was too much realism and if I hadn’t toned it down people would’ve complained more.

Having said that, maybe someone will come along with a solution for you. I just think it already looks pretty awesome.