Files
Papay-Shooter/assets/shaders/ink_edge.gdshader
T
2026-08-02 02:20:02 -04:00

260 lines
12 KiB
Plaintext

shader_type spatial;
render_mode unshaded, fog_disabled, depth_draw_never, depth_test_disabled, cull_disabled;
// ── The 3D-to-2D pass ────────────────────────────────────────────────────────
//
// One fullscreen quad doing three jobs, in this order:
//
// sky repaint the background, because this pass owns those pixels
// ink screen-space line work from the depth buffer
// grade the anime split-tone
//
// ## Lines come from a SECOND DIFFERENCE of linearised depth
//
// This is the change that matters, and it is worth spelling out because the
// previous version of this file did the usual thing and it was wrong:
//
// float ddiff = abs(dl - dr) + abs(du - dd); // <- first difference
//
// A first difference measures the SLOPE of the depth buffer, and slope is large
// wherever a surface is oblique to the camera — which the ground always is. So
// the road, the pavement, every roof and every long wall seen at an angle all
// carried ink, and the threshold had to be raised until it smeared rather than
// drew. Raising it then lost the real silhouettes at distance, which is why
// `depth_threshold` had to be scaled by `dc` to stop far geometry dissolving
// into noise: the whole formula was fighting itself.
//
// A second difference measures CURVATURE, and curvature is exactly zero across
// any planar surface no matter how oblique it is to the camera. So it fires on
// real silhouettes and real creases and on nothing else — a flat road grazing
// the camera produces literally no signal, and the threshold can therefore be
// set low enough to catch a distant railing.
//
// The two signs are kept apart and drawn differently, which is also from the
// reference: positive curvature (the near side of a silhouette, a convex ridge)
// inks strongly, negative curvature (an inside corner, where two surfaces meet
// away from the viewer) inks faintly. That is what an animator does — the
// outside line of a shape is heavy, the contact line inside it is light.
uniform sampler2D screen_tex : hint_screen_texture, filter_linear;
uniform sampler2D depth_tex : hint_depth_texture, filter_nearest;
group_uniforms ink;
uniform vec4 ink_color : source_color = vec4(0.224, 0.196, 0.310, 1.0);
// How far apart the taps sit, in pixels. This IS the line weight. The reference
// runs 1.35 at a 1.5-2x internal resolution; this project renders at 1x, and the
// brief asks for a slightly harder and thicker line than the reference, so it
// sits at 2.0 — about a two-pixel line at 1080p.
uniform float ink_thickness : hint_range(0.5, 6.0) = 2.3;
// Curvature at which a convex edge reaches full ink. Small, because the second
// difference is already normalised by distance and produces almost nothing on
// flat ground — this can be sensitive in a way the old first-difference test
// could never afford to be.
uniform float ink_sensitivity : hint_range(0.0005, 0.05) = 0.0034;
uniform float ink_concave : hint_range(0.002, 0.2) = 0.024;
uniform float ink_concave_amount : hint_range(0.0, 1.0) = 0.45;
// Hardness: where the ramp to full ink STARTS, as a fraction of the threshold.
// The reference effectively runs 0.32 — ink begins to appear at a third of the
// threshold curvature, which is a soft, tapering line. Pushing it toward 1.0
// gives a harder, more decisive one, which is what the brief asks for.
//
// It cannot BE 1.0: that is a step(), and a step on a diagonal edge is a
// staircase that shimmers the moment anything moves.
//
// It also sets the NOISE FLOOR, and that is not a side effect to ignore. A
// grazing ground plane quantises in the depth buffer into a staircase, and a
// staircase has a large second difference at every step — at 0.32 those steps
// ink faintly and the road comes out finely hatched, which is precisely what a
// first render at a wider tap spacing showed.
uniform float ink_hardness : hint_range(0.1, 0.95) = 0.62;
// How much of the surface's own colour survives inside the line. Low but not
// zero: pure flat ink over every edge reads as pasted on, a whisper of the
// underlying hue reads as drawn.
uniform float ink_hue_bleed : hint_range(0.0, 0.6) = 0.14;
uniform float ink_strength : hint_range(0.0, 1.0) = 1.0;
// Let the far background dissolve into haze instead of turning into a mess of
// busy line work.
uniform float ink_fade_start : hint_range(5.0, 400.0) = 85.0;
uniform float ink_fade_end : hint_range(10.0, 900.0) = 220.0;
// Depth taps land on the sky. Clamping rather than special-casing them keeps
// the silhouette signal huge but bounded, so an object against the sky inks at
// full weight on BOTH sides of its edge — which is where the reference's early
// return leaves the line only half as thick as it should be.
uniform float ink_sky_depth : hint_range(50.0, 4000.0) = 900.0;
// ── The grade ────────────────────────────────────────────────────────────────
//
// A split-tone: cool violet into the darks, warm paper-white into the lights,
// plus a lift that keeps shadow off the floor. Godot tonemaps and converts to
// sRGB after this pass, so unlike the reference this stays in linear and does
// NOT do its own sRGB conversion — doing both would gamma the frame twice.
group_uniforms grade;
uniform bool grade_enabled = true;
uniform vec3 grade_shadow_tint : source_color = vec3(0.678, 0.659, 0.816);
uniform vec3 grade_light_tint : source_color = vec3(1.0, 0.969, 0.910);
uniform float grade_saturation : hint_range(0.0, 2.0) = 1.12;
uniform float grade_lift : hint_range(0.0, 0.2) = 0.028;
uniform float grade_warmth : hint_range(0.0, 0.3) = 0.05;
uniform float grade_vignette : hint_range(0.0, 0.6) = 0.15;
// The fullscreen pass owns the background pixels as well as the geometry ones.
// Godot's screen copy holds only the viewport clear colour at far depth, so
// sampling screen_tex there silently erases the real Environment sky — these
// repaint it with the same palette and law as anime_sky.gdshader, and
// LevelEnvironment keeps the two materials in sync.
group_uniforms sky;
uniform vec3 sky_top_color : source_color = vec3(0.18, 0.40, 0.85);
uniform vec3 sky_horizon_color : source_color = vec3(0.72, 0.88, 0.98);
uniform vec3 sky_ground_color : source_color = vec3(0.24, 0.22, 0.30);
uniform vec3 sky_cloud_color : source_color = vec3(1.0, 0.99, 0.97);
uniform vec3 sky_cloud_shadow : source_color = vec3(0.72, 0.75, 0.88);
uniform float sky_cloud_cover : hint_range(0.0, 1.0) = 0.46;
uniform float sky_cloud_scale : hint_range(0.2, 8.0) = 1.6;
uniform float sky_cloud_softness : hint_range(0.001, 0.2) = 0.035;
uniform float sky_drift : hint_range(0.0, 0.02) = 0.0022;
uniform float sky_horizon_falloff : hint_range(0.1, 2.0) = 0.45;
uniform vec3 sky_sun_direction = vec3(0.45, 0.34, -0.82);
uniform vec3 sky_sun_color : source_color = vec3(1.0, 0.91, 0.72);
uniform float sky_sun_halo : hint_range(0.0, 2.0) = 0.35;
#include "res://assets/shaders/anime_clouds.gdshaderinc"
vec3 painted_sky(vec3 dir) {
float up = clamp(dir.y, 0.0, 1.0);
float down = clamp(-dir.y, 0.0, 1.0);
vec3 col = dir.y >= 0.0
? mix(sky_horizon_color, sky_top_color, pow(up, sky_horizon_falloff))
: mix(sky_horizon_color, sky_ground_color, pow(down, 0.62));
float sun = pow(max(dot(dir, normalize(sky_sun_direction)), 0.0), 28.0);
col += sky_sun_color * sun * sky_sun_halo;
if (dir.y > 0.005) {
vec2 uv = dir.xz / (dir.y + 0.18) * sky_cloud_scale
+ vec2(TIME * sky_drift, 0.0);
float n = cloud_field(uv);
float edge = max(sky_cloud_softness, fwidth(n) * 1.35);
float shape = smoothstep(
sky_cloud_cover - edge, sky_cloud_cover + edge, n);
vec3 sun_direction = normalize(sky_sun_direction);
float facing = cloud_sun_response(uv, sun_direction);
float core = smoothstep(
sky_cloud_cover + 0.045 - edge,
sky_cloud_cover + 0.155 + edge, n);
float carving = smoothstep(
0.38, 0.64, cloud_detail(uv + vec2(2.4, 9.7)));
float lit_face = clamp(
0.12 + core * 0.25 + facing * 0.58 + carving * 0.05,
0.0, 1.0);
vec3 lit_color = mix(sky_cloud_color, sky_sun_color, 0.18);
vec3 cloud = mix(sky_cloud_shadow, lit_color, lit_face);
float boundary = 1.0 - smoothstep(
sky_cloud_cover + edge,
sky_cloud_cover + 0.105 + edge, n);
float silver_lining = boundary * facing * facing
* mix(0.10, 0.24, clamp(sun_direction.y, 0.0, 1.0));
cloud += sky_sun_color * silver_lining;
col = mix(col, cloud, shape * smoothstep(0.0, 0.22, dir.y));
}
return col;
}
// Linear view depth, with the sky pinned to a finite value so the second
// difference across a silhouette is large but never infinite.
float linear_depth(vec2 uv, mat4 inv_proj) {
float d = texture(depth_tex, uv).r;
// Godot 4.3+ is reverse-Z: the far plane and the sky read exactly 0.
if (d <= 0.000001) {
return ink_sky_depth;
}
vec4 ndc = vec4(uv * 2.0 - 1.0, d, 1.0);
vec4 view = inv_proj * ndc;
return min(-view.z / view.w, ink_sky_depth);
}
vec3 apply_grade(vec3 c, vec2 uv) {
if (!grade_enabled) {
return c;
}
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
// Split-tone: cool violet in the darks, warm paper white in the lights.
float k = smoothstep(0.02, 0.55, l);
c *= mix(grade_shadow_tint, grade_light_tint, k);
// A gentle overall warmth, like late afternoon light through blossom.
c += vec3(grade_warmth, grade_warmth * 0.45, 0.0) * l * 0.35;
// Keep the shadows readable — never crushed to black.
c += grade_lift * (1.0 - k);
c = mix(vec3(l), c, grade_saturation);
float r = length(uv - 0.5) * 1.42;
c *= 1.0 - grade_vignette * pow(clamp(r, 0.0, 1.0), 2.6);
return max(c, vec3(0.0));
}
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
vec2 px = 1.0 / VIEWPORT_SIZE;
vec2 uv = SCREEN_UV;
// Background first: where the depth buffer has no geometry the screen copy
// holds nothing but the clear colour, so those pixels are painted rather
// than sampled. They still go through the ink test below — an object read
// against the sky should be inked on the sky side of its edge too, which is
// what makes the silhouette the heaviest line in the frame.
float raw_depth = texture(depth_tex, uv).r;
vec3 col;
if (raw_depth <= 0.000001) {
vec4 far_view = INV_PROJECTION_MATRIX * vec4(uv * 2.0 - 1.0, 0.0, 1.0);
vec3 view_dir = normalize(far_view.xyz / far_view.w);
vec3 world_dir = normalize((INV_VIEW_MATRIX * vec4(view_dir, 0.0)).xyz);
col = painted_sky(world_dir);
} else {
col = texture(screen_tex, uv).rgb;
}
vec2 t = px * ink_thickness;
float dc = linear_depth(uv, INV_PROJECTION_MATRIX);
float dl = linear_depth(uv - vec2(t.x, 0.0), INV_PROJECTION_MATRIX);
float dr = linear_depth(uv + vec2(t.x, 0.0), INV_PROJECTION_MATRIX);
float du = linear_depth(uv + vec2(0.0, t.y), INV_PROJECTION_MATRIX);
float dd = linear_depth(uv - vec2(0.0, t.y), INV_PROJECTION_MATRIX);
// Second difference of linear depth, normalised by distance so that a given
// real-world crease inks with the same weight near and far.
float sx = (dl + dr - 2.0 * dc) / dc;
float sy = (du + dd - 2.0 * dc) / dc;
float convex = max(0.0, sx) + max(0.0, sy);
float concave = max(0.0, -sx) + max(0.0, -sy);
float edge = smoothstep(
ink_sensitivity * ink_hardness, ink_sensitivity, convex);
edge = max(edge, smoothstep(
ink_concave, ink_concave * 3.4, concave) * ink_concave_amount);
// Fade on the nearest real surface involved in the edge, not on dc: a sky
// pixel beside a nearby roof would otherwise be measured at 900 m and its
// half of the silhouette would fade out while the roof's half did not.
float near_d = min(dc, min(min(dl, dr), min(du, dd)));
edge *= 1.0 - smoothstep(ink_fade_start, ink_fade_end, near_d);
edge *= ink_strength;
vec3 line = mix(ink_color.rgb, col * 0.42, ink_hue_bleed);
col = mix(col, line, clamp(edge, 0.0, 1.0));
ALBEDO = apply_grade(col, uv);
}