feat: screen-space ink edge pass — drawn outlines on everything

New assets/shaders/ink_edge.gdshader: fullscreen depth+normal discontinuity
detection draws thin ink lines over the whole scene — the renderer-level
line pass anime-styled games use. Works on every model regardless of mesh
topology (kit buildings, cars, heroes, characters), with distance fade so
the skyline stays clean. Attached via LevelEnvironment so every map gets
it for whatever camera renders (first-person, third-person, spectator).

Street capture verified: crisp uniform outlines citywide, no artifacts.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-19 11:54:55 -04:00
co-authored by Claude Fable 5
parent 911066875b
commit 5224a455a6
2 changed files with 71 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
shader_type spatial;
render_mode unshaded, fog_disabled, depth_draw_never, depth_test_disabled, cull_disabled;
// Screen-space ink outline: detects depth and normal discontinuities and
// draws thin dark edges over the scene — the drawn-line pass that unifies
// every model (kit, hero, character) into the cel look. Attach to a
// fullscreen quad (POSITION override) so it runs for whatever camera renders.
uniform sampler2D screen_tex : hint_screen_texture, filter_linear;
uniform sampler2D depth_tex : hint_depth_texture, filter_nearest;
uniform sampler2D normal_tex : hint_normal_roughness_texture, filter_nearest;
uniform vec4 ink_color : source_color = vec4(0.07, 0.06, 0.09, 1.0);
uniform float depth_threshold : hint_range(0.0, 4.0) = 1.1;
uniform float normal_threshold : hint_range(0.0, 1.0) = 0.55;
uniform float edge_strength : hint_range(0.0, 1.0) = 0.8;
uniform float max_distance : hint_range(10.0, 500.0) = 220.0;
float linear_depth(vec2 uv, mat4 inv_proj) {
float d = texture(depth_tex, uv).r;
vec4 ndc = vec4(uv * 2.0 - 1.0, d, 1.0);
vec4 view = inv_proj * ndc;
return -view.z / view.w;
}
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
vec2 px = 1.0 / VIEWPORT_SIZE;
vec2 uv = SCREEN_UV;
float dc = linear_depth(uv, INV_PROJECTION_MATRIX);
float dl = linear_depth(uv - vec2(px.x, 0.0), INV_PROJECTION_MATRIX);
float dr = linear_depth(uv + vec2(px.x, 0.0), INV_PROJECTION_MATRIX);
float du = linear_depth(uv - vec2(0.0, px.y), INV_PROJECTION_MATRIX);
float dd = linear_depth(uv + vec2(0.0, px.y), INV_PROJECTION_MATRIX);
// Depth edge: neighbor difference scaled by distance so far geometry
// doesn't dissolve into noise.
float ddiff = abs(dl - dr) + abs(du - dd);
float depth_edge = step(depth_threshold * (0.5 + dc * 0.08), ddiff);
// Normal edge: crease lines on same-depth corners.
vec3 nc = texture(normal_tex, uv).xyz * 2.0 - 1.0;
vec3 nl = texture(normal_tex, uv - vec2(px.x, 0.0)).xyz * 2.0 - 1.0;
vec3 nu = texture(normal_tex, uv - vec2(0.0, px.y)).xyz * 2.0 - 1.0;
float ndiff = length(nc - nl) + length(nc - nu);
float normal_edge = step(normal_threshold, ndiff) * step(ddiff, depth_threshold);
float fade = 1.0 - smoothstep(max_distance * 0.6, max_distance, dc);
float edge = max(depth_edge, normal_edge * 0.6) * edge_strength * fade;
vec3 scene = texture(screen_tex, uv).rgb;
ALBEDO = mix(scene, ink_color.rgb, edge);
}
+14
View File
@@ -34,6 +34,20 @@ static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment
fill.shadow_enabled = false fill.shadow_enabled = false
level.add_child(fill) level.add_child(fill)
# Screen-space ink edge pass: drawn outlines on every model for whatever
# camera renders (fullscreen POSITION-override quad, never culled).
if not level.has_node("InkEdgePost"):
var ink := MeshInstance3D.new()
ink.name = "InkEdgePost"
var quad := QuadMesh.new()
quad.size = Vector2(2, 2)
var ink_mat := ShaderMaterial.new()
ink_mat.shader = load("res://assets/shaders/ink_edge.gdshader")
quad.material = ink_mat
ink.mesh = quad
ink.extra_cull_margin = 16384.0
level.add_child(ink)
# Quiet ambient bed so the arena never sits in dead air. # Quiet ambient bed so the arena never sits in dead air.
if not level.has_node("AmbientBed") and ResourceLoader.exists("res://assets/sounds/wind.wav"): if not level.has_node("AmbientBed") and ResourceLoader.exists("res://assets/sounds/wind.wav"):
var amb := AudioStreamPlayer.new() var amb := AudioStreamPlayer.new()