Skip to content

Apply gamma correction to compute the SDF#61

Merged
mourner merged 3 commits intomapbox:mainfrom
xavierjs:xavierjs/gammaCorrection
Apr 7, 2026
Merged

Apply gamma correction to compute the SDF#61
mourner merged 3 commits intomapbox:mainfrom
xavierjs:xavierjs/gammaCorrection

Conversation

@xavierjs
Copy link
Copy Markdown
Contributor

@xavierjs xavierjs commented Apr 6, 2026

Hi,

Thank you for considering my PR.

Context

When we compute the SDF, we render the glyph with the Canvas2D API, then we compute the distance to the border:

const d = 0.5 - a;

Issue

The glyph is rendered with the Canvas2D API, which applies antialiasing and gamma correction to the output.

Let's take a concrete example:

Consider a pixel that lies exactly on the glyph's border. If we could render this pixel with infinite subpixel precision, half of the pixel would be white (outside the glyph), and half would be black (inside the glyph). In this ideal case, the coverage value a for the pixel would be 0.5 (i.e., 50% grey, or rgb(127, 127, 127)), so d = 0.

However, due to gamma correction, the actual color displayed for this pixel will not be 50% grey. Instead, we must apply gamma correction to obtain the color that corresponds to 50% luminance. The displayed grey will be 0.5^2.2 ≈ 0.22, which is a much lighter grey: rgb(199, 199, 199) , since 199 = 255 * (1 - 0.5^2.2).

Improvement

So if we want to compute the d value for this pixel (mapping to the black/white coverage), we need to apply the inverse gamma transform:

const aLin = Math.pow(a, 1.0 / 2.2);
const d = 0.5 - aLin;

Benchmarks

In these 2 images, the Arlington label on the bottom is displayed in HTML above the canvas. This is the reference.
The label above is rendered using this package.
Without the gamma correction, the glyphs look a bit thinner than the reference:
before

With the correction, it looks a bit thicker:
after

Here is a gif for a better comparison:
output

Ref

https://www.cambridgeincolour.com/tutorials/gamma-correction.htm

@redblobgames
Copy link
Copy Markdown

Interesting! BTW some articles say that fonts should be rendered at 1.4 gamma:

I don't know what gamma the browsers use. If they use 1.4, then using 1.0 / 1.4 instead of 1.0 / 2.2 will probably make your glyphs look closer to the reference.

@xavierjs
Copy link
Copy Markdown
Contributor Author

xavierjs commented Apr 6, 2026

Interesting! BTW some articles say that fonts should be rendered at 1.4 gamma:

I don't know what gamma the browsers use. If they use 1.4, then using 1.0 / 1.4 instead of 1.0 / 2.2 will probably make your glyphs look closer to the reference.

In this case, we're reading the antialiased glyph directly from a Canvas2D rendering context. The pixel values we get are already gamma-encoded (typically with a gamma of 2.2), as per the sRGB standard for images. This gamma encoding happens at the image level, independent of the browser’s display pipeline or the monitor’s characteristics.

While browsers may apply further color management or gamma correction when displaying the final image on screen, that process doesn't affect the pixel values we read from the canvas. So, for the purpose of decoding the coverage values from the rendered glyph, we should apply the inverse of the standard sRGB gamma (usually 2.2), not a display-specific value like 1.4.

@mourner
Copy link
Copy Markdown
Member

mourner commented Apr 7, 2026

Thanks for the PR, seems like a nice subtle improvement! Although the GIF attached as a static image, so the difference is a bit hard to see.

Can you also measure how much this impacts performance of a tiny-sdf run? I assume it's negligible (distance transform takes a lot more time than the initial single pass of reading pixel values), but worth double-checking.

@xavierjs
Copy link
Copy Markdown
Contributor Author

xavierjs commented Apr 7, 2026

Hi @mourner
Thank you for your feedback. I added a small benchmark to the PR where I generate SDFs. There is a tiny slow down:
with the gamma correction:

Durations for 100000 iterations: 6.673960083 seconds

Without:

Durations for 100000 iterations: 6.476474625 seconds

So about 3%
If you think it is too important, I can see if I can replace the Math.pow by its Taylor expansion. We will loose some precision but we may gain in speed.

@mourner
Copy link
Copy Markdown
Member

mourner commented Apr 7, 2026

@xavierjs we could also precompute since alpha values are discrete (only 256 different values), but probably not worth the effort, 3% degradation is fine. It's hard to measure how much the rendering improves, I guess it's somewhat subjective (and we'll always have some minor discrepancy with the way a browser engine renders text) — is it noticeable for your case? Overall I think we can land this.

@xavierjs
Copy link
Copy Markdown
Contributor Author

xavierjs commented Apr 7, 2026

I observe the difference on my end (Macbook pro M4 Max 16 inch). With the gamma correction, the Arlington label is undistinguishable from the HTML copy, whereas without alpha correction the Arlington label is a bit thinner than the HTML one.

@mourner mourner merged commit 532f30b into mapbox:main Apr 7, 2026
1 check passed
@mourner
Copy link
Copy Markdown
Member

mourner commented Apr 7, 2026

Thanks again! I ended up adding a lookup table optimization that makes it a bit faster than it was before the PR. f2a1550

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants