Real GBA and DS-Phat colors

A lot of weird things happen when I scan the screen that way, moreso directly to the LCD without any case glass on top of it. It was the best I can do. So I had to do tricks in order to grab the data much closely. I had someone who tried to find RGB gamut by clipping black and white’s values to 0-1, except the white being scaled to D65 ones (0.9504,1,1.0889) to reveal. It looks odd to the primary colors as I stated previously, as the blue color on the SP when scaling XYZ has the blue color go way past even rec 2020 which looks unrealistic to what I saw on the SP 001 screen. So my better solution that rally solved all three LCDs constantly is to instead use the RGB values from the HCFR (under Rec 709 Gamma 2.2 and exported from csv) to have the black and white be scaled 0-1. HCFR’s RGB data are stored linearly than actual post-gamma correction. I used the black data from the primary page as I scan the whole primaries just after the greyscale, as when I finish the white color, the colormunki photo somehow is slightly bright on the lens when it just scanned a bright color on a very dim space, but it still samples the white, black, red, green, blue, yellow, cyan, and magenta consistently with very low deviations.

Regardless, it does sample the luminance of the greyscale consistently, as I have that banding issue on the real display too. All I did was to only scale the Y value to 0-1 for the primaries to find its xy values which worked out well. Yeah I scaled it to D65 that way, but the warm white color is pretty much how the GBC looks. Not sure about the dark blacks tho, as that’s another issue I have yet to find out to see its full contrast ratio. But yeah, any instrument can scan color primaries including with inactive subpixels, which even the emission LCD displays has that treatment to both my Colormunki Photo and Display. It was why when I mentioned that about the NDS that it also shows same level of saturation when it’s done that way when comparing the shader with the real display. Of course you can still preserve the white color temperature just by scaling the RGB value down to 1 together instead of each individually, and have your own white values preserve its color temperature while the black one is clipped. It’s the way how to read the chc data’s full input color from its black to white to see how it looks.

The color primaries are solved that way to fully see the colorspace of the LCD. The greyscale can easily be read by its Y value scaled. I even take data from RGB to put onto GIMP before I apply gamma correction and remove saturation after clipping black and white swatches, and get the same luminance results. The problem the screen shows when scanned that way is it somehow scans the gamma at its brightest, so a really bright gamma when it comes out that way. It could be due to how pixels in those reflective displays work on how it perceives the light in order to be lit on different degrees than typical LCDs. It also has different gamma depending on what angle the light shoots to the screen from top, middle, or bottom. Left and Right angle doesn’t matter, but upper angle makes the screen with higher gamma and reverse applies too on lower angle. I ended up using the GBC test suite to use pure black and white horizontal stripes on the motion blur page to check each swatch to find out its gamma by squinting my eyes to blend the horizontal square to see if it blends, and that’s how I find the gamma for it, which I just simply tone the gamma on the scanned gamma inside GIMP and that’s how I figured how its gamma pretty much matched on the whole thing. Even the GBA’s data did the same thing except with a darker gamma by default when scaling the Y value to see how it looks. It has a reverse effect on the gamma works on the GBA by light angle, mostly because of how GBA layout is BGR than GBC using RGB, so the whole screen is rotated 180* angle since the subpixels literally is also rotated. It’s also a gamma I have to solve, except I somehow poorly scanned the greyscale as I haven’t opened the case yet, but I did with the SP as it was easier. It somehow reads the luminance better like almost 5 nits, so figuring the gamma is less of a hassle. Except the very dark colors have a blue tint, which I don’t know why. Tho it concerns me if I left the frontlit on, but it seem impossible since I took out the frontlit glass layer out when I scanned the screen directly, so it wouldn’t shoot lights on the whole screen. Although because of higher read nits than GBC or GBA, the gamma seems to look more closer to the screen overall, and despite the tint on the blacks, clipping it with the whites to find its colorspace still completely matched with the screen.

