This page runs from a web server, not from a file: in the repository run python3 -m http.server 8000 and open http://localhost:8000/tutorial/.

Part VII · Speed · Chapter 28

Feeling fast

A renderer can be fast and still feel slow: a camera that lags behind the mouse, a page that does not answer while a heavy picture renders, a frame the graphics card's driver gives up on. This chapter is how ShadyTracer's live view decides how much to draw each frame, what it draws while something moves, how it cuts a heavy picture into pieces that fit a frame, and how an offscreen render keeps the card busy. Most of it is rules in code; where a number was measured, it says so, and where nothing was, it says that.

1How many samples a frame

The live view is a loop: each time the browser is ready to show a new frame, the renderer traces some samples and adds them to the picture. More samples a frame, a quieter picture sooner; but a frame that takes too long makes the view sluggish. A governor decides: it measures the frames a second the loop is managing, and when that is above 23 it asks 3% more samples next frame, below 18, 3% fewer; in between it leaves the number alone. The number is a fraction and the frame traces its whole part. Here each frame costs 5 ms plus 20 ms a sample (a model's numbers): the governor settles at 2 samples, 22 frames a second, and stays: 22 is inside the band, so nothing changes. (The first four steps show the governor alone; the live view adds one more rule, the last step's.)

A light scene, 5 ms a sample: it climbs, 3% a frame, to 8 samples a frame in about 35 frames, and stays. The steps are a share of the count, so they are as gentle at 2 as at 40.

The rule before, as its comment tells it: steps of 0.1 sample, whatever the count, and a band of only one frame a second either side of 20. At 2 samples the frame is 45 ms, 22 a second, over the band, so it climbs; at 3, 65 ms, 15 a second, under it, so it falls: it hunts between the two for ever, and the frame rate with it. "Gentle where it does not matter and violent where it does", the comment says of the fixed step: at 2 samples a frame one sample more is half as much work again, at 40 a fortieth; a share of the count is as gentle at both.

And its floor guard, count > 1, let floating point through. The guard is asked before the step, and steps of 0.1 do not land on whole numbers in a computer's arithmetic: a number is kept as a binary fraction, and a tenth has none that ends (only halves, quarters, eighths and the like do), so each step is a hair off and the hairs add up. From 3, nineteen steps down reach 1.0999999999999983, still more than 1, so the guard allows one more, to 0.9999999999999983, whose whole part is 0 (computed). A frame then traced no sample, showing the picture unchanged "while the HUD cheerfully reports 60 fps", in the comment's words (the HUD: the readout over the picture). On a heavy scene (60 ms a sample) the old rule here does just that, every other frame. Today's clamps the count at 1.

The live view's one rule more, from section 3 (since 0.58.0): it gives the samples of each frame a slice of about 30 ms, and a sample that fits in it is traced no more times a frame than fit, whatever the governor asks. At 5 ms a sample that is 6 (30 / 5); with the frame's own 5 ms the frame takes 35 ms, 28.6 a second (the slice limits the samples' time, not the rest of the frame). The governor, above its band, keeps asking 3% more, so the loop is told what the frame really traced and holds the count to one more than that, 7: it asks 7.21 the next frame and is cut back again. The code says why the count is held near what is traced: without it, the count of a light scene climbed to 64 and was handed whole to the next scene opened, a heavy one (section 7). Why one more and not exactly what was traced, it does not say. At 20 ms a sample the slice allows 1, 40 frames a second.

The governor frame by frame: above, the samples each frame traces (blue; a red dot where a frame traced none) and the count it is the whole part of (grey); below, the frames a second, the band where the rule changes nothing shaded. Frames cost 5 ms plus the samples' (a model: the first frame starts at a browser's 60 a second, and a frame of no sample, 5 ms, reads 200, where a real browser would show 60 at most). Today's rule is lib/render-loop.js's, mirrored and held to it by the test, the slice's raytrace_main.js's; the old rule is rebuilt from its comment. Choose the rule, the cost of a sample and whether the slice is on.

2Lighter while moving

While the camera, an object or a value is being moved, every frame resets the picture (chapter 23: the picture is an average of samples of one view, and a sample of the old view would smear into the new): the view shows one or two samples at a time, all noise, and a full sample, every path with all its bounces, is slow. So while something moves, a full picture is traced in a lighter mode, Draft (light straight from the sun, the sky and the lamps, with shadows, no bounce light) or Sketch (a fixed light), and the full picture comes back once the moving stops. "Moving" is told, never guessed: the camera and the builder's live values poke it each time they change, and it stays on until 400 ms have passed with no poke. Here a drag from 0.3 s to 1.7 s that stands still, button down, from 0.7 to 1.3: the view traces Draft from the first poke until 0.4 s after the button comes up.

