shader_type spatial; // Cel/toon surface shader used across the game. // - Banded (stepped) diffuse with a tinted shadow color instead of black // - Soft rim light for silhouette pop // - Optional world-space triplanar albedo (level geometry: no UVs needed, // grid stays world-scaled) — otherwise standard UV sampling (characters) uniform vec4 albedo_color : source_color = vec4(1.0); uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable; uniform bool has_texture = true; uniform bool use_vertex_color = false; uniform bool use_triplanar = false; uniform float triplanar_tile = 2.0; // world units per texture tile uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light band starts uniform float band_softness : hint_range(0.001, 0.5) = 0.04; uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band // How dark the mid band sits below full light. Near 1.0 the second step almost // disappears, which is what textures that already carry painted cel shading // (imported anime characters) need — a hard step on top of painted shading // reads as a glossy stripe sweeping across the hair. uniform float mid_tone : hint_range(0.0, 1.0) = 0.82; uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint // How far a CAST shadow drops below the form-shadow tint. Cel art draws the // two separately: the terminator (a surface turning away from the sun) is a // tone step, a cast shadow (something standing in the way) is a darker, harder // shape. See light() for why collapsing them made benches float. uniform float cast_shadow_depth : hint_range(0.0, 1.0) = 0.62; // Width of the cast-shadow edge, in ATTENUATION. Small keeps it crisp like // inked artwork; not zero, or the shadow map's own stair-steps show. uniform float cast_shadow_softness : hint_range(0.01, 1.0) = 0.35; // Matte-anime defaults: zero specular (any stepped glint reads as shine // sweeping across hair/cloth when the camera moves), whisper of rim. uniform float rim_strength : hint_range(0.0, 2.0) = 0.05; uniform float rim_width : hint_range(0.0, 1.0) = 0.28; uniform float specular_strength : hint_range(0.0, 1.0) = 0.0; uniform float specular_shininess : hint_range(1.0, 128.0) = 64.0; // Viewmodel/readability controls. Both default to zero so world materials are // byte-for-byte unchanged. The first keeps near-black authored metal from // collapsing below display range; the second is a controlled painted fill, // useful in a transparent viewmodel viewport that has no environment ambient. uniform float albedo_floor : hint_range(0.0, 0.5) = 0.0; uniform float ambient_fill : hint_range(0.0, 1.0) = 0.0; // Viewmodel color shaping. Defaults preserve every world/character material; // weapon profiles opt in so dark receiver metal, pale trim and painted parts // do not all converge on the same middle grey under a uniform fill. uniform float albedo_contrast : hint_range(0.5, 2.0) = 1.0; uniform float albedo_saturation : hint_range(0.0, 2.0) = 1.0; uniform float albedo_gain : hint_range(0.25, 1.5) = 1.0; uniform float viewmodel_light_strength : hint_range(0.0, 2.0) = 0.0; uniform vec3 viewmodel_key_direction = vec3(-0.42, 0.58, 0.70); uniform vec3 viewmodel_key_color : source_color = vec3(1.0, 0.91, 0.80); uniform vec3 viewmodel_fill_color : source_color = vec3(0.72, 0.80, 1.0); // Viewmodel ink controls. The old deterministic light was an additive // key/fill wash: it made every normal visible, but also lifted receiver // recesses and polymer undersides toward the same middle grey. These controls // make that painted light a four-tone cel ramp and add a thin curvature ink // pass. They default off/neutral so world and character materials are // unchanged. uniform float viewmodel_shadow_tone : hint_range(0.0, 1.0) = 0.18; uniform float viewmodel_mid_tone : hint_range(0.0, 1.0) = 0.52; uniform float viewmodel_crease_strength : hint_range(0.0, 1.0) = 0.0; uniform float viewmodel_crease_threshold : hint_range(0.001, 1.0) = 0.12; // ── Surface law ────────────────────────────────────────────────────────────── // Flat cel colour is the style, but a wall that is ONE value from end to end is // not stylised, it is empty — tools/levels.py measured 0.28 mean adjacent-pixel // difference across the probe, i.e. essentially nothing but the silhouettes. // These give a surface its own quiet structure without touching the flat-shaded // look: everything here is authored into ALBEDO, so the cel bands still land on // top of it exactly as before. // // All default to OFF (scale 0). A material opts in through LevelMaterials' // surface laws, so props, vehicles and foliage keep plain flat colour and only // architecture and ground get the treatment. group_uniforms detail; // Panel seams: a world-space grid of thin darker lines, triplanar so it needs // no UVs and stays the same physical size on every surface it crosses. This is // the single biggest change to how "built" a surface reads. uniform float seam_scale = 0.0; // metres per panel; 0 = off uniform float seam_strength : hint_range(0.0, 1.0) = 0.30; uniform float seam_width : hint_range(0.001, 0.2) = 0.012; // fraction of a panel // Vertical grade: surfaces darken toward the ground. A painter would put this // in by hand — it is ambient occlusion as artwork rather than as a screen // effect, it costs nothing, and it works at any distance, which SSAO does not. uniform float grade_height = 0.0; // metres to fade over; 0 = off uniform float grade_strength : hint_range(0.0, 1.0) = 0.16; uniform float grade_floor = 0.0; // world Y the grade starts from // Halftone: screen-tone dots inside the shadow band. The signature manga move // and ZZZ does use it, but sparingly — off unless a material asks. uniform float halftone_scale = 0.0; // screen px per dot; 0 = off uniform float halftone_strength : hint_range(0.0, 1.0) = 0.35; varying vec3 world_pos; varying vec3 world_normal; // How fast the normal is turning across one pixel, measured in fragment() and // read in light(). This is the band-edge anti-aliasing budget — see light(). varying float normal_slope; // Screen-tone dot mask, measured in fragment() where FRAGCOORD lives and spent // in light() where the shadow band is known. varying float halftone_dot; void vertex() { world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); } vec3 sample_triplanar(vec3 p, vec3 n) { vec3 w = abs(n); w = pow(w, vec3(4.0)); w /= (w.x + w.y + w.z); vec2 uv_x = p.zy / triplanar_tile; vec2 uv_y = p.xz / triplanar_tile; vec2 uv_z = p.xy / triplanar_tile; vec3 cx = texture(albedo_texture, uv_x).rgb; vec3 cy = texture(albedo_texture, uv_y).rgb; vec3 cz = texture(albedo_texture, uv_z).rgb; return cx * w.x + cy * w.y + cz * w.z; } // One axis-plane's worth of panel grid. Returns 1 on a seam line, 0 on the // panel face, anti-aliased by the on-screen size of a cell so the lines neither // shimmer at distance nor turn into fat bars up close — a seam drawn with // step() looks fine in a screenshot and crawls badly the moment anything moves. float seam_lines(vec2 uv) { vec2 g = abs(fract(uv) - 0.5); vec2 fw = fwidth(uv) + 0.0001; vec2 line = smoothstep(0.5 - seam_width - fw, 0.5 - seam_width + fw, g); return max(line.x, line.y); } // Triplanar panel seams, weighted the same way the albedo triplanar is so the // lines agree with the texture across a corner instead of crossfading against // it. Far enough away that a cell is sub-pixel the whole thing fades out, // otherwise the grid turns into moiré. float panel_seam(vec3 p, vec3 n) { vec3 w = pow(abs(n), vec3(4.0)); w /= (w.x + w.y + w.z); float s = seam_lines(p.zy / seam_scale) * w.x + seam_lines(p.xz / seam_scale) * w.y + seam_lines(p.xy / seam_scale) * w.z; float cell_px = length(fwidth(p)) / seam_scale; return s * (1.0 - smoothstep(0.25, 0.5, cell_px)); } void fragment() { vec3 base = albedo_color.rgb; if (has_texture) { if (use_triplanar) { base *= sample_triplanar(world_pos, world_normal); } else { base *= texture(albedo_texture, UV).rgb; } } if (use_vertex_color) { base *= COLOR.rgb; } base *= albedo_gain; float source_luma = dot(base, vec3(0.2126, 0.7152, 0.0722)); base = mix(vec3(source_luma), base, albedo_saturation); // Non-clipping power curve. Imported gun greys arrive as very small LINEAR // values (an sRGB 0.17 receiver is roughly 0.025 here); a conventional 18% // linear contrast pivot clips all of those finishes to exactly zero. The // reciprocal curve expands their separation without moving black or white. base = pow(max(base, vec3(0.0)), vec3(1.0 / max(albedo_contrast, 0.001))); if (albedo_floor > 0.0) { float luma = dot(base, vec3(0.2126, 0.7152, 0.0722)); base = min(base * max(1.0, albedo_floor / max(luma, 0.001)), vec3(1.0)); } // Surface law, into the albedo so the cel bands still light it normally. if (seam_scale > 0.0) { base *= 1.0 - panel_seam(world_pos, world_normal) * seam_strength; } if (grade_height > 0.0) { float h = clamp((world_pos.y - grade_floor) / grade_height, 0.0, 1.0); base *= mix(1.0 - grade_strength, 1.0, h); } // A one-pixel technical-ink accent where the surface normal changes // quickly. Unlike an inverted hull this follows receiver cut-lines and // bevels without exploding on the weapon FBXs' split hard normals. float local_normal_slope = length(fwidth(normalize(NORMAL))); float vm_crease = smoothstep( viewmodel_crease_threshold, viewmodel_crease_threshold * 2.6, local_normal_slope); base *= 1.0 - vm_crease * viewmodel_crease_strength; // Screen-tone dots, rotated 45° the way a real screentone sheet is. Spent // in light(), which is where we know whether this pixel is in shadow. halftone_dot = 0.0; if (halftone_scale > 0.0) { vec2 hp = FRAGCOORD.xy / halftone_scale; vec2 r = vec2(hp.x - hp.y, hp.x + hp.y) * 0.70710678; halftone_dot = smoothstep(0.36, 0.30, length(fract(r) - 0.5)); } ALBEDO = base; ROUGHNESS = 1.0; SPECULAR = 0.0; // Screen-space rate of change of the normal, for the band-edge width in // light(). It has to be measured here: derivatives are a fragment-stage // operation, and light() runs once per light — taking fwidth() inside that // loop is undefined behaviour on some drivers. normal_slope = length(fwidth(NORMAL)); // Rim: brighten grazing angles for that inked-silhouette pop. float rim = 1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0); rim = smoothstep(1.0 - rim_width, 1.0, rim); // Deterministic view-space key/fill for the separate transparent viewmodel // viewport. Directional lights parented to its camera are renderer- and // world-sharing-sensitive; this painted light map follows the gun normals // directly and therefore survives every level environment. float vm_ndotl = dot(normalize(NORMAL), normalize(viewmodel_key_direction)); float vm_leave_shadow = smoothstep(-0.24, -0.04, vm_ndotl); float vm_enter_light = smoothstep(0.18, 0.34, vm_ndotl); float vm_highlight = smoothstep(0.64, 0.78, vm_ndotl); float vm_tone = mix(viewmodel_shadow_tone, viewmodel_mid_tone, vm_leave_shadow); vm_tone = mix(vm_tone, 0.82, vm_enter_light); vm_tone = mix(vm_tone, 1.0, vm_highlight); vec3 vm_light = mix(viewmodel_fill_color, viewmodel_key_color, vm_enter_light); EMISSION = base * ( vec3(rim * rim_strength + ambient_fill) + vm_light * vm_tone * viewmodel_light_strength); } void light() { float ndotl = dot(NORMAL, LIGHT); // Band-edge width, with a screen-space floor. // // band_softness is measured in NdotL, which says nothing about how many // PIXELS the transition actually covers. On a slowly-curving surface it can // span a third of a wall; on a tight curve, or simply at distance, it // collapses below one pixel and the terminator crawls and stair-steps as // the camera moves. MSAA cannot touch this — it is shading aliasing, not // geometry. normal_slope is how much the normal turns across one pixel, so // widening the step to at least that much keeps every band edge about a // pixel wide while leaving edges that are already wider exactly as authored. // Flat surfaces have a slope of zero and so keep their crisp break. float w = max(band_softness, normal_slope * 0.9); // FORM shadow — the surface turning away from the light. This is the cel // terminator, and its numbers are what the whole look was calibrated on, so // it still lands exactly where it did. float band = smoothstep(band_edge - w, band_edge + w, ndotl); float mid = smoothstep(mid_band_edge - w, mid_band_edge + w, ndotl); // 3 tones: shadow tint -> base band (mid_tone) -> full light. float tone = mix(mid_tone, 1.0, mid); vec3 shade = mix(shadow_color.rgb, vec3(tone), band); // CAST shadow — something is standing between this pixel and the light. // // These used to be one thing: NdotL was multiplied by ATTENUATION and the // PRODUCT was banded, so a cast shadow could never be darker than the // terminator's own tint, about 0.65 of full light. Under the old ambient // flood (Neon Alley used to run 1.35) that came out as a dip of roughly a // seventh — which is why a bench standing in direct sun threw nothing onto // the pavement, and why tools/levels.py measured a 1st percentile of 119 on // a daylit frame: nothing in it was dark, so nothing rested on anything. // // Cel art draws the two separately, and so does this now: the terminator // keeps its calibrated tone step, and occlusion drops below it to // cast_shadow_depth as a harder, deeper shape. float occ = smoothstep(0.0, cast_shadow_softness, ATTENUATION); shade = mix(shadow_color.rgb * cast_shadow_depth, shade, occ); // Screen tone, in the shadow only — that is where a manga artist puts it, // and confining it there means it reads as shading rather than as a filter // laid over the picture. Scaled by how deep in shadow the pixel is, so the // dots fade in across the terminator instead of switching on at it. float in_shadow = (1.0 - band) * (1.0 - occ * 0.4); shade *= 1.0 - halftone_dot * in_shadow * halftone_strength; DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade; // Stepped specular dot for glossy toon highlights. Gated by occ so a // highlight cannot sit inside a shadow. vec3 h = normalize(VIEW + LIGHT); float spec = pow(clamp(dot(NORMAL, h), 0.0, 1.0), specular_shininess); spec = smoothstep(0.5 - w, 0.5 + w, spec); SPECULAR_LIGHT += LIGHT_COLOR * spec * specular_strength * band * occ; }