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;
}
}


Comments 0
No comments yet.