292 lines
14 KiB
Plaintext
292 lines
14 KiB
Plaintext
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;
|
|
}
|