Snow that settles on any sprite

Snow found from the sprite’s own alpha: it settles on heads, shoulders and ledges that face the sky, never on the sides, with a ragged inner edge so it never copies the outline. No extra art, no mask to paint.

 

Put it on a Sprite2D or an AnimatedSprite2D (every frame works) and set amount from 0 to 1; tween it to let the snow settle or thaw.

 

Settings:

  • amount: 0 none, 1 the full thickness.
  • coat_color, shade_color: the snow on top and the colour deep in it.
  • thickness_px: the depth at amount 1, in screen pixels, whatever the sprite’s scale.
  • raggedness: how uneven the inner edge is.
  • uv_flip: set x to -1 when the sprite uses flip_h (the free CoatLite2D node does it for you).

 

Works with the Compatibility renderer.

 

The free Coat 2D Lite adds a node with grow() and melt(); the full Coat 2D has 14 coats (moss, frost, rust, ice, mud…) and grass, flowers, icicles and drips that grow past the outline.

Shader code
shader_type canvas_item;
// Snow on any sprite, found from its alpha: a pixel is coated when the sprite ends within
// the snow's depth above it, and only on surfaces that face the sky. The inner limit is
// broken by noise at two scales, so the snow never copies the outline, and its edge is
// anti-aliased over one screen pixel.

uniform vec4 coat_color : source_color = vec4(0.95, 0.97, 1.0, 1.0);
uniform vec4 shade_color : source_color = vec4(0.62, 0.72, 0.9, 1.0);
uniform float amount : hint_range(0.0, 1.0) = 0.6;
uniform float thickness_px : hint_range(1.0, 32.0) = 14.0;   // depth of the coat at amount 1, in screen pixels
uniform float raggedness : hint_range(0.0, 1.0) = 0.5;       // how uneven the inner edge is
uniform vec2 uv_flip = vec2(1.0);                            // set by CoatLite2D: -1 on an axis the sprite mirrors
uniform bool overlay = false;                                // set by CoatLite2D: drawn as a layer over the sprite, so only the snow is output
const vec2 from_direction = vec2(0.0, -1.0);                 // the sky
const float grain = 0.1;
const float noise_scale = 1.0;

varying vec4 tint;
varying vec2 dir_local;   // from_direction turned into the sprite's own axes (rotated sprites)

float hash(vec2 p) {
	p = fract(p * vec2(0.3183099, 0.3678794)) * 17.0;
	return fract(p.x * p.y * (p.x + p.y));
}

float vnoise(vec2 p) {
	vec2 i = floor(p);
	vec2 f = fract(p);
	f = f * f * (3.0 - 2.0 * f);
	return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}

// how many steps of `step_uv` until the sprite ends (alpha below one half or out of the
// picture), up to max_steps; 99 when it does not end that close
float open_dist(sampler2D tex, vec2 uv, vec2 step_uv, float max_steps) {
	for (int k = 1; k <= 49; k++) {   // 32 px at amount 1, plus the deepest bump
		if (float(k) > max_steps) break;
		vec2 q = uv + step_uv * float(k);
		if (q.x < 0.0 || q.y < 0.0 || q.x > 1.0 || q.y > 1.0 || texture(tex, q).a < 0.5) {
			return float(k);
		}
	}
	return 99.0;
}

void vertex() {
	tint = COLOR;
	// from_direction is given on screen (0, -1: the sky): undo the node's rotation and
	// scale so a rotated or mirrored sprite still gets its coat from the sky
	mat2 m = mat2(MODEL_MATRIX[0].xy, MODEL_MATRIX[1].xy);
	dir_local = abs(determinant(m)) > 1e-6 ? inverse(m) * from_direction : from_direction;
}