A drag that only poked would go back to the full picture in the middle of that pause, at 1.1 s, and to Draft again when the mouse moves on: the full picture flashing under the pointer. So a drag in the picture holds "moving" for as long as the button is down, and the 400 ms start when it is let go.

Why told and not guessed from the picture's resets: a reset is also written by a scene swap, a kernel build, a resize, and by the switch to the lighter mode itself, which would then keep itself on for ever. With fast while moving off, the view traces the full picture throughout. What the lighter mode saves while moving has not been measured.

A drag frame by frame (16 ms each): the mouse (red: moving, faint: still with the button down), whether ShadyTracer counts it as moving (blue), and the mode each frame traces in. The real interaction.js on a clock run by hand, and the real quality.js choosing the mode. Choose how the drag holds, and the setting.

3A heavy sample in pieces

A sample is every pixel's path. A light scene's sample takes 20 ms: one frame holds it whole, and only one, since a second would not fit the slice below (section 1's last step).

A heavy scene's takes a second: as one frame, the view would answer the mouse once a second. So since 0.58.0 the live view cuts a sample into batches, bands of the picture's pixels, and traces about 30 ms of them a frame (the setting sliceMs); the sample is added to the picture when its last batch is done. A second's sample: 34 batches, 34 frames of about 30 ms. The picture changes once a sample, as before; the page answers every frame.

But a batch has a floor: about 16,384 rays. The card runs thousands of rays side by side, each on a lane of its own (chapter 21), so a batch of a few thousand leaves most of its lanes with nothing to do; that 16,384 is where that starts to matter is the code's own inference, its comment says. The code caps the number of batches at the rays over 16,384, rounded up: at 1280 × 720, 921,600 / 16,384 = 56.25, so 57 batches, of about 16,168 rays each (rounding up puts them a little under the floor; until version 0.67.3 the comment said "nor batches under", which this chapter found the arithmetic does not keep, and it now gives this example). A 10-second sample is then 57 frames of 10,000 / 57 = 175 ms, not 30: slicing cannot hold 30 ms on a very heavy scene. Nothing of this has been seen on a card or measured: the version that brought it says so.

A second rule, older and learned by an accident: Windows resets the graphics card when one piece of work sent to it, one submission, runs about 2 seconds. A frame was then one submission, every sample in it whole; the Noise library had been timed at over a second a sample, and it hung (2026-09-29; how long the frame that hung ran was not recorded). So a frame over 250 ms (chosen so a fast scene pays nothing; not measured) is cut into submissions of about 150 ms each. The cut falls between iterations: chapter 22's rounds, each one running every kernel (one of the card's programs) once over the rays still going. Every batch runs all of them, 24 at the default settings (38 at High quality), and the engine reckons a batch's time spread over at most 24 of them, as if they cost alike, though the first few carry nearly all the rays, and as if every batch cost alike, though a batch is a band of the picture and the band with the object in it costs more than the band of sky. So since version 0.81.0 every submission is timed too, and the longest of the last frame caps the next cut: if it held 1 iteration and took 1000 ms, the next frame sends every iteration alone, whatever the average says (150 / 1000 rounds down to 0, and 1 is the least). It only ever cuts finer. The figure's iterations all cost alike, where the two agree. At 640 × 360 the floor allows only 15 batches: a 10-second sample makes frames of one batch, 667 ms, so an iteration is about 667 / 24 = 28 ms, and 150 ms holds 150 / 28 = 5.4 of them, rounded down to 5 (about 139 ms): 5 submissions a frame, the last of 4 iterations.

One sample of the time you set, at the picture size you choose, as the live view traces it: frames as bars (the first 60 drawn), white lines where a frame is cut into submissions, the slice asked for dashed. The rules are raytrace_main.js's, their constants copied into kit/feel.js; a picture's rays are assumed to fit the card's ray buffer, the memory that holds the rays in flight, at once (one batch planned: chapter 23). Computed, not measured.

4Keeping the card busy

