This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
+212 -8
View File
@@ -9,6 +9,7 @@ shader_type spatial;
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
@@ -21,15 +22,84 @@ uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter
// 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;
@@ -49,6 +119,33 @@ vec3 sample_triplanar(vec3 p, vec3 n) {
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) {
@@ -58,30 +155,137 @@ void fragment() {
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);
EMISSION = base * rim * rim_strength;
// 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);
// Shadowing folds into the band test so shadow edges band too.
float lit = ndotl * ATTENUATION;
float band = smoothstep(band_edge - band_softness, band_edge + band_softness, lit);
float mid = smoothstep(mid_band_edge - band_softness, mid_band_edge + band_softness, lit);
// 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.
// 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 - band_softness, 0.5 + band_softness, spec);
SPECULAR_LIGHT += LIGHT_COLOR * spec * specular_strength * band;
spec = smoothstep(0.5 - w, 0.5 + w, spec);
SPECULAR_LIGHT += LIGHT_COLOR * spec * specular_strength * band * occ;
}