void fragment() {
	vec4 c = texture(TEXTURE, UV);
	// texels per screen pixel: the coat keeps its thickness on screen whatever the scale
	// (below 1 when the sprite is magnified: pixel art, a close camera)
	float tpp = max(fwidth(UV.y) / TEXTURE_PIXEL_SIZE.y, 0.02);
	vec2 st = TEXTURE_PIXEL_SIZE * tpp;
	// as far as the deepest bump of a ragged edge (1.5 times the thickness), or the bumps
	// past the search would be cut flat
	float reach = thickness_px * amount * (1.0 + 0.5 * raggedness) + 1.0;
	float d = 99.0;
	float facing_k = 1.0;
	if (amount > 0.0 && c.a > 0.01) {
		{
			vec2 dir = length(dir_local) > 0.001 ? normalize(dir_local) * uv_flip : vec2(0.0, -1.0);
			d = open_dist(TEXTURE, UV, dir * st, reach);
			if (d < 99.0) {
				// a coat that falls (snow, dust) only settles on a surface that faces where it
				// comes from: at the edge it reached, the alpha gradient points into the
				// sprite; a steep side faces sideways and keeps almost none
				vec2 e = UV + dir * st * (d - 1.0);
				vec2 gx = vec2(st.x * 2.0, 0.0);
				vec2 gy = vec2(0.0, st.y * 2.0);
				vec2 grad = vec2(texture(TEXTURE, e + gx).a - texture(TEXTURE, e - gx).a,
						texture(TEXTURE, e + gy).a - texture(TEXTURE, e - gy).a);
				float facing = length(grad) > 0.01 ? dot(-normalize(grad), dir) : 1.0;
				// the slope of the edge from two more rays, 3 screen pixels to each side: on a
				// steep side drawn in pixel steps the gradient above sees each step as facing
				// up; the edge height changing fast between the rays does not
				vec2 side = vec2(-dir.y, dir.x) * st * 3.0;
				float dl = open_dist(TEXTURE, UV - side, dir * st, reach + 6.0);
				float dr = open_dist(TEXTURE, UV + side, dir * st, reach + 6.0);
				float slope = (dl < 99.0 && dr < 99.0) ? (dr - dl) / 6.0 : 99.0;
				facing = min(facing, inversesqrt(1.0 + slope * slope));
				facing_k = smoothstep(0.35, 0.8, facing);
			}
		}
	}
	// the bumps are fixed on the picture (in its own pixels), so they do not crawl when the
	// sprite breathes, zooms or turns
	vec2 sp = UV / TEXTURE_PIXEL_SIZE / noise_scale;
	// bumps about 15 picture pixels wide with finer detail on them; shaped so that a ragged
	// coat swings from a thin film to well past its thickness, never an even band
	float n = vnoise(sp * 0.065) * 0.7 + vnoise(sp * 0.22) * 0.3;
	float lumps = 0.15 + 1.35 * smoothstep(0.2, 0.8, n);
	float limit = thickness_px * amount * mix(1.0, lumps, raggedness) * facing_k;
	// one screen pixel of soft edge, measured in steps. The sprite's own soft rim (alpha
	// under one half) is coated too, or its dark fringe shows as a line between the coat
	// and what is behind the sprite; it keeps its alpha, so the outline stays smooth
	float on = clamp(limit - d + 0.5, 0.0, 1.0);
	if (c.a < 0.5) {
		// only the rim that touches the body: a soft glow or a baked shadow farther out stays as drawn
		// (one and a half picture pixels, or one screen pixel on a sprite drawn smaller than its
		// picture: a fringe a screen pixel wide would otherwise stay as a dark line)
		vec2 tx = max(TEXTURE_PIXEL_SIZE * 1.5, st * 1.2);
		float body = max(max(texture(TEXTURE, UV + vec2(tx.x, 0.0)).a, texture(TEXTURE, UV - vec2(tx.x, 0.0)).a),
				max(texture(TEXTURE, UV + vec2(0.0, tx.y)).a, texture(TEXTURE, UV - vec2(0.0, tx.y)).a));
		on *= step(0.5, body);
	}
	float inner = clamp(d / max(limit, 1.0), 0.0, 1.0);
	vec3 col = mix(coat_color.rgb, shade_color.rgb, inner * 0.7);
	col *= 1.0 - grain * 0.35 * step(0.82, hash(floor(sp * 1.5)));
	if (overlay) {
		// the sprite is drawn under this layer with its own material (a petrify, a hit flash)
		COLOR = vec4(col, on * c.a) * tint;
	} else {
		COLOR = vec4(mix(c.rgb, col, on), c.a) * tint;
	}
}
Live Preview
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.

More from DimensionAlmios

Related shaders

Comments 0

No comments yet.