As of finding out the color temps, I can still solve it by simply scaling the white to D65 on either RGB or XYZ to find the color temps of each swatches. I put them in GIMP and was able to use LCh Color to blend them together, except the SP due to blue tint on darker colors. For GBC and GBA, it was pretty easy, as I just blend them to the gamma ramp to see how it looks. It seem to match the middle and brighter greys quite well, but the darker and the black swatches looks too saturated for how it looks compared to the rest of the greyscale. What I did was play with the averages for GBA gamma by adjusting its RGB gamma each. I was able to have the middle colors match that way while the darker colors blend to the black color pretty good, if not better and matches much more closely to the GBA screen.

So at the end, I really find the greyscale and gamma pretty difficult to solve, but possible based on those experience I did, and still in wip for a scaled look. I spent a week solving the whole situation on the greyscale, with both ups and downs of course.

As for the next steps, the test suite can measure RGB gradients, but I do have issues when opening up the GBC case as I put a new one that makes the battery metal pad tighter so it’s harder to remove. But overall, I can easily solve the color primaries by RGB scaling, and greyscale color temps by RGB or XYZ scaling, and luminance by Y scaling alone. Although I can rescan the GBA without the case someday while trying to average the swatches by multiple data on black swatches to get better reading.

1 Like

Well the results look good at least, even if the accuracy is questionable. How well do you think the screens align with each other in tolerance? If you only have measurements from one screen, may not be too helpful if another screen has a strongly different result.

The screen gamma is also sensitive to viewing angle, and different colors present a different viewing angle. The spectrophotometer’s light source and sensor cannot be at the same angle, so is there any possibility for that to have an effect?

I have an R script that can calculate RGB to XYZ matrices:

https://github.com/anikom15/scanline-classic/blob/master/tools/colormatrixcalc.R

Sorry if I missed it. Have you done work on the monochrome Game Boys?

1 Like

Hi @anikom15

With “the results look good at least,” do you mean the shader don’t look too bad in general, or that it’s pretty close to the GBC display?

For the tolerances, unfortunately screens vary by manufacturer, production tolerances, warm‑up behavior (likely irrelevant for reflective displays), and aging/exposure, which can shift colors. I can’t confirm how different Game Boy Color screens compare to each other because I don’t own one, but notable unit‑to‑unit differences are likely (I can’t say exactly how much); the same goes for other consoles. I have some examples which has been reported on this forum thread on my github but they are just for the GBA SP. Always on this forum some users reported differences between different 3DS and NDSi display units.This is why collecting multiple measurements for the same console is valuable: it helps design an appropriate shader and determine whether differences are primarily due to the display manufacturer or other factors. We can also create multiple shaders based on the most relevant factors, such as the panel manufacturer.

About the spectrophotometer’s sensor, I can’t comment definitely because I don’t have the console or the instrument. We could ask @Pokefan531 whether rotating the spectrophotometer changes the readings, since he mentioned that higher incidence angles yield higher gamma. If the readings don’t change, we can assume the light is effectively perpendicular or that its angle effects are negligible.

For RGB to XYZ conversion, I have a script in the math folder on github that uses the Python colour‑science library. I haven’t promoted it much because I’m still iterating, but I plan to add clearer documentation so the matrix, gamma, and related steps are reproducible and accurate even without this library.

Regarding monochrome Game Boys, I don’t currently have data for them. Most measurements so far are from @Pokefan531, with a few from @Pica200. They both have the consoles and the measurements tools which I don’t have. You can ask them if they have those consoles, but I don’t think so from what I remember.

1 Like

I always have the Colormunki Photo literally placed center at a flat angle, meaning it lays on the metal side of the LCD screen while the screen is getting scanned from it. The gamma and all are pretty much compressed depending how much nits it can measure.

