Drop-in PBR 2d lighting system with soft shadows and ambient occlusion

Relatively basic shader that’s pretty much a drop-in with the existing godot 2d lighting system. Adds relatively cheap and significantly better looking soft shadows, a much better PBR system with zero additional textures, as well as removing the need to make annoying light textures– all distance fade is calculated in-shader.

Usage:

– Uniforms:

  – light_count: Only use this if you _really_ need to– use_directional_light_as_ambient allows for using directional lights as the source of ambient light, eliminating the need for this. If you can’t do that for whatever reason, this will allow ambient occlusion to remain constant, even when the number of lights change.

  – use_directional_light_as_ambient: If true, only directional lights will be used for ambient light. This allows for any number of small light sources, without changing the ambient amount; I HIGHLY recommend using it.

  – apply_distance_fade: Whether to apply distance fading to lights

 – object_height: The height of the object. Mostly changes distance fade and normals.

  – shadows: Whether to use shadows at all

  – soft_shadows: Whether shadows should be hard or soft

  – light_radius: Radius of all lights in the scene, in pixels. Affects soft shadows.

  – shadow_march_count: Number of march steps to perform. Default to 256, lower values will have higher performance but risk visual glitches.

  – shadow_march_min_step: Minimum distance of a single march step. Default to 0.05, I don’t recommend touching this much– it can improve clarity if combined with an increase in march count, or increase performance if increased by reducing the number of required steps, but carries a lot of potential for visual bugs.

  – shadow_march_step_scale: Another thing I don’t recommend touching. Default to 1.0, this will scale up/down the distance of each march– lower values will increase shadow quality slightly, but will require a *lot* of increase to shadow_march_count if you want to avoid visual glitches. Higher values will improve performance by reducing the number of required steps, but has a very significant chance of causing light leak.

  – ambient_amount: The value of “ambient” lighting, which is used to approximate global illuminations. Higher values will increase the “minimum” light levels.

  – ambient_occlusion: Whether or not to apply ambient occlusion. For the unfamiliar, this applies simple shading to simulate how much indirect light (or ambient light) should be obscured by surrounding objects. Disabling this increases performance slightly, but not by a massive amount.

  – ao_step_count: Ambient occlusion step count. Increases the radius and quality of the ambient occlusion; higher values should be paired with lower step sizes. Default to 6.

  – ao_step_size: Ambient occlusion step size, in pixels. Increases the radius of the ambient occlusion. Default to 20.

  – ao_influence: Ambient occlusion, by default, can obscure all indirect lighting; however, this allows that to be scaled. For example, a value of 0.5 would mean that as much as half of the indirect lighting can be removed. In practice, I find this looks a lot better than alternative methods. Default to 0.3.

– Texturing

  – For regular textures, the default system can be used– nothing special there.

  – The same applies to normal maps, the default system can be used with no changes.

  – I’ve done a bit of tweaks to specular maps. Rather than using them as simply the color of specular highlights, I’ve instead used them for all material properties; as a result, you can make *very* detailed materials, relatively easily.

    – R: The red channel represents how much “diffuse” lighting there should be. Diffuse lighting means any lighting that reflects uniformly off a surface; think walls, or paper.

    – G: The green channel represents how shiny “specular” lighting there should be. Specular lighting is lighting that is reflected; think plastic, or metal. Values closer to 0 will mean the specular highlight is “rougher”, indicating a surface that’s less shiny.

    – B: The blue channel represents how “metallic” the surface is. In this case, I’m modelling metallic very simply; generally speaking, metals will reflect light with the same color as the metal in its specular highlight, while things like tomatoes or plastic tends to reflect white light int its specular highlight. The metallic slider simply scales between white specular reflections at 0, to reflections the color of the material at 1.

    – A: This is a shame, and I really don’t know why, but this is only adjustable through the “shininess” slider on the specular section. I’ve set it up to simply represent how much specular highlight there should be; unfortunately, this can’t be variable along the texture. Note that, if needed, you can use a value of 1 in the G channel in order to disable specular highlight without this (by making the highlight infinitely small).

That’s pretty much it for usage!

Limitations:

– For now, DirectionalLight2D is not supported. This is because I have literally no clue how to make it look good.

– Lighting with a non-uniform output (I.E. a flashlight) still require a texture to help the shader know the bounds.

– Individual lights can’t have individual settings, such as radius; unfortunately, there’s just not very much available to me in the light() function for each light. I could create a global array uniform, but I find that clunky, so for now I’m using this simplified system– if enough people request, I might make a global array option.

Recommendations:
In project settings->rendering->2d, set SDF scale and oversize essentially as high as possible while still maintaining your performance target; this will significantly improve shadow quality.

Acknowledgements:

A lot of the soft shadow code was based on IQ’s incredible tutorial, which I highly recommend (as well as all of his other work) to anyone learning ray marching.

The AO code is based on a screenshot I found in a discord server; I’m not sure who the original author is, but if they light me know who they are I will add credit immediately.

Shader code
shader_type canvas_item;

// I hate that this is required, but godot has forced my hand-- see https://github.com/godotengine/godot-proposals/discussions/9354
uniform int light_count;

uniform float light_falloff;

uniform bool apply_distance_fade;
uniform float object_height;

uniform bool shadows;
uniform bool soft_shadows;
uniform float light_radius;
uniform uint shadow_march_count;
uniform float shadow_march_min_step;
uniform float shadow_march_step_scale;

uniform float ambient_amount : hint_range(0,1);
uniform bool use_directional_light_as_ambient;
uniform bool ambient_occlusion;
uniform uint ao_step_count;
uniform float ao_step_size;
uniform float ao_influence : hint_range(0.0, 1.0);

varying mat4 inv_canvas_matrix;
varying mat4 canvas_matrix;

varying vec2 screen_pix_size;
varying vec2 world_pos;

varying vec2 screen_offset;
varying vec2 screen_scale;

// We can't avoid applying AO several times, but at least we can only
// calculate it once
varying float ao;

// Beckmann distribution
float kSpec(vec3 N, vec3 H, float m) {
	float alpha = acos(dot(N,H));
	// alpha = max(abs(alpha) - angleTolerance, 0.0) * sign(alpha);
	float mSquared = m * m;
	return (exp(-pow(tan(alpha) / mSquared, 2.0)) / (PI * mSquared * pow(cos(alpha), 4.0)));
}
 
void vertex() {
	// whyyyy can't I have the matrices in fragment() or light()
	inv_canvas_matrix = inverse(CANVAS_MATRIX);
	canvas_matrix = CANVAS_MATRIX;
	// Saves a bit of time later
	world_pos = VERTEX;
	
	// I hate this but it's the only decent way I've found to get zoom in a canvas_shader
	vec2 minP = (vec4(vec2(0), 0, 1) * inverse(CANVAS_MATRIX)).xy;
	vec2 maxP = (vec4(100,100, 0, 1) * inverse(CANVAS_MATRIX)).xy;
	screen_scale = (maxP - minP) / 100.0;
}

void fragment() {
	// whyyyyy can't I have SCREEN_PIXEL_SIZE in vertex() or light()
	screen_pix_size = SCREEN_PIXEL_SIZE; 
	// When we apply the inverse CANVAS_MATRIX to a canvas coordinate, we won't be applying
	// offset-- this calculates how much we're offsetting, because the world space coordinates
	// from vertex() are accurate
	vec2 world_pos_without_offset = (vec4(VERTEX, 0, 1) * inv_canvas_matrix).xy;
	screen_offset = world_pos_without_offset - world_pos;
	
	ao = 1.0;
	if(ambient_occlusion) {
		// Ambient occlusion based on some random stuff I found on discord .-.
		vec2 marchPos = FRAGCOORD.xy;
		vec2 Epsilon = vec2(2.0, 0.0);
		// These normals are horrible but they seem to work well enough
		vec2 norm = texture_sdf_normal(screen_uv_to_sdf(SCREEN_UV));
		float res = 0.0;
		vec2 rp;
		// TBH I like vaguely understand how this works but not enough to explain it
		for(uint i = uint(1); i<ao_step_count; i++) {
			rp = marchPos + ao_step_size*float(i)*norm * screen_pix_size;
			res+=(1.0/pow(2.0,float(i)))*(ao_step_size*float(i)-(texture_sdf(screen_uv_to_sdf(rp * screen_pix_size))));
		}
		// We need an influence scaler bc otherwise I've found it to look rlly weird
		// That's probably just a skill issue though
		if(res >= 0.0) {
			ao = 1.0 - ao_influence * clamp(1.0 - ao_step_size / res, 0.0, 1.0);
		}
	}
}

vec2 UVtoWorld(vec2 uv) {
	return (vec4((uv) / screen_pix_size, 0, 1) * inv_canvas_matrix).xy - screen_offset;
}

vec2 worldToUV(vec2 coord) {
	return (vec4((coord + screen_offset) * screen_pix_size, 0, 1) * canvas_matrix).xy;
}

