Gee, I guess I’d better write up some of the things I’ve been meaning to for you, hadn’t I?
About CAT16 instead of Bradford:
Literally just replace xyzToLms with
{{0.401288, 0.650173, -0.051461},
{-0.250268, 1.204414, 0.045854},
{-0.002079, 0.048952, 0.953127}}
and replace the inverse matrix too.
(Note, that’s row major; you’ll need to flip it for GLSL/slang.)
About Demodulators and Clipping:
It looks like these demodulators are multiplying red by about 1.3. (That sounds reasonable as far as color correction goes.) It looks like you’re preventing that from clipping by turning the saturation knob down to 75%ish. Is that correct? Can we confirm with actual hardware that (255, 0, 0) red was clipping with the saturation knob at 100%?
(Television broadcasts probably wouldn’t have been clipping with a 1.3 multiplier and the saturation knob at 100% because they were supposed to be sticking to 75% color bars and all that. I think?.. Video game consoles were a hack that didn’t always follow the rules.)
I bet there’s a straightforward math solution to find where to set the saturation knob to just barely avoid clipping.
About Modulators:
It took me a long time to figure out where the 0.2 was coming from in your CXA1145 emulation. If I understand correctly, the idea is that the top of the color burst is supposed to be at 20 IRE, relative to white’s 100 IRE, hence multiplying those chroma/burst ratios by 0.2 gets them relative to white at 1.0. Buuut that’s not quite right. The datasheet says white’s Y level is 0.71v, which we can assume really means 5/7v, or exactly 100 IRE. But it says burst is 0.29vpp. Divide by 2 to get the peak at 0.145v, or 20.3 IRE, which is not quite to spec. So the multiplier should be 0.203 rather than 0.2. The successor chip CXA1645 has the same angles and ratios, but a lower burst amplitude, resulting in a slightly different multiplier.
Which consoles had which modulators? I’ve got some partial information here: According to one of your posts, SNES had CXA1145. (Do you have a pdf of the sheet for that?) According to this, first generation Genesis mostly had CXA1145, but some had Fujitsu MB3514; second generation Genesis had four possibilities; and third generation Genesis all had CXA1645. (More info on Genesis.) Playstation 1 had CXA1645. (I had a better source for this, but I lost it. Here’s one though.) Neo-geo had CXA1145.
We can make a R’G’B’ to R’G’B’ matrix out of a modulator by multiplying the inverse of an idealized modulator with the actual modulator.
Back to Demodulators:
(Yeah, I’m bouncing around since my thoughts aren’t very organized tonight.)
If we assume overlapping tubes or overlapping demodulators implies the same phosphors, we have (at least) this cluster of Trinitrons:
year |
model |
tube |
demod |
notes |
1994 |
KV-20M10 |
A51LDG50X |
CXA1465AS |
|
1994 |
KV-20S11 |
A51LDG50X |
CXA1465AS |
|
1996 |
KV-20V60 |
A51LDG50X |
CXA1870S |
|
1997 |
KV-20M40 |
A51LDG50X |
CXA2061S |
|
1999 |
KV-20M42 |
A51LDG50X |
CXA2061S |
|
1995 |
KV-13M10 |
A34JBU10X |
CXA1465AS |
CRT Database lists demod as “CXA1465AS, CXA1865S” and tube as “A34JBU10X , A34JBU70X.” A34JBU70X is described as back-compatible update to A34JBU10X. Cannot find datasheet for CXA1865S. Service manual has CXA1465AS and A34JBU10X together. |
??? |
KV-1396R |
A34JBU10X |
??? |
Looks 80s. CRT Database has no service manual. |
??? |
PVM-1380 |
A34JBU10X |
CX20192 |
Described as “late 80s/early 90s" Can’t find datasheet for CA20192 |
1996 |
KV-13M20 |
A34JBU10X |
CXA1870S |
CRT Database lists tube as “A34JBU10X , A34JBU70X.” A34JBU70X is described as back-compatible update to A34JBU10X. |
1986 |
KV-1367 |
A34JBU10X |
??? |
CRT Database has no service manual. |
1999 |
KV-13M42 |
A34JBU70X |
CXA2061S |
|
1990 |
KV-13TR27 |
A34JBU10X |
CXA1013AS |
CRT Database has tube wrong. (It’s A34JBU10X, not the later A34JBU70X) Can’t find datasheet for CXA1013AS. |
1993 |
KV-13TR28 |
A34JBU10X |
CXA1465AS |
CRT Database has tube wrong. (It’s A34JBU10X, not the later A34JBU70X) |
1993 |
KV-13TR29 |
A34JBU10X |
CXA1465AS |
CRT Database has tube wrong. (It’s A34JBU10X, not the later A34JBU70X) |
I’m guessing the US model demodulators changed dramatically in 1987 when SMPTE-C was adopted. I’d really like to find a spec sheet for a 1985ish US demodulator to see how early US-made NES games were supposed to look.
You mentioned something about PAL axes. My understanding is that they are ALL straight 90 degrees.
Something else to emulate for Trinitrons is their “Dynamic Color” feature that was enabled by default. There’s partial information in the CXA1465AS datasheet: “The new dynamic color circuit detects flesh and white colors from the amplitude ratio of R, G and B primary color signals and changes the ratio of the R, G and B outputs so that the color temperature will be higher as the color is closer to white without changing the color temperature of the flesh colored portion.” In the testing section it indicates that red should be 97% and blue should be 106% when “Dynamic Color” is engaged. (Page 14 of this old brochure shows what it looks like.) No idea what qualified as a “flesh color” or what the function was for turning this on harder “closer to white.” My first guess would be that how strongly “dynamic color” is applied scales linearly with the inverse of the largest of R-Y, G-Y, B-Y, down to zero at whatever size R-Y counts as “fleshy.”
About Gamut Mapping/Compression:
It’s pretty much impossible to implement a “real” gamut compression algorithm in a shader. Figuring out accurate gamut boundaries is actually a really hard problem, and the first “good” solution to it was published in 2020. And it is still very compute heavy, and very space hungry, and pretty much demands computing both gamut boundary descriptors in their entirety once during initialization, storing them, and then accessing them as needed for processing each input color. This is a very CPU-friendly paradigm, and a very GPU-unfriendly one. Soooo… the best answer is to precompute your gamut conversion + compression and make a LUT out of it, then use the LUT in your shader. Annnd… that’s what gamutthingy is for. I need to push some commits and then it ought to be able to handle Trinitron P22 (NTSC-U, NTSC-J, or SMPTE-C whitepoint) to sRGB with fancy-pants gamut compression. I’ll try to upload some LUTs tomorrow or the next day. (And, yes, the readme is out-of-date in places. Both it terms of not reflecting recent commits, and not reflecting that demodulator chip datasheets are a great source of information on how color correction was performed in the 80-90s.)
Aside: I highly recommend reading everything in the “references” section of gamutthingy’s readme.
Aside: Grade’s “gamut compression algorithm” is trash because it’s operating in very-very-not-perceptually-uniform RGB space and it’s just guessing at the source gamut boundary. (Or, rather, it makes the user guess by way of a parameter.) Its only virtue is being fast enough to implement in a shader.
Something I still need to figure out how to implement: If we’re turning down the saturation knob to prevent red from clipping, then there are likely colors around the other primaries and secondaries that are within the Trinitron P22 gamut, but that we could never possibly output. So, if we do gamut compression using the gamut boundary descriptor for Trinitron P22, then, in many directions, we’re going to end up compressing more than we need to in order to make space for colors we can’t possibly have. What we need is to redefine the gamut boundary descriptor in terms of “the set of all possible outputs from the chain of modulator -> demodulator -> saturation knob -> (‘Dynamic Color’ emulation??) -> CRT gamma emulation given the domain of 0-1 R’G’B’ inputs.” I think I’ve got a rough idea how to implement that. But I’ve got a bad feeling that it unavoidably requires baking that whole chain into the LUT.
What if you really, really want to do gamut compression in the shader instead of using a LUT? If we decide to go with a relatively unsophisticated “desaturate only” compression algorithm, then we can “cheat” and reduce gamut boundary finding to binary search, and maaaaaybe that could be implemented fast enough for a shader. Maybe. No promises. It would look like this:
- (Do chromatic adaptation of the input color if necessary.)
- Convert the input color to LCh. (LCh is not quite perceptually uniform, but it’s close enough and a lot faster than something like JzCzhz.)
- Call our input color’s C value Ci.
- Keeping L and h constant, look for another C value, Cd, that marks the edge of the destination gamut in the +C direction along the vector away from the achromatic axis through our input color. How? Binary search!
- We can precompute and hardcode an unreachable Cbig value for the initial upper bound on our binary search. Check all 6 primaries and secondaries and take ~4/3 the largest C value of the 6. (4/3 should help us converge faster.)
- How do we test each candidate C value? Pair it with our constant L and h, and convert the resulting LCh value to linear RGB in the destination gamut. If any of the RGB values are below 0 or above 1, then it’s outside the gamut; otherwise it’s inside.
- Keep going until we find an “inside” C and an “outside” C that are arbitrarily close together, then average them and call it Cd.
- Compute Ct = 0.9 * Cd. (Alias so I don’t have to type it out a bunch.)
- If Ci <= Ct, then no compression is needed. Don’t change C, and just carry on converting the input to the destination gamut.
- Otherwise, we need to find the source gamut boundary. Same deal, except our test converts to linear RGB in the source gamut. (If you did a chromatic adaption, you will need to run it backwards here!)
- If Cd > Cs, then no compression is needed. Don’t change C, and just carry on converting the input to the destination gamut.
- Otherwise, compute the new C value as Ct + ((Ci - Ct) / (Cs - Ct)), replace Ci with that, then carry on converting the input to the destination gamut.
Do I recommend this? NO, I don’t. I’d much rather use a LUT generated by gamutthingy. But if you really, really want to gamut compression in a shader, the above is passable quality that’s maybe fast enough to work as a shader.