I did rescan the GBA one without the case for direct measure and got better results. I did scan each swatches three times to average them, which took a while to do. I did end up having more better results than my last attempt when it was scanned with the case. The only tricky part was to reverse the black compensation for first couple of grey swatches when putting them up on gimp or calculating them when trying to uncompress the gamma from the scan. Same with the SP scan, except I somehow solved that issue because it has slightly more nits when it was scanned. Despite that, I used the black data from the primary page and it totally improved by a lot. The gamma ramp on the GBA and the SP looks very similar to each other when I looked at both, as well as the test suite blending the black and white stripe square with grey 24 at motion blur section when I squint to see it blend, as well as grey 10 blend with black and grey 15 stripe, and at grey 26 with grey 15 and white stripes. This was shot at the middle light angle, and even the SP’s frontlight shoots the light that way internally from the glass layer, produce the same results. At the end, they can look slightly washout on the gamma ramp part, but that’s how both the GBA units I used for this looks.

Like I said for the GBA-SP, somehow my measurement on the color temps or xy values from black to grey looks colder than warm white above, which doesn’t match the greyscale color temps that I see at the real SP screen, from the frontlight or the portable light I use. I may revisit scanning them one day, but it was challenging to open the SP’s screen case and remove two glass layers since the last time I tried it, I ended up having dust inside it when assembled, as I lacked few materials for it.

As for my shader progress, I had finished the shaders and updated them, including the gamma shaders, which as previously stated, no longer use LUT shaders to do them. To summarize, I had updated all values for the colorspace part for GBA, GBC, SP001, SP101, Micro, NDS, DS-Lite, and PSP on sRGB, DCI-P3, AdobeRGB, and Rec2020. NSO-GBC needs its gamma ramp shader to look correctly.

The reason why I separated the gamma and shader for the next update is because of stuffs like LCD shaders where a gamma change can affect the LCD grids if placed on top of it, and it wouldn’t look right. It would be the best to place the gamma before any LCD shaders that use RGB subpixels is loaded, then use the color shader on top of it to fully emulate how the LCDs truly look. Also, only GBC and GBA has gamma toggles to when to use the gamma ramp or not. I still have to update the slang presets to direct the shaders to a right location before I publicly post them with a link.

Edit: Here is the update shaders that’s finally out.

Currently, I don’t have the reshade textures up yet, but will soon be up.

@Pokefan531 Just remembered that there are 2 variants of P3. With DCI-P3 you actually mean that variant with the odd whitepoint, right? Because many displays use Display-P3 now including my phone. It has a proper D65 whitepoint.

Also didn’t expect that. It has legit one of the best displays on the market. 99% P3 coverage. I guess i could use that phone for tests where sRGB isn’t enough.

When I use P3, I mean the P3 colorspace with white points at D65, as well as using Pure Power Gamma 2.2, as a lot of displays tunes for that setup. DisplayP3 however, does all that except the gamma where it uses sRGB tone curve where it’s slightly washouts on dark colors where you notice brighter shadows than pure power gamma. I pretty much switched off from sRGB tone curve to gamma 2.2 as there’s a lot displays that aims towards the pure power curve, including my S23 ultra and even the Switch LCD. It’s pretty easy to work with Pure Power as I get far more consistent results when using ColorHCFR and check from screen to screen.

Actually seems a bit more complicated. This indicates it uses gamma 2.2 curves in SDR mode while it uses PQ in HDR mode. This makes things even messier.

See under the Video section.

edit:
And if you believe the official docs it’s sRGB and Display-P3.
https://source.android.com/docs/core/display/color-mgmt

Interesting! Yeah, I could do SDR at the moment as trying to do PQ for HDR isn’t in my levels yet. I can provide the colormetrics and stuffs.

@Brankale How are you doing your chromatic adaptation and gamut mapping? Do you rely on LUTs or are you able to process the mapping in the shader?

Hi @anikom15,

Currently, by “chromatic adaptation,” I mean adapting to the D65 white point using the Bradford matrix, as described in the GitHub documentation (though it might be a bit hidden). I should probably rename the shader option to better reflect this.

