Texel Space Quantization

The finest in gay furry technology, also taking inspiration from this snippet with the formatting. Mainly useful for faux-retro pixelated shadows. You can either copy these functions directly into your shader or separate them out as their own shader include.

// Calculate screen-space displacement from the center of the current texel
vec2 screen_delta(vec2 uv, sampler2D tex) {
	vec2 tex_size = vec2(textureSize(tex, 0));
	vec2 texel_center = (floor(uv * tex_size) + 0.5) / tex_size;
	vec2 offset = texel_center - uv;
	mat2 screen_to_uv = mat2(dFdx(uv), dFdy(uv));
	mat2 uv_to_screen = inverse(screen_to_uv);
	return uv_to_screen * offset;
}

// Snap vec_type to extrapolated value at the center of the current texel
float texel_snap(float v, vec2 delta) {
	return v + dFdx(v) * delta.x + dFdy(v) * delta.y;
}

vec2 texel_snap(vec2 v, vec2 delta) {
	return v + dFdx(v) * delta.x + dFdy(v) * delta.y;
}

vec3 texel_snap(vec3 v, vec2 delta) {
	return v + dFdx(v) * delta.x + dFdy(v) * delta.y;
}

vec4 texel_snap(vec4 v, vec2 delta) {
	return v + dFdx(v) * delta.x + dFdy(v) * delta.y;
}

To use these functions you first get a screen_delta from your UV coordinates and texture, then feed that into texel_snap along with whatever value you want to quantize.

vec2 delta = screen_delta(UV, albedo_map);
LIGHT_VERTEX = texel_snap(VERTEX, delta);
NORMAL = texel_snap(NORMAL, delta);

The shader code below uses my standard lighting shader include and celyk’s accumulation hack to create a posterized PBR effect.

Shader code
shader_type spatial;

#include "texel_snap.gdshaderinc"

// https://godotshaders.com/shader/standard-lighting-shader-include/
#define DIFFUSE_LAMBERT
#define SPECULAR_SCHLICK_GGX
#include "standard_lighting.gdshaderinc"

uniform int posterize_levels = 16;
uniform sampler2D albedo_map : source_color, filter_nearest_mipmap;
uniform sampler2D normal_map : hint_normal, filter_nearest_mipmap;
uniform float roughness : hint_range(0.0, 1.0) = 1.0;
uniform float metallic : hint_range(0.0, 1.0) = 0.0;
uniform vec2 uv_tiling = vec2(1.0);
uniform vec2 uv_offset = vec2(0.0);

void vertex() {
	UV = (UV + uv_offset) * uv_tiling;
}

void fragment() {
	// Snap LIGHT_VERTEX and NORMAL for pixelated shadows
	vec2 delta = screen_delta(UV, albedo_map);
	LIGHT_VERTEX = texel_snap(VERTEX, delta);
	NORMAL = texel_snap(NORMAL, delta);

	// Snap TANGENT and BINORMAL to support normal mapping
	TANGENT = texel_snap(TANGENT, delta);
	BINORMAL = texel_snap(BINORMAL, delta);
	
	ALBEDO = texture(albedo_map, UV).rgb;
	NORMAL_MAP = texture(normal_map, UV).xyz;
	ROUGHNESS = roughness;
	METALLIC = metallic;
}

void light() {
	vec3 d = vec3(0.0), s = vec3(0.0);
	STANDARD_LIGHTING(d, s);

	DIFFUSE_LIGHT += ALBEDO * d + s;
	float l = float(posterize_levels);
	SPECULAR_LIGHT = floor(DIFFUSE_LIGHT * l) / l;
	SPECULAR_LIGHT -= ALBEDO * DIFFUSE_LIGHT;
}
Live Preview
Tags
pixel, retro, texel space lighting, texture space
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from tentabrobpy

Related shaders

guest

26 Comments
Oldest
Newest Most Voted
Skooma_man
5 months ago

That is awesome! Been looking for something like this for a long time.

Here is this shader merged with a generic standardMaterial + normal map

// NOTE: Shader automatically converted from Godot Engine 4.5.1.stable.mono's StandardMaterial3D.


shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;


uniform vec4 albedo : source_color;
uniform sampler2D texture_albedo : source_color, filter_nearest_mipmap, repeat_enable;
uniform ivec2 albedo_texture_size;
uniform float point_size : hint_range(0.1, 128.0, 0.1);


uniform float roughness : hint_range(0.0, 1.0);
uniform sampler2D texture_metallic : hint_default_white, filter_nearest_mipmap, repeat_enable;
uniform vec4 metallic_texture_channel;
uniform sampler2D texture_roughness : hint_roughness_r, filter_nearest_mipmap, repeat_enable;


uniform float specular : hint_range(0.0, 1.0, 0.01);
uniform float metallic : hint_range(0.0, 1.0, 0.01);


uniform vec3 uv1_scale;
uniform vec3 uv1_offset;
uniform vec3 uv2_scale;
uniform vec3 uv2_offset;


void vertex() {
	UV = UV * uv1_scale.xy + uv1_offset.xy;
}


// Calculate screen-space displacement to the center of the current texel
vec2 screen_delta(vec2 uv, sampler2D tex) {
	vec2 tex_size = vec2(textureSize(tex, 0));
	vec2 texel_center = (floor(uv * tex_size) + 0.5) / tex_size;
	vec2 uv_delta = texel_center - uv;
	mat2 screen_to_uv = mat2(dFdx(uv), dFdy(uv));
	mat2 uv_to_screen = inverse(screen_to_uv);
	return uv_to_screen * uv_delta;
}


// Snap vector to the value at the center of the current texel
// Note this works for any vec type (float, vec2, vec3, vec4)
vec3 texel_snap(vec3 v, vec2 delta) {
	return v + dFdx(v) * delta.x + dFdy(v) * delta.y;
}


// Simple posterized lighting
void light() {
	#define levels 12.0
	float n_dot_l = clamp(dot(NORMAL, LIGHT), 0.0, 1.0);
	float i = n_dot_l * ATTENUATION;
	i = floor(i * levels) / levels;
	DIFFUSE_LIGHT += LIGHT_COLOR / PI * i;
}
uniform sampler2D normal_map: hint_normal;


void fragment() {
	vec2 base_uv = UV;


	vec4 albedo_tex = texture(texture_albedo, base_uv);
	ALBEDO = albedo.rgb * albedo_tex.rgb;
	vec2 delta = screen_delta(UV, texture_albedo);
	LIGHT_VERTEX = texel_snap(VERTEX, delta);
	NORMAL = texture(normal_map, UV).xyz;
	NORMAL = texel_snap(NORMAL, delta);
	


	ALBEDO = texture(texture_albedo, UV).rgb;


	float metallic_tex = dot(texture(texture_metallic, base_uv), metallic_texture_channel);
	METALLIC = metallic_tex * metallic;
	SPECULAR = specular;


	vec4 roughness_texture_channel = vec4(1.0, 0.0, 0.0, 0.0);
	float roughness_tex = dot(texture(texture_roughness, base_uv), roughness_texture_channel);
	ROUGHNESS = roughness_tex * roughness;
}


forgeworksdev
5 months ago

How does one apply this shader??

Alex
Alex
5 months ago

What is the STANDARD_LIGHTING function? And where can I find standard_lighting.gdshaderinc?

Axi
Axi
5 months ago

Is there a way to separate the texel size from UV side? I’m trying to not scale my texture, just the lighting “sub” texel to have smaller, pixelated lighting.

Axi
Axi
4 months ago
Reply to  tentabrobpy

Thank you, that put me on the right path.
What about if I wanted to rotate the texel grid off of the UV’s axis?
I’m trying to get the texel orientation aligned with the world xyz, or otherwise capable of manual rotation to accomplish that.

Purpbatboi2i
5 months ago

hm, per-texel eh.. i wonder if you could make it look like if its per-vertex

Ian
Ian
4 months ago

How would you adapt this to work with a triplanar texture?

VHDsdk
VHDsdk
4 months ago

hi, im quite a bit of a dumbass, could you perhaps make a step by step on how to implement this shader? if its not much of an inconvenience of course, thanks in advance

ghostsoft
ghostsoft
4 months ago

i just ran into this excellent shader after messing with the same effect myself and i’m wondering if i have ran into an actual bonafide floating point error

it’s almost impossible to see when not palettizing/posterizing but it shows up even in the preview gif on this page; occasionally a texel will have two different colors depending on view angle. i’ve tried to dive deep into this and figure out what’s going on and to my surprise on an unposterized output the error is still there but the difference in color is a single bit. half the texel might be #5d471c while the other half is #5d461c. this only becomes a problem when posterizing as those bit differences inevitably end up straddling the divide between colors

i’ve included a couple of images
comment image
comment image

after consulting some much smarter friends they concluded that it’s an accumulation error and they had no ideas how to fix this or if a fix is possible. what do you think? any ideas?

ghostsoft
ghostsoft
4 months ago
Reply to  tentabrobpy

you’re testing on a flat surface and i suspect that this is a problem that gets worse with curved surfaces/grazing angles

here’s a simple example with an empty scene, cylinder with the shader and a single omnilight with no shadows, you can see the effect in motion here by the texel “wiping” between two colors as the angle of the camera changes

comment image

without the posterization this would be impossible to notice since again the color difference is just 1 bit however with posterization it becomes very obvious in a complex scene

another example for good measure, you can see the “wiping” happening all over this

comment image

Last edited 4 months ago by ghostsoft
ghostsoft
ghostsoft
4 months ago
Reply to  tentabrobpy

now i’m really curious why you can’t replicate it… could it be because the mesh has a normal map? i’ll investigate this more but with the nature of the error being so incredibly tiny i’m still leaning towards floating points. the idea of clamping the light values is interesting too, for something this crunchy that could be an easy fix if it works!

if you’re interested in looking at this more i could easily make a MRP

ghostsoft
ghostsoft
4 months ago
Reply to  ghostsoft

damnit i think it really is the addition of a normal map that causes it

ghostsoft
ghostsoft
4 months ago
Reply to  tentabrobpy

oh gosh, i was resigned to this not being fixable and you solved it right quick! thank you thank you

just to be annoying i found there still A Weirdness that can very rarely happen and maybe this one is truly a floating point thing. it took me a few minutes of rotating to get this pic which shows how rare and hard to replicate it is and it’s not nearly as visually disruptive as the “wiping” but i figured i’d document it while i’m at it

comment image

it’s a similarly odd bit thing to the previous issue, here the original pre-posterized pixels are #404039 and #414139 respectively

ghostsoft
ghostsoft
4 months ago
Reply to  tentabrobpy

gotcha! i wonder, do mp4’s embed on here? e: nope! let’s try gif
https://i.imgur.com/GiHjVMh.mp4
comment image

i have gotten so tired of antialiasing i am now fully pro aliasing. thank you so much for making this possible!

Last edited 4 months ago by ghostsoft
mom0367
mom0367
26 days ago

For anyone who wants a UV scale independent of the light square size I’ve found that replacing the tiling size with a new size variable in all places except for the vec2 delta = screen_delta part works. This allowed me to resize the uv scale while keeping the light tiling unchanged.