Adding WebGL to a Canvas 2D chart: the GPU was not the slow part
What we measured while giving TradeCanvas a WebGL renderer. A blend per canvas every frame, a stencil buffer nobody used, 19,200 rectangles, float32 rounding and a browser that quietly drops the sixteenth context.
Bài viết bằng tiếng Anh.
TradeCanvas draws with Canvas 2D, and on an ordinary screen that is enough: at a pixel ratio of 1 it holds 60 frames a second while panning in all but the heaviest of our benchmark scenes. A laptop with a high-density screen is another matter. At a pixel ratio of 2 there are four times the pixels, and Canvas 2D dropped frames in every one of the 14 scenes, from 31 to 149 of the 150 frames panned.
So we wrote a WebGL renderer. It took five pull requests, and most of what we learned was not about shaders. The numbers below are from those pull requests and from the benchmark file in the repository, most of them measured in headless Chrome on integrated Intel graphics (UHD 770), a common laptop-class GPU.
Before the GPU: stop drawing what cannot be seen
The first change was not WebGL at all. Zoomed out on 200,000 bars, an indicator line has thousands of points and a few hundred columns of pixels to put them in. Canvas 2D strokes every segment anyway, and rasterising those strokes was most of the frame.
Once a bar takes less than a pixel, each line is now drawn as one filled span per pixel column: from its lowest to its highest point in that column, joined to the column before, as wide as the line. Bands do the same, and a histogram keeps its tallest bar per column. That scene went from 54 to 21 ms a frame at a pixel ratio of 1, and from 86 to 38 ms at 2, with about 1% of pixels different, mostly on line edges.
It is the same idea as data grouping in our first analysis post: make the work follow the screen, not the history. Doing it first meant the GPU work started from an honest baseline.
The browser’s share of a frame
The target of the first WebGL phase was 60 fps for a 2560×1400 chart at a pixel ratio of 2: a 27-inch screen at 200%. Candles moved to the GPU and the frame went from 38.1 to 22.5 ms. Faster, but not 60 fps, and the drawing was not what was left.
To find out where the time went, we measured empty full-size canvases at that size, redrawn every frame.
One redrawn 2D canvas with a still one beside it already takes 17.6 ms. Add a WebGL canvas and it is about 22. Every full-size canvas the browser has to blend costs time every frame, whatever is on it, and at 5K pixels on an integrated GPU that cost alone misses 60 fps.
That changed how we built the rest. Before making a shader faster, count the full-size layers that change each frame. The chart already kept a light top canvas for the crosshair and the legend, so a hover repaints only that canvas, not the scene. The WebGL renderer draws the grid and the session shading on the GPU too, so the 2D background canvas exists only when something needs it, a watermark for example. The 5K target moved to a later phase, and it is still not reached; the docs say so.
Canvas 2D stays the source of truth
The other early decision was what not to port. A chart library is mostly code that draws with Canvas 2D: 134 indicators, chart types, and plugins written by people we will never meet. Rewriting each for the GPU would split the library in two and leave plugins behind.
So indicators draw on a Canvas 2D context that records instead of drawing. Paths, rectangles, arcs, curves, linear gradients and rectangular clips become GPU commands; the GPU draws the recording. Anything it would not draw the same, text above all, goes to the 2D canvas, and the steps keep their order, so what is on top stays on top, with one exception: text from the steps the GPU draws lands above all of their lines. A plugin indicator comes along without a change.
This is the opposite trade from a renderer that is fast because it drops features. Switching TradeCanvas between Canvas 2D and WebGL changes the speed, not what is drawn, and we check that with screenshots: candles land on the same device pixels in both, and lines and fills differ on up to about 2% of pixels (0.1 to 1.7% in our checks), at their antialiased edges.
Four things that cost more than they should have
- A stencil buffer. The usual way to fill a polygon on the GPU uses one. At 5K, a canvas-sized stencil cost about 5 ms a frame, in clears alone, used or not. Fills are now drawn as spans between their two sides instead, and strokes with a distance shader that draws each pixel once.
- 19,200 rectangles. The order-book heatmap is 240 snapshots of 80 price levels. Recorded naively, it took 57.7 ms a frame. Plain rectangles now go straight to a GPU rectangle, and the rectangles of steps in a row draw in one call: 16.8 ms.
- float32. JavaScript computes positions in 64-bit floats and a shader in 32. With a bar step of 5.1 px at 200%, every fifth bar landed one device pixel apart between the two renderers. Both now round to device pixels the same way, and a test that found 822 mismatching wicks with the old rounding finds none.
- The sixteenth context. Chrome and Safari keep about 16 WebGL contexts a page and drop the oldest past that, for good. Firefox kept all 40 we opened. A page with a grid of charts and a few thumbnails gets there fast, so at most 8 charts on a page draw with WebGL at once (
setMaxWebGLChartschanges it), and a chart past the limit draws with Canvas 2D until a context is free. A context the browser loses and later hands back is rebuilt, and the chart says which renderer it is on through therendererChangeevent.
Where it ended up
At a pixel ratio of 2, WebGL holds 60 fps in 7 of the 14 scenes, against none for Canvas 2D. The rest are the heavier scenes. In the two at 2560×1400 the browser’s blending is a large part of the frame, and in the area chart zoomed out on 200,000 bars the two renderers are level (83 and 88 slow frames).
WebGL is still opt-in, renderer: 'webgl' or 'auto', and Canvas 2D is the default. The WebGL code is a chunk of its own, about 16 kB gzipped, loaded the first time a chart asks for it, and a chart falls back to Canvas 2D wherever WebGL 2 is missing. Making it the default waits on checks on real high-density screens in all three browser engines, not just headless ones.
pnpm bench:render runs the same scenes on your machine, with --renderer=webgl for the second table. If your numbers look very different from ours, we would like to see them.
Nguồn
- TradeCanvas measurements, 2026-10-25 (benchmarks/2026-10-25.md)
- PR #47: indicators draw per pixel column when zoomed out
- PR #48: WebGL renderer, phase 1 (grid, sessions, candles, volume)
- PR #49: WebGL renderer, phase 2 (indicators, panes, chart types)
- PR #51: WebGL renderer, phase 3 (heatmap, profiles, large data)
- PR #53: WebGL renderer, phase 4 (lost contexts, many charts, three browsers)
- TradeCanvas docs, performance