About chromatic adaptation, here’s a comparison between the “LUT” and “no LUT” shader versions:

  • LUT shader version : All colors are precomputed using chromatic adaptation and stored in a LUT. There’s no option to disable chromatic adaptation.
  • No LUT shader version : The chromatic adaptation matrix is precomputed (you can check the math section for details) and applied in the shader code. You can enable/disable it.

Regarding gamut mapping, there’s no algorithm implemented yet. Currently, RGB colors are simply clipped if they fall outside the normalized [0, 1] range (or [0, 255] for non-normalized values).

Unfortunately there are no standard gamut mapping algorithms so, I developed my own gamut mapping algorithm (maybe it’s a well-known algorithm but I don’t know) for the LUT shader version: For colors outside the target display’s gamut (e.g., sRGB, DisplayP3), it finds the closest matching color on the gamut’s convex hull based on the lowest delta E (in simple terms, if a color is outside the target colorspace, I map it to the closest reproducible color instead of clipping, which avoids unnecessary accuracy loss). This helped reduce both the average and max delta E (e.g. there was a console where the max delta E dropped from 8 to 4). However, I haven’t released the shader with this algorithm yet as I want to ensure it’s implemented correctly.

In general, I think that effective gamut-mapping algorithms are computationally expensive, and I’m not sure they can run in real time without a precomputed LUT. It’s also worth noting that implementing them in the shaders requires a lot of boilerplate code, and accuracy will likely be lower since shaders use single-precision rather than double-precision floats.

For chromatic adaptation, have you evaluated CAT16 and if it is preferable to Bradford? I read a paper that stated Bradford was superior, but it appeared biased.

What you’re doing for gamut sounds similar to the method here:

https://bottosson.github.io/posts/gamutclipping/

Are there differences?

I have a realtime gamut method algorithm. I’d like to know if you think it’s acceptable. It requires a linear input and determines scaling factors. in CIE Luv space. It determines a luminance scaling factor from the white point, then a chrominance scaling factor based on three primaries. For a reflective display, maybe a color space like Oklab would be superior, I’m not sure. It copes well with minor conversions but I’m not sure how well it will hold up with larger color space shifts.

https://github.com/anikom15/scanline-classic/blob/dev/shaders/color-base.slang

Yes, I also evaluated the CIECAT16 (there are some variations, but I used the version from the CIE). Honestly, the difference during gameplay was negligible, if not imperceptible, without close inspection of specific colors.

I tried several times to find comparisons between the Bradford and CAT16 matrices online, and results varied depending on the test type. In general a good summary of my researches is:

  • CAT16 is just a matrix, and you can use it in place of Bradford. However, it’s probably better to use CIECAM16, which includes the CAT16 matrix as part of a full Color Appearance Model (CAM) which involves other calculations. You can find the math of the CIECAM02 on Wikipedia, which is similar to CIECAM16.

  • CIECAM16 should perform better for large gamuts (e.g., the Nintendo Switch OLED which has P3 color space). For most consoles measured so far, the difference from the Bradford matrix is likely minimal.

  • CIECAM16 is the latest CIE standard, which likely makes it more accurate in most scenarios than the simpler Bradford matrix.

  • using just Bradford or CAT16 matrices perform full chromatic adaptation, while CIECAM models like CIECAM02 and CIECAM16 also handle partial adaptation, which more closely reflects how our eyes process color.

I glanced at the “gamut clipping” article. The end goal is the same, but the process is completely different from what I’ve done. My approach uses DeltaE as the metric for comparing colors, while the algorithm in the article performs approximations on lightness and chroma values while leaving the hue unaltered to preserve the original color appearance as much as possible. My method should be more accurate, but it isn’t suited for real-time use. Overall, it’s a well-written article, and I plan to read it more thoroughly when I have time.