void light() {
	// We can figure out how much the distances should be scaled by figuring out the difference
	// between a screenspace distance of 1px and a worldspace distanc of those points. We also
	// need to include our scaling factor, again bc everything breaks if we don't
	vec2 worldspaceScaleCalcA = UVtoWorld(vec2(0));
	vec2 worldspaceScaleCalcB = UVtoWorld((1.0 / screen_scale ) * screen_pix_size);
	float worldspaceDistanceScale = distance(vec2(0), vec2(screen_scale)) / distance(worldspaceScaleCalcA, worldspaceScaleCalcB);
	
	// Soft shadows based on https://iquilezles.org/articles/rmshadows/
	vec2 marchPos = world_pos;
	vec2 lightPos = UVtoWorld(LIGHT_POSITION.xy * screen_pix_size);
	vec2 toLight = normalize(lightPos - marchPos);

	vec2 screenPos = LIGHT_VERTEX.xy;
	vec2 lightPosScreen = LIGHT_POSITION.xy;
	vec2 toLightScreen = normalize(lightPosScreen - screenPos);
	// We need to scale this by our scale factor because if we don't, everything breaks.
	// We also need this to respect x and y, hence not just multiplying by the length.
	float distToLight = distance(vec2(0), (lightPos - marchPos) * screen_scale);
	
	// Step distance, total distance
	float d, t = 0.0;
	// Obscurity - basically, how obscured our ray is
	float obs = 1.0;
	
	float lightDistObj = texture_sdf(screen_uv_to_sdf(lightPosScreen * screen_pix_size));
	
	float posOffset = min(light_radius / 2.0, distToLight / worldspaceDistanceScale / 2.0);
	if(lightDistObj < 0.0) posOffset = min(posOffset, lightDistObj + light_radius);
	
	lightPos -= toLight * posOffset;
	marchPos += toLight * posOffset;
	float distToLightForCheck = distance(vec2(0), (lightPos - marchPos) * screen_scale);
	
	// Ideally I'd make this its own function but texture_sdf has forced my hand
	if(shadows && !LIGHT_IS_DIRECTIONAL) {
		obs = 0.0;
		for(uint i = uint(0); i < shadow_march_count; i++) {
			// texture_sdf returns screenspace distance, but we want world space distance
			d = texture_sdf(screen_uv_to_sdf(screenPos * screen_pix_size)) * worldspaceDistanceScale;
			// If we aren't using soft shadows, there's no reason to march through objects
			if(!soft_shadows && d <= 0.0001) {
				obs = 1.0;
				break;
			}
			// I don't entirely understand how this works, but the gist is that we can get a very good looking
			// soft shadow effect by storing how close the ray got to an obstacle
			if(soft_shadows) obs = max(0.5 - length(1.0 / screen_scale) * d * distToLight / (2.0 * light_radius * t), obs);
			// If we're in shadow no need to continue
			if(obs > 0.99) break;
			// Prevent getting stuck on the border of objects
			// 0.1 is a number I pulled out of my ass but it seems to work well enough
			// light_radius*t/distToLight is the relative size of the light or something idk I'm dumb
			d = max(d * shadow_march_step_scale, screen_scale.x *  shadow_march_min_step  * light_radius*t/distToLight);
			t += d;
			// If we pass the light we know we're done
			if(t >= distToLightForCheck) break;
			marchPos += d * toLight;
			// To be frank, I have no idea why we need to divide by scale here, but it works
			screenPos += (d / worldspaceDistanceScale) * toLightScreen / screen_scale;
		}
		// Make sure we don't have any weird values lol
		obs = 1.0 - clamp(obs, 0.0, 1.0);
	}
	
	if(!use_directional_light_as_ambient || LIGHT_IS_DIRECTIONAL) LIGHT = COLOR * ambient_amount * dot(NORMAL, vec3(0,0,1)) * ao / float(light_count);
	if(!LIGHT_IS_DIRECTIONAL) {
		// Finally get the world distance (including height) for the final magnitude
		vec2 worldPos = UVtoWorld(SCREEN_UV);
		vec3 worldPosLight = vec3(UVtoWorld(LIGHT_POSITION.xy * screen_pix_size), LIGHT_POSITION.z * screen_scale.x);
		vec3 worldDelta = worldPosLight - vec3(worldPos, object_height);
		float worldDist = distance(vec3(0), worldDelta);
		
		// PBR Lighting
		float roughness = SPECULAR_SHININESS.r;
		float specular = 1.0 - SPECULAR_SHININESS.g;
		float metallic = SPECULAR_SHININESS.b;
		
		float distance_fade = 1.0 / pow(worldDist / (light_falloff) + 1.0, 2.0);
		
		vec4 specularColor = mix(vec4(1), COLOR, metallic);
		vec4 specularAddition = specularColor * LIGHT_COLOR * LIGHT_ENERGY * max(kSpec(NORMAL, (LIGHT_DIRECTION + vec3(0,0,1)) * 0.5, specular), 0.0) * obs;
		vec4 diffuseAddition = roughness * LIGHT_COLOR * LIGHT_ENERGY * COLOR * max(dot(NORMAL, LIGHT_DIRECTION), 0.0) * obs;
		if(apply_distance_fade) {
			specularAddition *= distance_fade;
			diffuseAddition *= distance_fade;
		}
		LIGHT += specularAddition + diffuseAddition;
	}
	LIGHT.a = 1.0;
}
Tags
2d, ambient occlusion, AO, canvas item, canvas_item, lighting, path tracing, ray marching, ray tracing, shadows, Soft shadows
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

Dynamic 2D Lights and Soft Shadows

Smooth outline 2D with enable disable system

Ramp Lighting

Subscribe
Notify of
guest

7 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Jesper_J
3 months ago

This looks great. Thanks for all the documentation and clarity about the limitations you had to work around, I will experiment with using this.

Rangal
Rangal
2 months ago

Does this allow for limited height/distance shadows?

Echoless
Echoless
1 month ago

Hey there, does this work with Tilemap/Tileset nodes?