Skip to content

Experiment: use colordx for pixel renderer hot path#211

Merged
ai merged 3 commits intoevilmartians:mainfrom
dkryaklin:colordx-pixel-renderer
Apr 20, 2026
Merged

Experiment: use colordx for pixel renderer hot path#211
ai merged 3 commits intoevilmartians:mainfrom
dkryaklin:colordx-pixel-renderer

Conversation

@dkryaklin
Copy link
Copy Markdown
Contributor

@dkryaklin dkryaklin commented Apr 2, 2026

An experimental PR to explore replacing culori with @colordx/core in the generateGetPixel hot path.

What changed

generateGetPixel now uses colordx's low-level channel functions (oklchToLinear, oklchToRgbChannels, linearToP3Channels, linearToRec2020Channels) instead of culori's higher-level color objects avoiding object allocation on every pixel.

Performance

Benchmarked on a 500×500 grid (250k pixels):

Mode culori colordx speedup
sRGB only ~53ms ~49ms ~1.1×
P3+Rec2020, sRGB display ~115ms ~106ms ~1.1×
P3+Rec2020, P3 display ~166ms ~62ms 2.7×

The biggest win is when the display supports P3 the most common modern case

@ai
Copy link
Copy Markdown
Member

ai commented Apr 2, 2026

Very interesting. I will look tomorrow and will give feedback about the API.

I think we can migrate if we will be able to remove culori.

Comment thread lib/colors.ts Outdated
let color = getColor(x, y)
let proxyColor = getProxyColor(color)
let colorP3 = p3(proxyColor)
let [lr, lg, lb] = oklchToLinear(color.l, color.c, color.h ?? 0)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is very crazy optimization trick of re-using the same array:

const RETURN_ARRAY = [0, 0, 0]

export const oklabToLinear = (l: number, a: number, b: number): [number, number, number] => {
  
  RETURN_ARRAY[0] = 4.0767416613 * lv - 3.3077115904 * mv + 0.2309699287 * sv
  RETURN_ARRAY[1] = 4.0767416613 * lv - 3.3077115904 * mv + 0.2309699287 * sv
  RETURN_ARRAY[2] = 4.0767416613 * lv - 3.3077115904 * mv + 0.2309699287 * sv
  return RETURN_ARRAY;
}

It will be interesting to check how this trick could affect performance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've update colordx lib to support this. The performance gain is huge in memory. Time is also improved

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will need numbers from b integration benchmark (custom one is very different)

Comment thread lib/colors.ts Outdated
let colorSRGB = rgb(proxyColor)
let [lr, lg, lb] = oklchToLinear(color.l, color.c, color.h ?? 0)
let [sr, sg, sb] = oklchToRgbChannels(color.l, color.c, color.h ?? 0)
let [pr, pg, pb] = linearToP3Channels(lr, lg, lb)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can speed up the code by calling linearToP3Channels only before if (inGamutEps(pr, pg, pb)) { check

Comment thread lib/colors.ts Outdated
let proxyColor = getProxyColor(color)
let colorP3 = p3(proxyColor)
let [lr, lg, lb] = oklchToLinear(color.l, color.c, color.h ?? 0)
let [pr, pg, pb] = linearToP3Channels(lr, lg, lb)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same here, we need [pr, pg, pb] only on false inGamutEps(lr, lg, lb), let’s calc it there

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure here. It is using next line

Comment thread test/bench.ts Outdated
@@ -0,0 +1,102 @@
import './set-globals.ts'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The webapp has built-in benchmark.

Open http://localhost:5173 and press b, you will see a panel with real benchmark inside the browser (which will be more accurate than different env of Node.js)

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. Just wanted to have some numbers for debug/validation

Copy link
Copy Markdown
Member

@ai ai Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are numbers in our built-in benchmark? The best way to test is to open some specific colors (remember it).

Then open old version and press b.

Then open new version with the same color and press b again.

Copy link
Copy Markdown
Contributor Author

@dkryaklin dkryaklin Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ai

The built-in overlay isn't great for this comparison:

  • Timings use Date.now() (1 ms resolution) — too coarse for sub-ms work
  • Worker sum is CPU time across parallel cores, not wall-clock
  • Paint is bounded by worker messaging + main-thread drawing (Freeze), so once color math drops below those, Paint stops reflecting it
  • Each press of b is a single-frame sample — no median, no p95

Workers parallelize across cores, so user-visible repaint ≈ max(L, C, H) instead of sum. That hides most of the speedup at typical canvas sizes.

Instead I added a batch bench: 100 sequential color changes on main thread, each rendering all 3 charts synchronously. This isolates the color-math path and gives stable median / p95:

before:
Screenshot 2026-04-20 at 00 13 31
after:
Screenshot 2026-04-20 at 00 12 53

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each press of b is a single-frame sample — no median, no p95

BTW, if you want you can change it in separated PR, I will accept it.

But without synchronous run. The whole idea of real benchmark is to see the difference for real end-user.

Paint is bounded by worker messaging + main-thread drawing (Freeze), so once color math drops below those, Paint stops reflecting it

But we can use worker * metrics, right? worker max should be equal to Paint without messaging and freeze.

before: / after:

Why Freeze is twice bigger? Is it just one-run issue or the result is repeating?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on new bench

before:
Screenshot 2026-04-20 at 01 40 12

after:
image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Give me a few days to review benchmark PR.

Comment thread test/colordx.test.ts Outdated
@dkryaklin dkryaklin force-pushed the colordx-pixel-renderer branch from 75a108f to a78ff5c Compare April 18, 2026 15:06
@dkryaklin dkryaklin requested a review from ai April 18, 2026 15:11
@ai
Copy link
Copy Markdown
Member

ai commented Apr 20, 2026

Can we replace all culori code to not duplicate colour math libs?

@dkryaklin
Copy link
Copy Markdown
Contributor Author

Can we replace all culori code to not duplicate colour math libs?

I would prefer to split work in few PR's if possible

@dkryaklin
Copy link
Copy Markdown
Contributor Author

Something like this:

  1. Stores sweep: migrate all 4 stores (formats.ts, current.ts, benchmark.ts, visible.ts)
  2. lib/colors.ts full swap: parse, gamut, converters, LCH hot path, types. Drop all useMode(...)
  3. Remove culori: delete culori + @types/culori, clean up leftover imports, delete the parity test

@ai
Copy link
Copy Markdown
Member

ai commented Apr 20, 2026

Nice plan

@ai ai merged commit 95ea5cd into evilmartians:main Apr 20, 2026
2 checks passed
@ai
Copy link
Copy Markdown
Member

ai commented Apr 20, 2026

Seems like there is an issue in OKLCH math.

Check out the left side of L graph and bottom side of H graph.

culori:

Снимок экрана от 2026-04-20 20-59-01

colordx:

Снимок экрана от 2026-04-20 20-59-06

In fact, culori is not right too and we should not have any bump on the left side of L graph. But colordx does it much worse. Can you check math from Color.js?

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.

2 participants