Regarding your algorithm, I’ll take a closer look when I have more time, but based on what you said it seems fine (it’s also similar to the approach described in the article). For the color space, it really depends on your needs. CIELUV and OkLab are both perceptually uniform, but OkLab is likely a better fit for your case since it already encodes colors in LCh (Lightness, Chroma, Hue), which aligns with what you’re working with. I don’t think reflective or emissive displays make a difference for these operations because this is just a gamut mapping algorithm and not an RGB to XYZ conversion which requires a precise model of the display characteristics.

FYI: don’t repeat my mistake by performing multiplication with mat3. mat3 type seems to fall back to medium precision floating point (at least on Mac) which literally destroys accuracy. Look at these to know what I mean and how to fix it:

1 Like

This doesn’t sound right. All intermediates are supposed to be done in at least 32-bit unless you explicitly request a precision modifier. Have you tried opening an issue to troubleshoot? Matrix multiply will be much faster than individual multiplications.

ETA: If you move your calculations to the vertex shader, it should guarantee 32-bit precision.

Are you using Metal or Vulkan?

The LUT approach is quite cheap on modern GPUs since we can map all GBA colors (full 3D LUT) with the highest precision with just 96 KiB of LUT data (128 if RGBA). This of course assumes emulators will give you the 15 bit colors directly which most of them don’t do.

DS is a bit heavier needing 768 KiB minimum.

1 Like

I’ll try to respond point by point:

  • I preferred not to open a GitHub issue since I don’t know much about shaders or GPUs.
  • I didn’t force medium precision—only high precision—but it still doesn’t work.
  • As you said, I can move some operations to the vertex shader (for example, precomputing a matrix for RGB→XYZ, chromatic adaptation, and XYZ→RGB), but I still need to run part of the work in the fragment shader and since it uses medium precision, I lose a lot of accuracy, especially during chromatic adaptation.
  • I’ve tried both OpenGL and Vulkan and see the same issue in both. Metal, for some reason, runs extremely slow—around 1 FPS—even in the menus, so it’s basically unusable and I couldn’t properly test it.

In general, this is my first time working with shaders, so if you have more experience (I saw that your GitHub is full of shader-related work) and are willing to help, I’d really appreciate it. One thing I especially want to figure out is how to enable wide-gamut support, but the framebuffer always seems to be treated as sRGB.

We may be dealing with OS-level issues and incompatibilities. On Mac, everything uses Metal under the hood, and Metal is supposed to be less flexible regarding formats and the like, everything is supposed to ‘just work’.

Apparently the ‘metal’ driver in Retroarch doesn’t really work and you end up with software rendering, so best to stick with Vulkan. That’s good because lots of people have eyes on Vulkan and can help determine what’s going on:

https://github.com/libretro/RetroArch/issues/18051

Sorry if this is a stupid question, but are you defining your matrices in column-major format? E.g.

mat3 matrix = mat3(
    col1_row1, col1_row2, col1_row3
    col2_row1, col2_row2, col2_row3
    col3_row1, col3_row2, col3_row3);

Wide gamut doesn’t actually require anything in the shader to work. Both RA and the shaders aren’t even aware of what the target gamut is. You’re supposed to set the framebuffers to at least 10-bit, but you don’t have to. The video card may or may not send metadata about what the gamut is. On my PC I have to manually switch my TV to BT.2020 because Windows is technically gamut unaware when not in HDR. OS X may operate differently, maybe forcing sRGB for RA?

Are there any that do? How exactly does that work? Is an 8-bit texture still used as a container or is something else done?

( ̄_ ̄) the problem was actually the column-major format. Now, it works fine without all that boilerplate code.

Is there a reason for this matrix format? I would never have imagined it.

It’s faster for the video card :stuck_out_tongue: Also, vectors are implictly column vectors in shaders, but they will be treated as row vectors if it results in a possible matrix multiplication where a column vector multiplication would not be possible.