259 lines
12 KiB
Plaintext
259 lines
12 KiB
Plaintext
shader_type spatial;
|
|
|
|
// ── The cel law ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Ported from the Sakura Crossing renderer (three.js MeshToonMaterial + a
|
|
// patched toon BRDF). Two ideas, and the second one is the one that matters:
|
|
//
|
|
// 1. Direct light is QUANTISED against a hand-authored ramp — a tiny
|
|
// nearest-filtered texture sampled at `dot(N,L) * 0.5 + 0.5`. So a surface
|
|
// is never lit by a falloff, only ever by one of 2-5 flat values.
|
|
//
|
|
// 2. The darker bands are HUE-SHIFTED toward a cool violet rather than being
|
|
// a darker version of the base colour:
|
|
//
|
|
// irradiance = band * mix(shadow_tint, vec3(1.0), band)
|
|
//
|
|
// The band drives its own tint, so full light is untinted, the mid band is
|
|
// slightly cooled and the darkest band is almost entirely shadow-hue. That
|
|
// hue shift in shadow is most of what separates "anime cel" from
|
|
// "low-poly 3D" — a wall whose shade is just 40% of its lit colour reads
|
|
// as an unlit polygon; a wall whose shade swings violet reads as painted.
|
|
//
|
|
// Godot's light() is called per light and adds into DIFFUSE_LIGHT, and its
|
|
// Lambert term already carries the 1/PI that three.js puts in BRDF_Lambert, so
|
|
// the two pipelines agree once the ramp value is substituted for `dot(N,L)`.
|
|
//
|
|
// This is a SEPARATE shader from toon.gdshader on purpose. That one carries the
|
|
// weapon-viewmodel colour shaping and the character band calibration, both
|
|
// tuned against imported textures that are already painted with cel shading.
|
|
// This one is for FLAT-COLOURED WORLD GEOMETRY, which is what the reference is
|
|
// built out of end to end, and it would be wrong to make either compromise for
|
|
// the other.
|
|
|
|
// ── Ramps ────────────────────────────────────────────────────────────────────
|
|
//
|
|
// The reference's stop tables, kept as raw 0-255 values so they can be checked
|
|
// against the source line by line. These are LINEAR values, not sRGB: three.js
|
|
// gives a DataTexture no colour space, so the stops are used as authored, and
|
|
// the same has to be true here or every band lands too bright.
|
|
//
|
|
// 2/3/4/5 the standard ramps. 3 is the default and does most of the world.
|
|
// 6 "soft" — high key, 2 bands, for pale masses (blossom, cloud,
|
|
// 7 "soft3" — high key, 3 bands plaster) that must stay light even
|
|
// on the shadow side. Without these a
|
|
// cherry canopy goes grey the moment
|
|
// it turns away from the sun, which
|
|
// is the one thing a blossom tree may
|
|
// never do.
|
|
const float RAMP_2[5] = float[](96.0, 255.0, 255.0, 255.0, 255.0);
|
|
const float RAMP_3[5] = float[](92.0, 178.0, 255.0, 255.0, 255.0);
|
|
const float RAMP_4[5] = float[](80.0, 142.0, 202.0, 255.0, 255.0);
|
|
const float RAMP_5[5] = float[](74.0, 124.0, 172.0, 214.0, 255.0);
|
|
const float RAMP_SOFT[5] = float[](180.0, 255.0, 255.0, 255.0, 255.0);
|
|
const float RAMP_SOFT3[5] = float[](172.0, 214.0, 255.0, 255.0, 255.0);
|
|
|
|
// Which ramp, and how many of its stops are live.
|
|
// 0 = 2 band 1 = 3 band (default) 2 = 4 band 3 = 5 band
|
|
// 4 = soft 2 band (high key) 5 = soft 3 band (high key)
|
|
uniform int ramp_id : hint_range(0, 5) = 1;
|
|
|
|
uniform vec4 albedo_color : source_color = vec4(1.0);
|
|
uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable;
|
|
uniform bool has_texture = false;
|
|
uniform bool use_vertex_color = false;
|
|
|
|
// The cool violet the shadow bands swing toward. 0x6c5f8c is the reference's
|
|
// default; individual materials shift it (warmer for timber and blossom, bluer
|
|
// for metal and water) the same way the reference's `cel({tint})` does.
|
|
uniform vec4 shadow_tint : source_color = vec4(0.424, 0.373, 0.549, 1.0);
|
|
|
|
// Faceted shading. The reference sets `flatShading: true` on essentially
|
|
// everything, and it is not a shortcut — a quantised ramp over SMOOTH normals
|
|
// puts a curved band boundary across a curved surface, which reads as an
|
|
// airbrushed gradient no matter how few steps it has. Faceting forces every
|
|
// band edge onto a polygon edge, which is where a cel painter would put it.
|
|
uniform bool flat_shading = true;
|
|
|
|
// ── Cast shadow ──────────────────────────────────────────────────────────────
|
|
//
|
|
// The reference folds shadow attenuation into the light colour, so an occluded
|
|
// pixel there goes to zero direct light and is carried entirely by ambient.
|
|
// That works when the whole world is one authored scene; in a shooter, where
|
|
// players have to be read against the geometry they are standing on, a cast
|
|
// shadow that reaches black swallows them.
|
|
//
|
|
// So occlusion drives the surface to the ramp's DARKEST STOP — fully tinted,
|
|
// scaled by cast_shadow_depth — rather than to nothing. The result is still a
|
|
// hard, dark, clearly separate shape (which is what an animator inks), but it
|
|
// keeps hue, so a character standing in the shade of a shopfront stays legible.
|
|
uniform float cast_shadow_depth : hint_range(0.0, 1.0) = 0.72;
|
|
uniform float cast_shadow_softness : hint_range(0.01, 1.0) = 0.30;
|
|
|
|
// Band-edge anti-aliasing floor, in NdotL. Nearest-filtered quantisation is
|
|
// a hard step, and a hard step on a slowly-curving surface crawls and
|
|
// stair-steps as the camera moves — this is shading aliasing, so MSAA cannot
|
|
// touch it. See light() for how the screen-space term works.
|
|
uniform float band_softness : hint_range(0.0, 0.5) = 0.012;
|
|
|
|
// Rim and specular are OFF by default and should mostly stay that way: the
|
|
// reference has neither, and on large level surfaces a toon rim reads as a
|
|
// giant soft blob smeared across a wall.
|
|
uniform float rim_strength : hint_range(0.0, 2.0) = 0.0;
|
|
uniform float rim_width : hint_range(0.0, 1.0) = 0.28;
|
|
|
|
// ── Surface law ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Carried over from toon.gdshader, because the problem it solves is real here
|
|
// too: a flat cel colour is the style, but a twelve-metre wall holding exactly
|
|
// one value is not stylised, it is empty. Both default to off — the reference
|
|
// answers this with geometry (every fascia, sill and downpipe is modelled), and
|
|
// where this map does the same it does not need them.
|
|
group_uniforms detail;
|
|
uniform float seam_scale = 0.0; // metres per panel; 0 = off
|
|
uniform float seam_strength : hint_range(0.0, 1.0) = 0.24;
|
|
uniform float seam_width : hint_range(0.001, 0.2) = 0.010;
|
|
uniform float grade_height = 0.0; // metres to fade over; 0 = off
|
|
uniform float grade_strength : hint_range(0.0, 1.0) = 0.18;
|
|
uniform float grade_floor = 0.0; // world Y the grade starts from
|
|
|
|
varying vec3 world_pos;
|
|
varying vec3 world_normal;
|
|
varying float normal_slope;
|
|
|
|
|
|
float ramp_stop(int i) {
|
|
// GLSL ES 3.0 will not index a const array with a non-constant expression on
|
|
// every driver, so the ramp is selected by branch and the stop by a small
|
|
// unrolled pick. Both indices are uniform-or-loop-constant, so this costs
|
|
// nothing measurable and works everywhere.
|
|
float s[5] = RAMP_3;
|
|
if (ramp_id == 0) { s = RAMP_2; }
|
|
else if (ramp_id == 2) { s = RAMP_4; }
|
|
else if (ramp_id == 3) { s = RAMP_5; }
|
|
else if (ramp_id == 4) { s = RAMP_SOFT; }
|
|
else if (ramp_id == 5) { s = RAMP_SOFT3; }
|
|
float v = s[0];
|
|
if (i == 1) { v = s[1]; }
|
|
else if (i == 2) { v = s[2]; }
|
|
else if (i == 3) { v = s[3]; }
|
|
else if (i >= 4) { v = s[4]; }
|
|
return v / 255.0;
|
|
}
|
|
|
|
|
|
int ramp_bands() {
|
|
if (ramp_id == 0 || ramp_id == 4) { return 2; }
|
|
if (ramp_id == 2) { return 4; }
|
|
if (ramp_id == 3) { return 5; }
|
|
return 3;
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
|
|
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 vertex() {
|
|
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
|
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
|
}
|
|
|
|
|
|
void fragment() {
|
|
vec3 base = albedo_color.rgb;
|
|
if (has_texture) {
|
|
base *= texture(albedo_texture, UV).rgb;
|
|
}
|
|
if (use_vertex_color) {
|
|
base *= COLOR.rgb;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (flat_shading) {
|
|
// VERTEX is view-space position here, so its screen derivatives span the
|
|
// triangle's own plane and their cross product is the true face normal.
|
|
// Forced to face the viewer, because the winding of that cross product
|
|
// flips with the handedness of the projection and a back-to-front normal
|
|
// lights the facet from behind.
|
|
vec3 fn = normalize(cross(dFdx(VERTEX), dFdy(VERTEX)));
|
|
NORMAL = fn * sign(dot(fn, VIEW));
|
|
}
|
|
|
|
ALBEDO = base;
|
|
ROUGHNESS = 1.0;
|
|
SPECULAR = 0.0;
|
|
|
|
// Screen-space rate of turn of the normal, measured here because
|
|
// derivatives are a fragment-stage operation — taking fwidth() inside
|
|
// light(), which runs once per light, is undefined on some drivers.
|
|
// Faceted geometry has a slope of zero across a facet, so this only ever
|
|
// widens the band edge where the surface is genuinely curving.
|
|
normal_slope = length(fwidth(NORMAL));
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
void light() {
|
|
float ndotl = dot(NORMAL, LIGHT);
|
|
|
|
// Quantise. `t` is the reference's ramp lookup coordinate exactly — the
|
|
// texture is sampled at dot(N,L) * 0.5 + 0.5 with NearestFilter, so band
|
|
// boundaries fall at evenly spaced values of that coordinate and nowhere
|
|
// else. With the 3-stop ramp that puts them at dot(N,L) = ±1/3.
|
|
int bands = ramp_bands();
|
|
float fb = clamp(ndotl * 0.5 + 0.5, 0.0, 0.999999) * float(bands);
|
|
int idx = int(floor(fb));
|
|
float frac_b = fb - float(idx);
|
|
|
|
float hi = ramp_stop(idx);
|
|
float lo = ramp_stop(max(idx - 1, 0));
|
|
|
|
// Anti-aliasing budget. band_softness is in NdotL, which says nothing about
|
|
// how many PIXELS a transition covers; normal_slope does. Widening the step
|
|
// to at least the on-screen rate of turn keeps every band edge about a pixel
|
|
// wide, and leaves edges that are already wider exactly as authored. The
|
|
// d(fb)/d(ndotl) factor of bands*0.5 converts the NdotL width into the
|
|
// band-index units `frac_b` is measured in.
|
|
float w = max(band_softness, normal_slope * 0.9) * float(bands) * 0.5;
|
|
float band = mix(lo, hi, smoothstep(0.0, max(w * 2.0, 0.0001), frac_b));
|
|
|
|
// The hue shift. Band drives its own tint: full light is untinted, the
|
|
// darkest band is very nearly pure shadow hue.
|
|
vec3 lit = band * mix(shadow_tint.rgb, vec3(1.0), band);
|
|
|
|
// Cast shadow: down to the darkest stop, fully tinted, never to black.
|
|
float floor_band = ramp_stop(0);
|
|
vec3 occluded = floor_band * mix(shadow_tint.rgb, vec3(1.0), floor_band)
|
|
* cast_shadow_depth;
|
|
float occ = smoothstep(0.0, cast_shadow_softness, ATTENUATION);
|
|
vec3 shade = mix(occluded, lit, occ);
|
|
|
|
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade;
|
|
}
|