Skip to content

Build planes several times faster by default, with the old path as PlaneRendering.Canonical - #365

Merged
smelfungus merged 14 commits into
masterfrom
plane-rendering
Sep 27, 2026
Merged

smelfungus merged 14 commits into
masterfrom
plane-rendering

Conversation

@smelfungus

@smelfungus smelfungus commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Builds on #364. A plane is rebuilt at every step of a hue drag; after #364 an LCH plane still took 35 ms on desktop. PlaneRendering.Fast, the new default, builds every library plane several times faster; PlaneRendering.Canonical keeps today's pixels as the reference Fast is tested against.

Plane at hue 200 desktop, master desktop, Canonical desktop, Fast Pixel 6 Pro, Canonical Pixel 6 Pro, Fast
LCH C × L 34.7 ms 33.4 ms 5.2 ms 47 ms 10.5 ms
OkLCh C × L 10.6 ms 8.4 ms 1.6 ms 16 ms 4.3 ms
Okhsl S × L 9.7 ms 7.1 ms 0.9 ms 10 ms 1.1 ms
HWB W × B – 0.5 ms 0.4 ms 0.1 ms 0.5 ms
Okhsv S × V – 0.5 ms 0.3 ms 0.5 ms 0.3 ms

Desktop from PlaneTimingTest, the median of 15 builds. The phone's from a non-debuggable build of the sample with its UI off, each plane rebuilt back to back for two seconds as during a drag, the mean of two rounds; single builds there swing 2–5× with the core the scheduler picks.

color

  • GamutMapping.ChromaReduction(solver) takes an EdgeSolver: ClosedForm, the default and unchanged, or Iterative, which walks to the edge with Newton's method inside stretches where a channel's cubic is monotone, with no cube roots or trigonometry. It lands within 1e-12 in chroma of the closed forms at every 0.05° and hundredth of lightness in sRGB, Display P3 and Rec. 2020, the sliver past pure blue included; near black, where the channels are about as small as the edge tolerance, the two may stop on neighbouring edges whose colors differ by under 1e-8 in linear light. On an LCH plane its chroma reduction takes 163.3 ns a color against 353.9 on desktop, and 29.8 ms a plane against 44.6 on the Pixel. ChromaReduction becomes a class for it.
  • GamutMapper.convertToArgb writes packed pixels, the bytes of convert's colors, from a table of the 255 linear values where the rounded sRGB curve steps, found once by bisection against the platform's own curve.
  • Bit for bit, with no switch: the per-thread memo remembers a hue's cosine and sine and Okhsl's per-lightness terms, and the mapper maps from the Oklab color its route already passed through.

colorpicker-foundation

  • LocalPlaneRendering provides PlaneRendering.Fast or .Canonical. Fast packs rows through convertToArgb with the iterative solver, within one level of Canonical, and shares them among up to four workers, each taking the next row as it is free; the browser has one thread and keeps one.
  • Builds yield only in the browser, where they share the thread that handles input. Elsewhere a yielded coroutine waits for a pool thread to wake, which on the Pixel cost more than the rows between: a build now checks for cancellation at every row instead.
  • Okhsl's S × L is two bands under Fast, meeting on the row of its cusp's lightness, where one grid blurred across its crease: 256 × 64 above and 256 × 16 below, 12.82/255 at worst against the single grid's 29.34, from 20,480 samples in place of 65,536. What error is left comes from the right edge, where a channel reaches zero and the sRGB curve rises steeply from it, which only columns reduce, so the bands keep all 256.
  • Fast draws each sample where its grid is measured, the outermost on the plane's edges. Scaled whole, as Canonical still is, a raster's outer samples sit half a cell in and the filter holds the edge beyond them. A plane never draws a raster the other preset built.

Screenshots: 15 re-recorded, every one with a raster plane: the Okhsl, Okhsv, OkLCh, LCH and HWB pickers, the Okhsl and Okhsv planes, the horizontal picker and both dialogs. Through the Okhsl crease they match master within 3 levels.

apiDump is committed. JVM, Android host and wasm suites, apiCheck, checkNoMaterial, the iOS compile, the sample and the screenshot validation pass.

An LCH plane's constant-hue lines curve through Oklab, so every color outside sRGB needs an edge of its own, and the closed-form roots of each channel's cubic made that most of the plane's time. Walking in from the color's own chroma, with Newton's method inside a stretch where the outside channel is monotone, lands within 1e-12 of the closed forms at every 0.05° and every hundredth of lightness in sRGB, Display P3 and Rec. 2020, the sliver past pure blue included. Near black, where the channels are about as small as the edge tolerance, two can reach zero within it of each other, and the two solvers may stop on neighbouring edges whose colors differ by under 1e-8 in linear light.

