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; }