ludic/packages/ludic.render3d/shaders/model.frag
Orkuncakilkaya 6a82b4928b render3d/foliage: a small plant is occluded by the sward it stands in, so it no longer glows at night
The user saw kit grass, ferns and flowers lit up at night. Nothing was
emissive and every layer draw binds the scene's lighting; what differed was
occlusion. At night the sky's light is nearly all the light, and it is
scaled by ambient occlusion: the meadow's blades are heavily occluded near
the ground, while a kit plant's own AO map knows nothing of the grass around
it, so it took the full sky. By day the sun hides the difference.

model.vert hands on each vertex's height above the model's base (v_lh), and
a non-blade FOLIAGE plant under 2 m scales its AO from 0.35 at the ground to
1 at 0.9 m. Crowns and the blades are untouched.

Measured in the lab (R3D_AT=tree, same binary, old and new SPIR-V): the fern
against the meadow beside it was 2.05x at 23:00 and 1.66x at 13:00; now 1.58x
and 1.60x - the same relation by night as by day. The flowers' brightest
tenth at night 78 -> 52. The noon frame moves 8.4% of pixels past 8 (the fern
and flower bases a little darker; run-to-run noise is part of that).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:19:10 +03:00

321 lines
16 KiB
GLSL

in vec3 v_wpos;
in vec3 v_nrm;
in vec2 v_uv;
in float v_seed;
in vec2 v_rot;
in float v_hull;
in float v_quake; // an aspen leaf turning its pale side up
in float v_near; // 0 in the camera's face, 1 past arm's length (NEAR_FADE)
#if defined(FOLIAGE) && !defined(BLADE)
in float v_lh; // height above the model's base (model.vert)
#endif
uniform float u_model_h;
out vec4 o_color;
uniform sampler2D u_diff;
uniform sampler2D u_nrm;
uniform sampler2D u_arm;
uniform mat4 u_view;
uniform vec3 u_tint;
uniform float u_rough_scale;
uniform float u_emissive; // self-lit (a flame): albedo added back after shading
#ifdef GBLADE
in vec3 v_tint; // grass.vert's colour field (the mesh-shader and scatter blades work it out here)
#endif
#ifdef BLADE
uniform vec3 u_blade_base;
uniform vec3 u_blade_tip;
uniform sampler2D u_blade_tex; // straightened photographic blades, side by side
uniform float u_blade_cols; // how many are in it
uniform float u_blade_tex_on;
#endif
#ifdef CARD
uniform float u_cull; // the layer's cull distance (m); 0 = none
#endif
mat3 cotangentFrame(vec3 N, vec3 p, vec2 uv) {
vec3 dp1 = dFdx(p), dp2 = dFdy(p);
vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
vec3 dp2perp = cross(dp2, N), dp1perp = cross(N, dp1);
vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
float invmax = inversesqrt(max(dot(T, T), dot(B, B)) + 1e-12);
return mat3(T * invmax, B * invmax, N);
}
void main() {
#ifndef EQ_PASS
// (the EQ_PASS variant draws over the foliage prepass: any discard at all would switch
// early depth rejection off and bring the overdraw back, and the prepass has already
// cut both the clip plane and the alpha)
if (v_wpos.y < u_clip_y) discard;
#ifdef NEAR_FADE
// the same dither as the prepass, for the draws that have no prepass in front of them
if (v_near < 0.999 && v_near < fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453)) discard;
#endif
#endif
vec3 N = normalize(v_nrm);
if (!gl_FrontFacing && v_hull >= 0.0) N = -N;
#ifdef BLADE
// A blade is thin and two-sided: the sun lights whichever face it reaches (the other glows
// through, below). Faced away from the sun, half the meadow was lit by the sky alone.
if (dot(N, u_sun_dir) < 0.0) N = -N;
#endif
#ifdef CARD
// a baked card: albedo with coverage (premultiplied mips), normal in the card's own frame
vec4 ca = texture(u_diff, v_uv);
float rawA = ca.a;
ca.rgb /= max(ca.a, 1e-3);
#ifdef SHADOW_PASS
if (ca.a < 0.3) discard;
float cardAlpha = 1.0;
return;
#else
float cov = (ca.a - 0.4) / max(fwidth(ca.a), 1e-4) + 0.5;
if (cov < 0.02) discard;
float cardAlpha = clamp(cov, 0.0, 1.0);
#endif
#endif
float dist = length(v_wpos - u_cam_pos);
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
#if defined(CARD) && !defined(SHADOW_PASS)
// A clump card at 400 m is a two-pixel disc of pure leaf colour on whatever the ground
// is doing — on dark scree it glows. Dissolve toward the cull distance: the carpet
// drape on the ground carries the meadow's colour from there on.
if (u_cull > 1.0) cardAlpha *= 1.0 - smoothstep(u_cull * 0.55, u_cull, dist);
if (cardAlpha < 0.02) discard;
#endif
vec3 alb;
vec3 n;
vec3 arm;
float meshAlpha = 1.0;
#ifdef BLADE
// a procedural blade: dark at the root, lighter at the tip; some blades gone to seed
float t = clamp(v_uv.y, 0.0, 1.0);
// most of a blade is its own green, only the sheath darker: t*t held the lower two thirds at the
// root colour (0.14, darker than the ground), and a meadow of those reads as dark wire
#ifndef GBLADE
vec3 field = regionTint(v_wpos, 0.35);
float patchy = fbm(v_wpos.xz * 0.045, 3) * 0.5 + 0.5;
field *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
#else
vec3 field = v_tint;
#endif
alb = mix(u_blade_base, u_blade_tip, smoothstep(0.0, 0.55, t)) * field;
float dry = smoothstep(0.7, 0.8, fract(v_seed * 3.17));
alb = mix(alb, vec3(0.40, 0.36, 0.13) * (0.45 + 0.55 * t), dry * 0.75);
// A REAL BLADE, if the game gave us one. Each blade picks a column of the atlas from
// its own seed and runs v from sheath to tip, so a meadow is eight different plants
// rather than one plant ten thousand times. The photograph carries the midrib, the
// olive-to-straw run and the dry browning; the gradient above stays as the tint that
// the season and the lushness drive, so nothing that used to control the colour stops
// working - the picture multiplies it rather than replacing it.
if (u_blade_tex_on > 0.5) {
float col = floor(fract(v_seed * 7.31) * u_blade_cols);
vec2 buv = vec2((col + clamp(v_uv.x, 0.0, 1.0)) / u_blade_cols, 1.0 - t);
vec3 photo = texture(u_blade_tex, buv).rgb;
// Normalised by the atlas's OWN MEAN LUMINANCE - a measured constant, 0.3736 over the
// opaque pixels - and not by each pixel's mean. Dividing by the per-pixel mean was the
// bug: it cancels exactly the thing the photograph was fetched for. What survives is
// the hue ratio, so every blade comes back out at the same brightness and the midrib,
// the dry browning and the sheath-to-tip run all vanish. It looked like a faint tint
// over the old procedural blade, which is precisely what it was.
// Blended, not applied whole. At full strength a photo pixel brighter than the
// atlas's mean is multiplied by up to 1.9, and the bright blades came out white -
// straws in a green sward. Three quarters of the photograph keeps the midrib, the
// browning and the sheath-to-tip run and leaves the extremes alone.
// CLAMPED. The atlas's mean luminance is 0.3736, so a blade pixel brighter than the
// mean is multiplied by up to 2.7 and comes out white - and a white blade is the one
// thing grass never is. Holding the factor to 1.15 keeps the midrib and the browning,
// which are the parts of the photograph worth having, and refuses the bleach.
vec3 g = photo * (1.0 / 0.3736);
alb *= mix(vec3(1.0), min(g, vec3(1.15)), 0.85);
}
// a far tuft is a patch of the meadow, darker than a lit blade tip and never straw
if (v_hull < 0.0) alb = mix(u_blade_base, u_blade_tip, 0.45) * field * 0.72;
// a rounded cross-section reads softer than a flat card
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = (v_hull < 0.0) ? N : normalize(N + side * (v_uv.x * 2.0 - 1.0) * 0.6);
// and a sward is lit as a mass leaning to the sky: a level normal took the light at a graze
n = normalize(n + vec3(0.0, 0.45, 0.0));
// A blade's own ambient occlusion. This was mix(0.2, 1.0, t*t): 80% occluded at the
// sheath and still 60% at half height, because t*t holds the curve down. Measured in a
// walking-distance shot the blades came out at 24-31 of 255 against a ground of 143 -
// near-black on light earth - and at that contrast the eye reads every blade as a hard
// EDGE rather than as a mass of vegetation, whatever shape it is. No albedo can answer
// an occlusion term; grass albedo is capped near 0.5 and this was dividing it by five.
// A blade is a thin thing standing in open air: shaded at the root by its neighbours,
// not buried.
arm = vec3(mix(0.55, 1.0, t), 0.85, 0.0);
#elif defined(CARD)
// thin grass is lit from either side: face the card toward the sun before shading
if (dot(N, u_sun_dir) < 0.0) N = -N;
// the normal atlas is premultiplied by coverage like the albedo (see impostor.frag)
vec4 cn = texture(u_nrm, v_uv) / max(rawA, 1e-3);
cn = clamp(cn, 0.0, 1.0);
vec3 bn = cn.rgb * 2.0 - 1.0;
// the card frame: right along the quad, up, and out of the quad
vec3 T = normalize(cross(vec3(0.0, 1.0, 0.0), N) + vec3(1e-5));
n = normalize(T * -bn.x + vec3(0.0, 1.0, 0.0) * bn.y + N * max(abs(bn.z), 0.25));
n = normalize(mix(n, normalize(N + vec3(0.0, 0.8, 0.0)), 0.35));
// A clump is a few pixels at 100 m: what the eye reads there is the hillside's shading,
// and a card facing the sun on a slope facing away from it glows against the ground
// like a sticker. Light it with the ground's own normal as it recedes (as the blades
// already do), so cover and terrain darken together.
#ifdef CHEAP
n = terrainNormalAt(v_wpos);
#else
n = normalize(mix(n, terrainNormalAt(v_wpos), smoothstep(25.0, 90.0, dist)));
#endif
alb = ca.rgb * 1.05 * regionTint(v_wpos, 0.8);
arm = vec3(mix(0.5, 1.0, clamp(v_uv.y, 0.0, 1.0)) * (0.6 + 0.4 * cn.a), 0.85, 0.0);
#elif defined(FLOWER)
// a lupine: green stem (uv.x < 1), violet florets above (uv.x in [1,2]), tinted per plant
float hue = fract(v_seed * 5.71);
float vy = clamp(v_uv.y, 0.0, 1.0);
vec3 violet = mix(vec3(0.07, 0.03, 0.32), vec3(0.28, 0.06, 0.36), hue);
vec3 tipc = mix(violet, vec3(0.5, 0.3, 0.7), 0.3);
if (v_uv.x >= 2.0) {
alb = vec3(0.05, 0.13, 0.025) * (0.7 + 0.6 * vy);
} else if (v_uv.x >= 1.0) {
float f = fract(v_uv.x);
alb = mix(violet, tipc, vy) * (0.65 + 0.35 * abs(f * 2.0 - 1.0));
// florets as little lobes: darker between them, a paler lip on each
float lobe = 0.55 + 0.45 * abs(sin(vy * 9.0 + f * 6.0));
alb = mix(alb * lobe, vec3(0.55, 0.45, 0.75), 0.18 * smoothstep(0.6, 1.0, lobe));
} else {
alb = vec3(0.07, 0.16, 0.03);
}
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = normalize(N + side * (fract(v_uv.x) * 2.0 - 1.0) * 0.7);
arm = vec3(0.9, 0.7, 0.0);
#else
vec4 d = texture(u_diff, v_uv);
#ifdef ALPHA_TEST
// A needle sprig's alpha averages away in the mips, so a plain 0.5 test strips the
// crown bare past 50 m. Scale the alpha back up by the mip level (Castano's alpha
// mipmaps, done at sample time) and sharpen the edge with its screen derivative, then
// let alpha-to-coverage resolve it.
float lod = textureQueryLod(u_diff, v_uv).x;
float a = min(d.a * (1.0 + 0.45 * max(lod, 0.0)), 1.0);
float cov = (a - 0.4) / max(fwidth(a), 1e-4) + 0.5;
if (cov < 0.02) discard;
meshAlpha = clamp(cov, 0.0, 1.0);
#endif
vec3 tn = texture(u_nrm, v_uv).rgb * 2.0 - 1.0;
mat3 tbn = cotangentFrame(N, v_wpos, v_uv);
n = normalize(tbn * tn);
arm = texture(u_arm, v_uv).rgb;
n = normalize(mix(n, N, smoothstep(30.0, 120.0, dist)));
alb = d.rgb;
// a crown's interior is occluded by its own cards
if (u_model_h > 2.0) arm.r *= mix(0.5, 1.0, v_hull);
#endif
alb *= u_tint * (0.85 + 0.3 * fract(v_seed * 7.13));
#if defined(FOLIAGE) && !defined(BLADE)
// A fern or a clump stands IN the sward, and its low leaves see the sky through it no more than the
// blades beside them do. Its own occlusion map knows nothing of the grass around it, so by night -
// when the sky's light is nearly all the light there is - it stood out lit against the meadow.
if (u_model_h <= 2.0) arm.r *= mix(0.35, 1.0, smoothstep(0.0, 0.9, v_lh));
#endif
// The quake made visible. A turning aspen leaf shows its pale, almost white underside, so
// the crown does not merely move - it GLITTERS, leaf by leaf, and that is what reads at a
// distance and in a still frame. Toward the viewer is the pale side; away is the face.
// The quake, as a LIGHTENING rather than a repaint. This used to mix up to 75% toward
// a fixed near-white (0.42,0.45,0.33), which was written for a hand-painted leaf atlas
// that had no pale underside of its own. On a photographed leaf it does not read as a
// turning leaf at all - it reads as parts of the tree going WHITE, because that is
// exactly what it does: it replaces three quarters of the leaf's colour with a constant.
// Nothing in a wood turns white in the sun. A real turning leaf shows a paler, greyer
// version of ITSELF, so lift and desaturate the leaf's own colour instead.
if (v_quake > 0.0) {
float q = min(v_quake * 2.6, 0.75);
float l = dot(alb, vec3(0.2126, 0.7152, 0.0722));
alb = mix(alb, mix(alb, vec3(l), 0.45) * 1.30, q);
} else if (v_quake < 0.0) {
alb *= 1.0 + v_quake * 0.28; // the dark half stays gentle: a crown should shimmer, not flicker
}
#if defined(FOLIAGE) && !defined(BLADE)
// Per-plant HUE, not only per-plant brightness. The line above varies value by +-15% and
// nothing else, so a stand of one species was one colour at fifteen different exposures -
// and a real stand is not: neighbouring trees of the same species sit anywhere from
// yellow-green to blue-green depending on age, aspect and how dry the ground under them is.
// Trading red against blue is a hue rotation about green, which is the axis that matters for
// foliage and costs two multiplies. Blades are excluded: they get their own patchy field
// and regionTint already, and doubling up on them reads as noise rather than variety.
float hj = fract(v_seed * 3.71) - 0.5;
alb *= vec3(1.0 + 0.20 * hj, 1.0, 1.0 - 0.20 * hj);
#endif
#ifdef CARD
// a card is its own caster: look up the shadow a little above and in front of it, and let
// light bleed through the thin clump as real grass does
#ifdef CHEAP
float shadow = cloudShadow(v_wpos) * terrainShadow(v_wpos);
#else
float shadow = mix(1.0, sunShadow(v_wpos + N * 0.1 + vec3(0.0, 0.2, 0.0), N, viewDepth), 0.55);
#endif
#elif defined(BLADE)
// A blade stands within a shadow texel of the ground under it, and biased along its own
// (level) normal it read the terrain as its caster: the whole meadow in shadow, dark wire.
// Looked up a hand's width up, along the ground's up, trees and rocks still shade it.
// past arm's length a blade is a pixel or two wide: one tap is what a soft filter averages to
float shadow = (dist < 8.0) ? sunShadow(v_wpos + vec3(0.0, 0.25, 0.0), vec3(0.0, 1.0, 0.0), viewDepth)
: sunShadowCheap(v_wpos + vec3(0.0, 0.25, 0.0), vec3(0.0, 1.0, 0.0), viewDepth);
#else
float shadow = sunShadow(v_wpos, N, viewDepth);
#endif
#ifdef FOLIAGE
// leaves and needles are matte at every angle: no grazing Fresnel on a two-sided card
float roughF = 1.0;
#else
float roughF = clamp(arm.g * u_rough_scale, 0.35, 1.0);
#endif
vec3 col = shade(v_wpos, n, alb, roughF, 0.0, arm.r, shadow, viewDepth);
#ifdef FOLIAGE
// thin-leaf translucency: light leaking through toward the viewer, and a wrapped diffuse
vec3 v = normalize(u_cam_pos - v_wpos);
float back = pow(max(dot(-v, u_sun_dir), 0.0), 3.0);
float wrap = max(dot(N, u_sun_dir) * 0.5 + 0.5, 0.0);
// Only a thin leaf a few metres away is translucent. A clump card at 200 m is a whole
// bush in two pixels, and giving it the leaf's glow toward the sun painted the
// backlit hillsides with lime discs. The term fades out with distance.
// A backlit stand glows for as far as you can see it, not 140 m. The cap below is what
// keeps a two-pixel clump card at distance from becoming a lime disc.
float thin = 1.0 - smoothstep(60.0, 260.0, dist);
// a dense crown of cards is not a thin leaf: much less light comes through it
if (u_model_h > 2.0) thin *= 0.45;
col += alb * u_sun_color * (0.24 * back + 0.07 * wrap) * thin * shadow * cloudShadow(v_wpos);
col += alb * skyIrradiance(vec3(0, 1, 0)) * 0.12 * arm.r;
// ---- leaf sheen ---------------------------------------------------------------------
// A LEAF IS NOT MATTE. Foliage roughness is pinned to 1.0 just above and grazing Fresnel
// is switched off, so nothing green in this game has ever had a highlight - and the glint
// off wet needles and waxy leaves is most of what makes a real stand look alive rather
// than painted. The reason it was switched off is real, though: a crown is card quads, and
// at a grazing angle the card's normal is a lie, so a plain specular lobe frosted whole
// crowns white against the sky.
//
// So the sheen is added back as its own term and gated on exactly that: it fades out as
// the card turns edge-on (NoV), which is where its normal stops meaning anything. A tight
// lobe for the glint, a weak wide one for the waxy rim, and nothing at all at the angles
// that used to frost.
{
vec3 vv = normalize(u_cam_pos - v_wpos);
vec3 hh = normalize(vv + u_sun_dir);
float NoH = max(dot(n, hh), 0.0);
float NoV = max(dot(n, vv), 0.0);
float sheen = pow(NoH, 26.0) * 0.55 + pow(1.0 - NoV, 4.0) * 0.05;
sheen *= smoothstep(0.10, 0.42, NoV);
#ifdef BLADE
sheen *= 0.2; // a sward seen from above is tips edge-on to the sun: the leaf's glint frosted them white
#endif
col += u_sun_color * sheen * shadow * cloudShadow(v_wpos) * 0.4 * (0.45 + 0.55 * arm.r);
}
#endif
col += alb * u_emissive;
if (any(isnan(col))) col = vec3(0.0);
col = applyFog(col, v_wpos, dist);
#ifdef CARD
o_color = vec4(sane(col), cardAlpha);
#else
o_color = vec4(sane(col), meshAlpha);
#endif
}