An outer edge is walked to from just past the most chroma the gamut's cube can reach, so the answer never depends on what was asked before. On the LCH plane at hue 200 the walk takes 163.3 ns a color against 353.9 (2.17× faster); on the OkLCh plane, where each row's edge is remembered, 34.2 against 43.2. Nothing uses the walk yet.
Where every color needs an edge of its own, the closed-form cubic roots are the cost, so ChromaReduction takes an EdgeSolver: ClosedForm, the default and what it did before, or Iterative, which lands on the same edge without them. ChromaReduction becomes a class for it, equal by its solver; 2.0 has not shipped it as an object.
A plane holds one hue, and along its row Okhsl changes only saturation, yet every color recomputed the hue's cosine and sine and, in Okhsl, the toe, the cusp's chroma terms and the edge for its lightness. They are remembered per thread by the exact bits asked with, as the cusp is, so every result keeps its bits.
A color outside the gamut was converted to Oklab a second time for the mapping, though its route from OkLCh, Okhsl, Okhsv or Oklab had just passed through the same Oklab color. The mapper keeps it on the way, taking the same steps in two legs, so every result keeps its bits; the bulk test now covers every route.
A plane is bytes, yet each of its colors paid for three pow calls to encode channels that are rounded to 8 bits straight after. convertToArgb gives the same bytes from the 255 linear values where the rounded curve steps up, found once by bisection against the platform's own curve, so the table cannot drift from the curve it stands for.
…ical

A plane mapped each color to Doubles with the closed-form solver and then rounded them. PlaneRendering.Fast, provided through LocalPlaneRendering and the default, packs each row straight into pixels and reduces chroma with the iterative solver, within one level of PlaneRendering.Canonical, which keeps the path it replaces as the reference. The raster cache keys on the rendering, so switching it never shows the other's raster.
Every row of a plane stands on its own, but one coroutine built them all while the other cores idled. Under PlaneRendering.Fast the rows are dealt out in turn to as many workers as the platform has processors, up to four, each with its own scratch row and yielding as before; the browser, which has one thread, keeps one. The pixels are the same bits as one worker's.
Okhsl's saturation × lightness creases along the row of its cusp's lightness, and one grid across it took 256 × 256 samples to measure 29.34/255 at worst. Under PlaneRendering.Fast it is two bands that meet on the crease, each sampling it as an edge: 96 × 16 each measures 25.04/255 at every whole degree, 3,072 samples in place of 65,536. The error left sits at the right edge, where a channel reaches zero and the sRGB curve rises steeply from it; more columns only whittle it, and more rows add nothing.
Scaled whole, a raster's outer samples sit half a cell in from the plane's edges, and the filter holds the edge over the half cell outside them. At 256 rows that is a pixel; on an Okhsl band of 16 rows it was a flat strip ten pixels tall on each side of the crease. Under PlaneRendering.Fast each raster is now scaled a cell to a sample and clipped to its band, so its first and last samples lie on the band's edges, as every grid is measured; a band whose cells are under a pixel, where the strip cannot be seen, is still drawn whole, and so is every Canonical plane.
Fifteen screenshots show a raster plane and are drawn by PlaneRendering.Fast: the Okhsl, Okhsv, OkLCh, LCH and HWB pickers, the Okhsl and Okhsv planes, the horizontal picker and both dialogs. Okhsl's now meet on its crease from two bands, and every one's outermost samples reach the plane's edges. The README says what LocalPlaneRendering chooses, and the timing test records both presets.
After LocalPlaneRendering changed, a plane went on drawing the previous preset's raster until its own arrived, laid out the new preset's way: an Okhsl raster built in two 16-row bands, drawn as Canonical scales its rasters, showed flat strips beside the crease for a frame or two. A raster now carries the rendering it was built with, and a plane draws only its own, as it already drew only its own pair's.
The error the Okhsl bands kept comes from the plane's right edge, where a channel reaches zero and the sRGB curve rises steeply from it, and only columns reduce it: at 96 the edge measured 25.04/255, worse than the single 256 × 256 grid draws it. The bands now keep 256 columns, 64 rows above the crease and 16 below, the fewest that leave only the edge's 12.82/255 where the single grid measures 29.34. That is 20,480 samples in place of 65,536; the Okhsl plane builds in 0.8 ms against Canonical's 6.9. The Okhsl screenshots follow.
Iterative's KDoc said it walks in from the color's own chroma, but an edge it remembers for a line is walked to from past the most chroma the gamut reaches, and only a color inside the sliver walks from its own. The changelog said it finds the same edge; it lands within 1e-12 in chroma of the closed forms, and near black within 1e-8 in linear light.
On a Pixel 6 Pro a plane's build was mostly waiting. Each worker yielded every 16 rows, and on a phone a yielded coroutine waits for a pool thread to wake, longer than the rows between; and rows dealt out in a fixed rotation left the plane waiting on whichever worker had landed on one of the phone's slow cores. Workers now take the next row as each is free, and yield only in the browser, where the build shares the input thread; elsewhere they check for cancellation at every row.

Built back to back on the Pixel, a Fast Okhsl plane drops from 12.3 to 1.1 ms, OkLCh from 5.6 to 4.3, LCH from 12.3 to 10.5 and Okhsv from 2.0 to 0.3. Canonical, which yielded the same way, drops from 51 to 47 ms on LCH and from 6 to 0.5 on Okhsv. The desktop's times are unchanged.
@smelfungus
smelfungus merged commit 90c9ca2 into master Sep 27, 2026
2 checks passed
@smelfungus
smelfungus deleted the plane-rendering branch September 27, 2026 16:18
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.

1 participant