#version 300 es precision mediump float; in highp vec3 vNormal; in highp vec3 vWorldPos; in vec4 vColor; in vec3 vSurface; // x = wash lift, y = contour weight, z = ink in vec3 vPen; // x = wet, y = paper seed, z = flat #include "_graphics_uniforms.glsl" #include "_fog.glsl" out vec4 fragColor; // one ink for every line on the page. LINEAR, not authored sRGB: it is pigment, not a tint, and it // is the same pigment whatever the wash under it happens to be const vec3 INK = vec3(0.030, 0.028, 0.036); const vec2 HATCH_A = vec2(0.70710678, 0.70710678); const vec2 HATCH_B = vec2(0.70710678, -0.70710678); vec3 srgbToLinear(vec3 c) { return pow(c, vec3(2.2)); } // hatching is SCREEN space, deliberately. a pen hatches the PAGE: three hundred bricks laid at three // hundred angles have to share one stroke direction or the crust stops reading as one drawing and // starts reading as three hundred separately shaded solids. the cost is that the strokes do not // travel with the object — which is exactly what a printed illustration does anyway. // duty is COVERAGE, not value: the ink is always the same black, and how much of the surface the // strokes cover is what carries the tone. // period is in FRAMEBUFFER pixels and there is no resolution uniform to normalize it against — so a // denser display prints a finer hatch, which is what a denser plate does float hatch(highp vec2 fragCoord, float period, float duty, vec2 dir) { highp float d = dot(fragCoord, dir) / period; float band = abs(fract(d) - 0.5) * 2.0; // 0 on a stroke, 1 midway between two return 1.0 - smoothstep(duty, duty + 0.30, band); } // paper tooth. highp throughout: the hash feeds on pixel coords in the thousands, and a mediump // fract() up there quantizes to bands float tooth(highp vec2 p) { highp vec2 cell = floor(p); return fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453); } void main() { highp vec2 fragCoord = gl_FragCoord.xy; float ink = clamp(vSurface.z, 0.0, 1.0); float wet = clamp(vPen.x, 0.0, 1.0); float seed = vPen.y; float plate = vPen.z; // 1 = line art: flat wash and a contour, no shading and no hatching // a hit is fresh ink, so it counts as weight while it is still wet float weight = clamp(ink + (wet * 0.5), 0.0, 1.0); vec3 base = srgbToLinear(vColor.rgb); vec3 n = normalize(vNormal); vec3 v = normalize(u_cameraPos - vWorldPos); // this game runs shadowStrength 0, so the cascade sampling of primitive.frag is left out entirely // rather than multiplied away float ndl = max(dot(n, u_lightDir), 0.0); float hemi = (n.y * 0.5) + 0.5; vec3 ambient = mix(u_ambientGround.rgb * u_ambientGround.a, u_ambientSky.rgb * u_ambientSky.a, hemi); vec3 lit = ambient + (u_lightColor.rgb * (u_lightColor.a * ndl)); // CEL — three hard steps with nothing between them. a gradient is the one thing a press cannot // print, so the lit term is reduced to a key value and then thrown into a band float key = clamp(dot(lit, vec3(0.299, 0.587, 0.114)), 0.0, 2.0); float wash = (key > 0.68) ? 1.00 : ((key > 0.40) ? 0.86 : 0.70); float bandTone = (key > 0.68) ? 0.00 : ((key > 0.40) ? 0.26 : 0.58); // the tone the pen is asked for: the light sets the floor, the damage piles on top. this is why // the crust reads as a line-weight map — a worked brick is hatched whatever the light is doing float tone = clamp((bandTone + (weight * 0.55)) * (1.0 - plate), 0.0, 1.0); // the gate is what keeps bare paper bare: below it no stroke is laid at all, rather than a // hairline everywhere, and paper left alone is what makes the ink elsewhere read as ink float cover = hatch(fragCoord, 7.0, (tone * 0.55), HATCH_A) * step(0.10, tone); // the second set only arrives on heavily worked ink — crosshatch is the darkest tone a pen has, // and spending it early leaves nothing to say about a brick that is actually about to give float crossTone = smoothstep(0.55, 0.95, tone); cover = max(cover, hatch(fragCoord, 9.5, (0.08 + (0.34 * crossTone)), HATCH_B) * step(0.03, crossTone)); // faint value break-up so a flat area is never dead flat. the seed shifts it per instance, and // never touches the hatch, which has to stay continuous across every brick at once float grain = tooth((fragCoord * 0.5) + vec2(seed * 37.0, seed * 61.0)); vec3 outColor = base * mix(wash, 1.0, plate) * (1.0 - (0.055 * grain)); // ink lands by REPLACEMENT. a pen puts pigment on top of a wash; it does not add light to it, // which is what every additive rim in this family of variants does and why none of them are paper outColor = mix(outColor, INK, cover * (0.60 + (0.30 * weight))); // the CONTOUR. a fresnel band tightened until it is a line rather than a rim: below the start // there is nothing at all, and the whole width of the band lives in the last few percent of the // silhouette. it widens with ink, so a spent brick is simply drawn more heavily if (vSurface.y > 0.0) { float rim = 1.0 - max(dot(n, v), 0.0); float start = mix(0.76, 0.56, weight); float edge = smoothstep(start, start + 0.14, rim); outColor = mix(outColor, INK, clamp(edge * vSurface.y, 0.0, 1.0)); } // the wet blot itself — the darkest the surface ever gets, gone in a tenth of a second. this is // the frame a hit is READ from; the thickened line it leaves behind is the aftermath outColor = mix(outColor, INK, (wet * 0.35)); // wash lift: a little of the bare page pushed back through the pigment. this is the only thing // resembling emissive that a printed surface has, and it stays under 1 outColor += base * vSurface.x; outColor = mix(outColor, u_fogColor.rgb, fogFactor(vWorldPos)); fragColor = vec4(outColor, vColor.a); }