Add buildable C engine, shaders and music source

This commit is contained in:
CRTkafa 2026-09-05 11:16:54 +07:00
parent 1d805b8896
commit 18721711c4
44 changed files with 23427 additions and 3 deletions

315
fx/signoff.hlsl Normal file
View file

@ -0,0 +1,315 @@
// The only text in the demo.
//
// crt.fyi
// hi@crt.fyi
//
// Two lines, small, once. The author was explicit about that and it is the
// right call: a demo that tells you who made it in forty-point letters is
// advertising, and a demo that puts it in the corner once at the end is a
// signature. So this file is not allowed to grow the type or centre it. What
// it IS allowed to do is make the eleven seconds it owns worth watching:
// the line writes itself, character by character, with a caret ahead of it
// and the phosphor still hot behind it.
//
// No glyph atlas. Between them these two lines use eleven distinct
// characters, and a monoline stroke distance field draws them from about
// thirty line segments - less code than the loader for an atlas would be,
// and it stays sharp at any size because it is analytic. The strokes are
// deliberately even-weight and slightly wide: at 360 lines a typographic
// contrast would just alias into mush.
//
// gTune.x typing speed, 1.0 = the tuned default (about eleven glyphs a
// second). The reveal used to be an absolute 0..1 fed from the shot
// table, and the shot table fed it a constant 1.0 - so the "typing"
// the comment promised never happened once. It is derived from
// gTime.x now, and gTune.x only scales the rate.
// gTune.y size
// gTune.z which line: 0 both, 1 only the address
// gTune.w the matte, as a fraction of the height hidden at each edge
cbuffer Scene : register(b0)
{
float4 gTime;
float4 gCam;
float4 gDir;
float4 gTune;
float4 gSync;
float4 gVoice;
};
struct VSOut
{
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
};
// distance to a line segment, the primitive everything here is built from
float sdSeg(float2 p, float2 a, float2 b)
{
float2 pa = p - a, ba = b - a;
float h = saturate(dot(pa, ba) / dot(ba, ba));
return length(pa - ba * h);
}
// distance to an arc of a circle, for the round letters. a0..a1 in radians.
float sdArc(float2 p, float2 c, float r, float a0, float a1)
{
float2 q = p - c;
float a = atan2(q.y, q.x);
// wrap the angle into [a0, a0+2pi) so the comparison is unambiguous
float w = a - a0;
w -= 6.28318530718 * floor(w / 6.28318530718);
if (w <= a1 - a0) return abs(length(q) - r);
// outside the sweep: the nearer of the two end points
float2 e0 = c + float2(cos(a0), sin(a0)) * r;
float2 e1 = c + float2(cos(a1), sin(a1)) * r;
return min(length(p - e0), length(p - e1));
}
// Each glyph is drawn in a box 0.62 wide and 1.0 tall, with the baseline at
// y = 0 and the x-height at 0.58 - the proportions of a plain grotesque.
float glyph(float2 p, int c)
{
float d = 1e9;
if (c == 0) { // c
d = sdArc(p, float2(0.31, 0.31), 0.27, 0.55, 5.73);
} else if (c == 1) { // r
// The arm used to sweep from three o'clock, which hooks it down past
// the middle of the x-height and makes the r read as a curl. A
// grotesque r lets go earlier and higher: the shoulder tops out AT
// the x-height and the terminal stops just under it.
d = min(sdSeg(p, float2(0.14, 0.0), float2(0.14, 0.58)),
sdArc(p, float2(0.34, 0.40), 0.19, 0.30, 2.60));
} else if (c == 2) { // t
d = min(sdSeg(p, float2(0.24, 0.10), float2(0.24, 0.86)),
sdSeg(p, float2(0.06, 0.58), float2(0.44, 0.58)));
// the tail bottoms out ON the baseline and lifts again, instead of
// ending nose-down under it
d = min(d, sdArc(p, float2(0.38, 0.14), 0.14, 3.14, 4.95));
} else if (c == 3) { // .
d = length(p - float2(0.16, 0.065)) - 0.048;
} else if (c == 4) { // f
d = min(sdSeg(p, float2(0.26, 0.0), float2(0.26, 0.72)),
sdSeg(p, float2(0.08, 0.58), float2(0.46, 0.58)));
d = min(d, sdArc(p, float2(0.40, 0.72), 0.14, 1.57, 3.14));
} else if (c == 5) { // y
d = min(sdSeg(p, float2(0.08, 0.58), float2(0.30, 0.06)),
sdSeg(p, float2(0.52, 0.58), float2(0.20, -0.22)));
} else if (c == 6) { // i
d = sdSeg(p, float2(0.16, 0.0), float2(0.16, 0.58));
// The dot has to clear the STROKE, not the centreline. Ink extends
// w = 0.070 glyph units past the geometry on both sides, so a dot at
// 0.775 with r 0.048 left 0.775-0.048-0.070-0.58 = 0.007 units of
// gap: a sixth of a virtual pixel. The two merged and the demo spelt
// the author's own domain "crt.fyl". At 0.88 with a slightly smaller
// dot the gap is 0.120 units, two and a half virtual lines of real
// dark, and it reads as an i.
d = min(d, length(p - float2(0.16, 0.88)) - 0.040);
} else if (c == 7) { // h
// shoulder raised so the h reaches the same x-height as the c and
// the r; it was sitting two hundredths low and the word looked like
// it had a dip in it
d = min(sdSeg(p, float2(0.12, 0.0), float2(0.12, 0.90)),
sdSeg(p, float2(0.44, 0.0), float2(0.44, 0.42)));
d = min(d, sdArc(p, float2(0.28, 0.42), 0.16, 0.0, 3.14));
} else if (c == 8) { // @
// The inner ring has to clear the outer one by more than the ink
// reaches, or the counter fills in and the @ reads as a solid disc.
// The comment that used to sit here claimed this was measured. It was
// not, and it was wrong: at r 0.095 the annulus is
// (0.31 - 0.070) - (0.095 + 0.070) = 0.075 glyph units
// which at this size is 1.46 virtual pixels - BELOW the threshold the
// i's dot just established at 2.34. So the c beside it kept its
// counter and the @ filled in, and the demo signed off with a blob in
// the middle of the author's email address. 0.05 puts the annulus at
// 0.120 units, the same gap that works for the i.
d = sdArc(p, float2(0.31, 0.34), 0.31, 0.60, 6.10);
d = min(d, sdArc(p, float2(0.31, 0.34), 0.05, 0.0, 6.28));
d = min(d, sdSeg(p, float2(0.405, 0.34), float2(0.405, 0.15)));
}
return d;
}
// crt.fyi -> c r t . f y i (index 0, seven glyphs)
// hi@crt.fyi -> h i @ c r t . f y i (index 7, ten glyphs)
// One table, two offsets, so the loop can only ever index inside it.
static const int kText[17] = { 0, 1, 2, 3, 4, 5, 6,
7, 6, 8, 0, 1, 2, 3, 4, 5, 6 };
// METRICS. Every glyph used to advance 0.72 whatever it was, which is a
// teletype and not a grotesque: the period and the i sat in the middle of
// slots twice their width and "crt.fyi" came out with two holes punched in
// it. kLsb pulls each glyph over so its ink starts at the origin, kAdv is
// the ink width plus about 0.30 of tracking - the round letters tucked in by
// the usual optical hundredths, and the i and the @ given back what the ring
// and the bare stem need so "hi@" does not collide. The address ends up
// fifteen percent tighter than it was, which is the opposite of enlarging it.
// c r t . f y i h @
static const float kLsb[9] = { 0.04, 0.14, 0.06, 0.112, 0.08, 0.08, 0.16, 0.12, 0.00 };
static const float kAdv[9] = { 0.80, 0.70, 0.68, 0.40, 0.66, 0.70, 0.34, 0.62, 0.94 };
// One line of type, written on. Returns
// x ink coverage 0..1
// y tight phosphor glow the beam spot
// z halation light scattered in the glass
// w the write head, in glyph units, for the caret
//
// prog is measured in glyph slots: 3.4 means three characters are down and
// the fourth is 40 percent of the way through its slot.
float4 drawLine(float2 p, int base, int n, float prog, float fw, float w)
{
float ink = 0.0, hot = 0.0, hal = 0.0;
float x = 0.0, head = 0.0;
[loop] for (int k = 0; k < 10; k++)
{
if (k >= n) break;
float age = prog - (float)k;
if (age <= 0.0) break; // not typed yet
int c = kText[base + k];
float d = glyph(p - float2(x - kLsb[c], 0.0), c) - w;
// A character does not fade in over a fifth of a second - it is
// struck. Two frames from nothing to solid, and then the phosphor
// where it landed stays hot for about a sixth of a second after.
float f = saturate(age * 3.2);
float s = exp(-max(age - 1.0, 0.0) * 0.55);
ink = max(ink, (1.0 - smoothstep(0.0, fw, d)) * f);
hot = max(hot, exp(-max(d, 0.0) / 0.22) * f * (1.0 + 1.20 * s));
hal = max(hal, exp(-max(d, 0.0) / 0.45) * f);
head = x + kAdv[c] * saturate(age * 1.8);
x += kAdv[c];
}
return float4(ink, hot, hal, head);
}
float4 main(VSOut i) : SV_Target
{
float2 uv = float2(i.uv.x * 2.0 - 1.0, 1.0 - i.uv.y * 2.0);
uv.x *= gTime.w;
// Derivatives before any branch: below this point whole waves get thrown
// away by the bounding box and a gradient taken in there is undefined.
float px = fwidth(uv.x);
// 0.055 was chosen on paper and measured wrong on screen: at 360 lines
// the strokes came out under two pixels and the letterforms turned to
// mush. Small enough to be a signature, big enough to be legible, is
// about twice that.
float sz = 0.115 * max(gTune.y, 0.15);
// 0.16 of the glyph box is not a monoline, it is a marker pen: adjacent
// strokes merged and every letter turned into a blob. A real monoline
// grotesque sits nearer 7 percent, and at that weight the counters in
// the c, the r and the @ survive.
float w = 0.070; // stroke half width, glyph units
// Bottom left, with a margin. Not centred: centred type is a title card,
// and this is meant to be found rather than presented.
//
// TITLE SAFE. The bottom of the PICTURE is not the bottom of the frame:
// the outro closes a letterbox over this shot, and bars of b hide
// everything below y = -1 + 2b. Placed at a fixed -0.62 the second line
// sat at 0.896 in screen uv against a matte cutting at 0.872, so the
// author's own email address was sliced through the middle. Deriving the
// position from the matte instead of from a constant means the type
// stays legible whatever the edit does to the aspect ratio.
float bottom = -1.0 + gTune.w * 2.0;
// o0 is the FIRST line, so it clears the edge by the margin, plus the
// second line's descender, plus the line spacing.
float2 o0 = float2(-gTime.w + 0.20, bottom + 0.10 + sz * (0.22 + 1.5));
float2 o1 = o0 - float2(0.0, sz * 1.5);
// ---- bounding box -----------------------------------------------------
// Seventeen glyphs were being evaluated at every pixel of a 1920x1080
// frame to draw two words in one corner. Ninety-odd percent of the frame
// is nowhere near the type; skipping it there is what pays for the
// second and third light terms below. The window is smooth to the edge
// so the halation cannot leave a rectangle behind it.
float2 imin = float2(o1.x - 0.30 * sz, o1.y - 0.42 * sz);
float2 imax = float2(o1.x + 6.25 * sz, o0.y + 1.00 * sz);
// The margin is not slack, it is the only thing standing between the
// halation and a visible rectangle: the window has to finish its work
// out where the glow is already under the dither floor, or the place it
// cuts reads as a straight edge in the dark.
float marg = 2.00 * sz;
float2 dd = max(imin - uv, uv - imax);
float e = max(max(dd.x, dd.y), 0.0);
if (e > marg) return float4(0.0, 0.0, 0.0, 1.0);
float win = 1.0 - smoothstep(marg * 0.55, marg, e);
// ---- the reveal -------------------------------------------------------
// Eleven glyphs a second: fast enough that it is a signature being
// written and not a crawl, slow enough that you can read it happening.
// The first line clears in nine tenths of a second, the address is down
// by two, and the rest of the shot is the caret blinking on it.
float rate = 11.0 * max(gTune.x, 0.20);
float t = gTime.x - 0.26; // let the dissolve land first
float lead = (gTune.z < 0.5) ? 9.0 : 0.0;// seven slots of line, two of pause
float p0 = t * rate;
float p1 = t * rate - lead;
// one virtual scanline of tracking error, so the type sits IN the signal
// rather than on top of it. Tenths of a pixel - it must never be legible
// as a wobble, only as the picture not being perfectly still.
float2 q = uv;
q.x += 0.0009 * sin(uv.y * 88.0 + gTime.x * 3.7) * (0.45 + gSync.x);
float fw = px * 1.5 / sz; // one pixel, in glyph units
float4 L0 = float4(0, 0, 0, 0), L1;
if (gTune.z < 0.5) L0 = drawLine((q - o0) / sz, 0, 7, p0, fw, w);
L1 = drawLine((q - o1) / sz, 7, 10, p1, fw, w);
float ink = max(L0.x, L1.x);
float hot = max(L0.y, L1.y);
float hal = max(L0.z, L1.z);
// ---- the caret --------------------------------------------------------
// It leads the writing, drops to the second line at the break, and once
// the address is down it stays behind it and blinks at one hertz. A
// secondary element with its own clock, which is what stops the last
// second of a held shot from being a still.
bool onSecond = (p1 > 0.0) || (gTune.z >= 0.5);
float2 co = onSecond ? o1 : o0;
float ch = onSecond ? L1.w : L0.w;
float cp = onSecond ? p1 : p0;
float done = saturate((p1 - 10.0) * 1.5);
// solid while it is writing, blinking once it has nothing left to write
float blink = lerp(1.0, smoothstep(-0.22, 0.22, cos(gTime.x * 6.28)), done);
// and it only exists at all once the first character has landed
float cOn = saturate(cp * 2.0) * blink;
float2 cq = (q - co) / sz - float2(ch + 0.13, 0.0);
float cd = sdSeg(cq, float2(0.0, -0.02), float2(0.0, 0.60)) - w * 0.85;
ink = max(ink, (1.0 - smoothstep(0.0, fw, cd)) * cOn * 0.85);
hot = max(hot, exp(-max(cd, 0.0) / 0.22) * cOn * 0.60);
// ---- the row the beam is writing --------------------------------------
// A tube does not draw a letter, it draws a line and turns the beam on
// in the middle of it. So the row under the active text carries a little
// warmth, brightest just after a character lands, and it decays once the
// line is finished. It has to hug the x-height: at a lazier falloff it
// stops being a scan line and becomes a lit rectangle behind the type,
// which is a caption box, which is the one thing this must not look like.
float2 bp = (q - co) / sz;
float bx = max(max(-0.20 - bp.x, bp.x - ch - 0.20), 0.0);
float bt = saturate(cp * 2.0) * (0.30 + 0.70 * exp(-max(cp - 10.0, 0.0) * 1.2));
float band = 0.030 * bt
* exp(-abs(bp.y - 0.29) / 0.17) * exp(-bx / 0.30)
* (1.0 + 0.80 * exp(-frac(max(cp, 0.0)) * 6.0));
// ---- phosphor ---------------------------------------------------------
// Three terms, not one: the beam spot tight around the stroke, the
// halation that scatters through the glass and puts a soft field around
// the whole word, and the ink itself. The glow used to be doing the job
// of the first two at once, which is why it read as a second, fatter
// stroke instead of as light. Only the two GLOW terms move with the
// music - the ink never does, so nothing here can flash.
float lift = 1.0 + 0.34 * gVoice.z + 0.22 * gSync.w;
float3 col = float3(0.68, 0.94, 0.72)
* (ink + (hot * 0.18 + hal * 0.020 + band) * lift) * win;
return float4(col * gTime.z, 1.0);
}