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, repeat_disable; uniform sampler2D depth_tex : hint_depth_texture, filter_nearest, repeat_disable; uniform sampler2D normal_roughness_tex : hint_normal_roughness_texture, filter_nearest, repeat_disable; 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, float d, mat4 inv_proj) { // 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 view_position(vec2 uv, float raw_depth, mat4 inv_proj) { vec4 ndc = vec4(uv * 2.0 - 1.0, raw_depth, 1.0); vec4 view = inv_proj * ndc; return view.xyz / view.w; } vec3 screen_normal(vec2 uv) { return normalize(texture(normal_roughness_tex, uv).xyz * 2.0 - 1.0); } 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 raw_l = texture(depth_tex, uv - vec2(t.x, 0.0)).r; float raw_r = texture(depth_tex, uv + vec2(t.x, 0.0)).r; float raw_u = texture(depth_tex, uv + vec2(0.0, t.y)).r; float raw_d = texture(depth_tex, uv - vec2(0.0, t.y)).r; float dc = linear_depth(uv, raw_depth, INV_PROJECTION_MATRIX); float dl = linear_depth(uv - vec2(t.x, 0.0), raw_l, INV_PROJECTION_MATRIX); float dr = linear_depth(uv + vec2(t.x, 0.0), raw_r, INV_PROJECTION_MATRIX); float du = linear_depth(uv + vec2(0.0, t.y), raw_u, INV_PROJECTION_MATRIX); float dd = linear_depth(uv - vec2(0.0, t.y), raw_d, 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); // Depth precision forms a staircase on a flat floor viewed almost edge-on. // Its second difference looks like curvature even though every tapped point // belongs to the same plane; beyond a camera-dependent distance the old test // therefore inked the entire road as one dark band. Confirm that curvature // with the normal/depth prepass before drawing it. A true edge either changes // normal, leaves the centre tangent plane, or crosses from geometry to sky. if (edge > 0.0001) { float sky_edge = step(raw_depth, 0.000001) != step(raw_l, 0.000001) || step(raw_depth, 0.000001) != step(raw_r, 0.000001) || step(raw_depth, 0.000001) != step(raw_u, 0.000001) || step(raw_depth, 0.000001) != step(raw_d, 0.000001) ? 1.0 : 0.0; float geometry_evidence = sky_edge; if (raw_depth > 0.000001 && sky_edge < 0.5) { vec3 nc = screen_normal(uv); vec3 nl = screen_normal(uv - vec2(t.x, 0.0)); vec3 nr = screen_normal(uv + vec2(t.x, 0.0)); vec3 nu = screen_normal(uv + vec2(0.0, t.y)); vec3 nd = screen_normal(uv - vec2(0.0, t.y)); float normal_turn = max(max(1.0 - dot(nc, nl), 1.0 - dot(nc, nr)), max(1.0 - dot(nc, nu), 1.0 - dot(nc, nd))); vec3 pc = view_position(uv, raw_depth, INV_PROJECTION_MATRIX); vec3 pl = view_position(uv - vec2(t.x, 0.0), raw_l, INV_PROJECTION_MATRIX); vec3 pr = view_position(uv + vec2(t.x, 0.0), raw_r, INV_PROJECTION_MATRIX); vec3 pu = view_position(uv + vec2(0.0, t.y), raw_u, INV_PROJECTION_MATRIX); vec3 pd = view_position(uv - vec2(0.0, t.y), raw_d, INV_PROJECTION_MATRIX); float off_plane = max(max(abs(dot(nc, normalize(pl - pc))), abs(dot(nc, normalize(pr - pc)))), max(abs(dot(nc, normalize(pu - pc))), abs(dot(nc, normalize(pd - pc))))); float normal_evidence = smoothstep(0.006, 0.045, normal_turn); float plane_evidence = smoothstep(0.012, 0.080, off_plane); geometry_evidence = max(normal_evidence, plane_evidence); } edge *= geometry_evidence; } // 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); }