Offscreen, rendering a picture to save, each sample used to be recorded (the main processor writing the card's list of work), sent, and waited for before the next was recorded. The card idled while the next was recorded, and the processor while the card ran: a sample cost both times added, like a washer and a dryer used by turns, the second load not washed until the first is dry. A sample sent and not yet finished is in flight. The benchmark's first runs put a sample's time by the clock on the wall 50 to 57 ms above its time on the card, of which 6 to 12 ms was the recording (the user's Pascal card, 2026-10-02). What the other 38 to 51 ms is was not measured; the engine's comment guesses the browser preparing one long submission before the card may start. The figure models only the recording and the card, so it shows less than was saved.

With two in flight, the next sample is recorded while the card runs this one, and no more than two are ever waiting. A sample then costs about the larger of the two times. Measured on the user's Pascal card: 13 to 25% off a sample (two of the scenes that come with ShadyTracer: the Noise library 257.6 to 225.0 ms, materials 57.5 to 42.9), more than the recording alone, so the overlap hid some of that unmeasured rest too (inferred). Recording 3 still waits for the card to finish sample 1: that is the "no more than two" rule. Saving a picture, the timeline's frames and the library's pictures ask for two; the benchmark's own samples stay one at a time, so a sample is timed alone.

Six samples offscreen: the main thread recording each (red) and the card running it (blue), one in flight or two. The order of recordings and waits is samples.js runSamples' (the test holds the figure's to it); the times are yours. Choose.

5How ShadyTracer does it

The loop and its governor:


    

Moving, told and held:


    

The mode a frame traces in:


    

A sample in pieces:


    

And a frame in submissions:


    

And the samples in flight:


  

6Tricks and trade-offs

Each is stamped with how it is known; point at a stamp to see what it means.

Hold the loop, do not stop it. When the builder, the page's scene editor, turns a changed scene into new kernels (chapter 24), the live loop is held: it keeps asking the browser for frames and sends the card nothing. Going back to the Noise library in the builder took 1.4 to 6.8 seconds with the loop sending frames, 0.2 with it paused (the user's GTX 1080 Ti in headless Chrome, 2026-10-03, render-loop.js); why the frames slowed the compile so much is inferred, the waits behind the frames queued. Why a hold and not simply stopping the loop: a stop shows the page's play button as paused, and stops nest, the scene swap's inside the builder's, and the inner one saw the loop already stopped and so never started it again. Holds are counted instead: the loop sends again once every hold is let go.

Never more than two frames waiting. The browser asks for a frame 60 times a second whatever the card manages. A loop that sent a frame each time would pile up frames the card has not yet run, and what the screen shows is as far behind the mouse as that pile is long: WEAVESIM, the simulator this code was ported from, once measured seventeen seconds of it. So the loop skips its turn while frames are waiting. How many may wait: one when a sample takes over 50 ms and so does the frame; two otherwise. (A frame that traces only part of a slow sample is short, and held to one the card would idle between such frames, the code's comment infers.) lib/render-cost.js.

A sample that never finishes. A sample in batches (section 3) is begun for one picture size and one scene; if either changes before its last batch, the half-traced sample is thrown away and begun again. Changed every frame, no sample would ever end and the picture would never change. So after three throws in a row the next sample is traced whole in one frame, however long it takes (raytrace_main.js, NEVER FINISHING).

Slicing's floor. That a batch under 16,384 rays leaves the card mostly idle is the code's own inference, and what slicing costs a sample in total is not measured: each batch runs the whole chain of kernels (chapter 22), so more batches may mean more fixed cost. What would test it: one heavy sample timed whole and in 57 batches.

A run that ends on a lost card says so. The benchmark, ShadyTracer's page of timed test scenes (chapter 25), lost the card at its 19th row and went on saying "running" (the user's Pascal card, 2026-10-04). A watchdog now waits on each step, the card's loss and a timer together, whichever comes first; it keeps the rows already done and names the row it stopped in. Its timers are choices, not measurements: a minute for a sample, where the slowest seen was 1.35 s (runwatch.js, tests.js).

7What went wrong before

  • A frame of no samples. Section 1's floor guard ().
  • A governor that hunted. Section 1's fixed steps ().
  • The heavy scene handed a light one's count. The governor's count climbs on a fast scene and was never reset, so a light scene's 64 samples a frame were once handed to the heavy scene opened next; a sample that fits a slice now gets only as many as fit ().
  • A card that hung. Section 3: one submission of a whole heavy sample ().
  • A frame timed with its wait. The split is sized from how long the card was busy with the last frame; timed from the frame's recording instead, a 200 ms sample queued behind another read about 400 and was cut into submissions it did not need (chapter 25's story).
  • A run that never ended. Section 6's watchdog ().