The measurement device · RadianceLab

The camera is not
a viewpoint

It is an aperture with a measure — and here every camera control either changes the transport or changes nothing.

The lens model is the one subsystem in this renderer with two independent implementations of the same idea, of which only one has ever produced a pixel. This page is about which, and how that is known.

Lens samples ever drawn
0
every generateRay call site in the program passes a zero lens sample
Aperture implementations
2
different shape sets; the one in src/camera/ has no writer outside it
Projection self-check
15
probes at 0.05 px; on failure the gather refuses rather than draw wrong
Presets that set a focal length
14
across ten branches, 24–34 mm; one global reset stands between them and a leak

01 — What a control has to reach

A camera control is a transport change, or it is a lie

In a rasteriser a camera is a matrix and the argument ends there. In a path tracer it is a measurement device: an aperture of finite area that light can physically arrive at, an importance function with units, and a mapping from world points to pixels that some estimators have to invert. Change the aperture and you have changed which light paths exist. Change the focal length and you have changed the measure every integrator in the program integrates against.

So a camera panel in a renderer like this one is making a claim every time it draws a slider, and the claim is checkable. There are exactly three things a lens control can reach here, and a control that reaches none of them is decoration no matter how physical its label:

  • The ray generator the renderer actually calls. Camera::generateRay is a virtual with three implementations. Whether a control reaches it is a question about call sites, and §04 answers it.
  • A uniform the megakernel reads. The GPU tracer builds its own primary ray and never calls the C++ camera at all, so a control reaches it only by way of an integer or a float named uSomething.
  • An estimator that inverts the projection. Two of the photon-primitive strategies do not walk from the eye; they compute where a point lands on the film. Those read the camera backwards, and they check that they are allowed to (§07).

Every row below is graded by that test. The panel itself never says which column a control is in, and three of these look identical while they sit on opposite sides of it.

ControlWhat it reachesGrade
Focal lengthField of view for every renderer — the megakernel through a uniform, the other CPU integrators by rebuilding a pinhole from camera.fovY()seen
Pinhole sizeA uniform, and through it the BDPT strategy count: a finite aperture adds a connection strategy to the MIS denominatorseen
f-stop / focus distanceTwo uniforms; the megakernel's own thin-lens ray. GPU onlyseen
Aperture shape and its five parametersOne uniform and a rejection sampler in pt_aperture.glsl. GPU only, and not the enum of the same name in src/camera/seen
Focus indicatorThe display image, after tonemap. A tint, not a measurement — the float film never sees itnever viewed
Ctrl+1 / 2 / 3The C++ camera class, which the megakernel does not read. Two log strings are the only consumers of the resultseen
Tilt-shift blockNothing. The Scheimpflug ray is written and correct; no field it needs has a writerinert, honestly marked (WIP)
Film size (43 mm)Every field of view in the program — and it has no slider anywhereseen
Shutter open / closeray.time, which nothing downstream readsabsent

The one sentence a reader should keep. Every depth-of-field pixel this program has produced came from a re-implementation, because Camera::generateRay has never once been called with a lens sample. The lens model that ships in src/camera/ is complete, correct and unexercised; the lens that renders is a couple of dozen lines of GLSL in the megakernel. §04 is that census.

02 — The pinhole with a size

Opening the pinhole adds a strategy to the MIS count

The default camera is a pinhole, and the slider beside it gives the pinhole an area. The blur that results is uniform rather than depth-dependent — the ray origin moves on a disk and the direction is left alone, so there is no focus plane and nothing is sharp. As an image that is a curiosity. As transport it is the difference between a light path existing and not existing.

The naive light tracer walks from the emitter and contributes only when a ray physically enters the aperture disk in space. A point aperture has measure zero, so that strategy contributes with probability zero, and the panel says so in red the moment you select it: Needs pinhole size > 0! — then, once it is positive but small, a second line in amber advising 500 mm or more. A tooltip that tells you the estimator you just picked cannot fire is doing the job of a paper's footnote.

The same radius is read one level up, inside the bidirectional path tracer's MIS weight. The strategy counter that normalises every connection asks whether the aperture is feasible before it counts the s ≥ 2, t = 0 cell:

bool aperFeasible = _bdptAperRadius() > 0.00001;
if (totalVerts ≥ 2 && (uLightStrategy == 3 || uLightStrategy == 5) && aperFeasible) count++;
From shaders/modules/pt_bdpt.glsl. The aperture is not a post-process on the image; it is a term in the denominator of every other strategy's weight. Turn the pinhole up and the light-tracer aperture hit joins the mixture, and every other connection is re-weighted to make room for it.

Two smaller pieces of the same panel are worth naming because they are the sort of thing that only gets built after somebody has lost an afternoon. The pinhole slider is on a perceptual power curve rather than linear, so the useful range is not compressed into the first two percent of its travel. And beneath it the panel prints a graded verdict — Subtle, Moderate, Heavy blur, with the millimetre figure — so a screenshot of the panel records what the picture should look like as well as what was set.

Screenshot — View ▸ Camera · preset 47 "Vol Caustic Lab" · Render ▸ Integrator ▸ Light Tracer (naive) so the aperture warning is live · shoot the panel TWICE at the same camera pose: once with Pinhole size 0 and the red "Needs pinhole size > 0!" line showing, once at 500 mm with the "Heavy blur" verdict · if you want the amber "Naive LT converges slowly. Try pinhole 500+ mm." line in shot as well, take a third frame on the way up, below 200 mm, because it stops being drawn above that · include the viewport in both so the difference between an empty light-traced frame and a blurred one is in the same shot

What that pair of frames proves. Not that the blur is pretty — that the estimator's support changed. At pinhole 0 the naive light tracer has no target to hit and the frame is whatever the eye path alone found. At 500 mm the same light paths terminate on a disk that exists, and a whole family of connections starts contributing. It is the cleanest demonstration on this site that a camera parameter is a transport parameter.

03 — The thin lens, and five shapes

Bokeh is a rejection sampler, and the focus indicator is a tint

Switch the model to Thin Lens and the aperture stops being a hole and becomes a lens: the ray origin moves on the aperture and the direction is re-aimed at the point where the pinhole ray crosses the focal plane. That is a real thin-lens camera, with an aperture radius derived the photographic way — focal length over f-stop, halved, in metres — from an f-stop slider that runs from 0.1 to 10000 on a log scale and a focus distance from 0.01 to 100 m.

The aperture it samples is not a disk unless you ask for one. Five shapes are implemented and rejection-sampled in the shader, each with its own parameters:

Circlethe concentric-disk default
Polygon3 – 12 blades, tilt 0 – 360°
Star3 – 12 tips, tilt, inner radius 0.05 – 0.95 — lower = sharper tips
Crossarm width 0.05 – 0.8, tilt; arms 2 – 8 on the slider, floored at 4 in the shader
Ellipseaspect 0.1 – 3.0 — <1 tall, >1 wide
Where it runspt_aperture.glsl — GPU only

Every one of those is seen in the catalogue and appears in no figure anywhere on this site, which makes the shaped-aperture block the largest photographable feature on this page.

The focus indicator, and why it is not evidence

What the roster calls DOF visualisation is one collapsing block in the Camera panel, spelled DOF Visualization there, and it holds three controls:

ControlRangeGrade
Focus IndicatorOff · Green = In Focus · Red = Out of Focusnever viewed
Focus Sharpness0.005 to 0.5 on a log slider, under the panel's own hint — “Lower = thinner in-focus line”. It sets how narrow a band counts as sharp, and nothing elsenever viewed
Auto-Focus (click viewport)On, with an Instant sub-checkbox; otherwise the focus distance eases to the clicked pointseen

Two facts about it matter more than the modes. It is gated on the aperture radius, not on the camera model — uFocusIndicator > 0 && uApertureRadius > 0.0001 — so on a pinhole camera it does nothing however large you make the pinhole, which is correct (a uniform blur has no focal plane to indicate) and completely unexplained in the panel. And it is applied after exposure, Reinhard and gamma, straight onto the display image. It is a tint over a tonemapped frame, not a channel of the render. The linear film that every number on this site was measured from never sees it, which is exactly the property you want in a diagnostic overlay and exactly the property that makes it useless as a measurement.

video · An f-stop sweep with the focus plane drawn on it
Preset 3 (Material Showcase), GPU path tracer, camera model = Thin Lens. Frame the sphere row obliquely so there is real depth from front to back. Set Focus Distance on the middle sphere by clicking it. Then sweep f-stop from 22 down to 1.4 over 10 s and hold; switch Focus Indicator from Off to "Green = In Focus" and hold 5 s; switch to "Red = Out of Focus" and hold 5 s; then set Aperture Shape to Star, tips 6, inner radius 0.15, and hold 8 s on a frame with a specular highlight behind the focal plane.
Watch for The depth of field collapsing onto one sphere while the others bloom, then the green band appearing exactly where the collapse left the image sharp — the indicator and the blur are computed from the same two numbers, so they agree by construction. At the end, the out-of-focus highlights stop being discs and become six-pointed stars: that is a rejection sampler in the aperture, not a post-process on the image.

04 — The census

src/camera/ is a complete lens model that has never rendered a pixel

There is a proper camera abstraction in this repository. A base class with a virtual ray generator, three concrete cameras, an aperture sampler with five shapes, a Scheimpflug tilted-film implementation, a shutter, and a projection routine documented as the exact inverse of the pinhole ray. It is good code. Almost none of it runs.

The test is a single grep, and it is the kind of check that is worth more than any amount of reading: what is passed as the lens sample at every call site?

Ray camRay = cam.generateRay(Vec2(uvx, uvy), Vec2(0.0f), rng);
Ten call sites, all in src/render/PPIntegrator.cpp — one in the camera walk, three in the primitive explorer's trace, one in the raster projection check, two in the correlated paths and three in the pass driver. All ten pass that literal Vec2(0.0f); no call site anywhere in src/ passes anything else. And no other renderer calls the class at all: the megakernel builds its primary ray in GLSL, and the other CPU integrators — MLT and PSSMLT, VCM, UPBP, path guiding, manifold exploration — each rebuild a bare pinhole from position, lookAt and camera.fovY().

Three consequences follow, and the third is the one that makes the section worth writing.

ObjectStateWhy
ThinLensCamera's aperture samplingnever executesIts lens sample is always the origin, under which it collapses exactly onto the pinhole ray. Every depth-of-field pixel came from the megakernel's own re-implementation instead
TiltShiftCamera — the Scheimpflug raynever executesThe tilted-film ray is really implemented. Every field it needs — filmTiltX/Y, filmShiftX/Y, filmCurvature, apertureTiltX/Y, usePlanarFocalRef — occurs only inside src/camera/. No writer, no uniform. It degenerates exactly to a thin lens, which degenerates to a pinhole
CameraParams::apertureShapeunreachableThe two-implementation finding. The enum is { Circular, Hexagonal, Square, Star, Custom } and it is read in exactly one place, inside Camera::sampleAperture, which is only ever called from the zero-lens path. The panel writes a different integer — { circle, polygon, star, cross, ellipse } — into a GLSL uniform. Two aperture implementations, two different shape sets, one of them with no writer outside its own directory
chromaticAberrationabsentOne declaration in Camera.h. Zero other references in src/ or shaders/

Why this is a finding and not a complaint. Two implementations of one idea, diverging silently, is the failure mode this whole site is organised against — and it is invisible to every test that renders an image, because the image is correct. The megakernel's lens is right. The panel's shape list is right. Nothing looks wrong; there is simply a second camera model in the repository that agrees with the first only because it is never asked. It was found by grepping an argument position, which is the cheapest audit in this document and the one that returned the most.

The honest half: the tilt-shift block is labelled (WIP) in the panel, so it is a completeness gap rather than a lie. The aperture-shape divergence is not labelled anywhere.

05 — Two switches, no wire between them

Ctrl+3 builds a camera that no renderer reads

There are two ways to change the camera model and they do different things to different objects. Nothing keeps them in step, and the panel shows only one of them.

  • The hotkeys. Ctrl+1, Ctrl+2 and Ctrl+3 call Engine::setCameraType, which destroys the current camera object and constructs a PinholeCamera, a ThinLensCamera or a TiltShiftCamera in its place. That function has three call sites in the program, and all three are those keys.
  • The panel radios. Pinhole and Thin Lens write m_ui.cameraModel, an integer that is 0 or 1 and has no third value. That integer is what reaches uCameraModel and decides which branch the megakernel's primary-ray code takes.

So the two switches move two different objects. Press Ctrl+3 and a tilt-shift camera really is constructed — and the megakernel keeps rendering whatever the radios last said, the estimator's zero lens sample collapses the new camera back onto a pinhole ray, and the panel still reads Pinhole. The only consumers of the class you just built are typeName()'s two readers, and one of them is the window frame: the title bar, rebuilt every frame as RadianceLab | FPS | renderer | camera | frame, and the estimator's own refusal message when a camera it cannot invert shows up (§07).

A correction to this section. It said the first of those two readers was a line in the frame log. It is not: the line setCameraType logs reports the string it was passed — “pinhole”, “thin_lens”, “tilt_shift” — and never asks the object what it is. So the only place in the program that interrogates the constructed camera and puts the answer where a person can see it is the operating system's window title, which is also the one place the mismatch in this section is visible without a console: press Ctrl+3 and the title bar changes while the panel does not.

Which makes the refusal message the interesting one. It is the single place in the program where the C++ camera class is allowed to change a rendering decision — and what it decides is to stop.

Screenshot — Any preset · open View ▸ Camera and View ▸ Console side by side · press Ctrl+3 · shoot the whole application window, title bar included, in one frame: the title bar's fourth field now reading tilt_shift, the console line naming the camera type that was just constructed, and the Model radios still showing Pinhole selected · leave the viewport visible and unchanged between them

The fix is one line and it is not the point. Syncing the radio to setCameraType would take a minute. What the shot records is a structural fact worth more than the fix: a program can hold two representations of the same user-visible state, keep both correct, and still be wrong — and the only reason anyone found out here is that a third component, the rasterised gather, refuses to run against a camera it has not verified.

06 — The one parameter everything honours

A 43 mm sensor with no slider, and the leak that came out of it

Field of view is derived, not set. The camera holds a film size in millimetres, takes sixty percent of it as the sensor height, and computes the vertical field of view from that and the focal length. So focal length is the only lens parameter that reaches every renderer in the program — the megakernel through a uniform, the estimator through generateRay, and every other CPU integrator by rebuilding its own pinhole from camera.fovY().

fovY = 2 · atan( 0.6 · filmSize / (2 · focalLength) )
filmSize is 43 mm, the full-frame diagonal, and there is no slider for it anywhere in the interface. The only writer in the entire program is the comparison-report JSON importer, which round-trips it along with everything else. So the sensor is a constant that one file-load path can quietly change.

The leak

Until recently exactly one preset set a focal length — The Gate, at 26 mm, chosen so the slot mouth lands where it should in frame. Presets do not reset what they do not set. So loading The Gate and then loading anything else left the camera at 26 mm forever: every later frame rendered at a vertical field of view of 52.8° instead of 40.5°, a thirty percent wider field, permanent until restart, with nothing on screen saying so.

The fix is one line in the preset prologue — focalLength = 35.0f before the dispatch — and the comment beside it is the part worth publishing:

“The byte-identity harness is structurally blind to it, because every headless render launches a fresh process and so never loads two presets in sequence.” That is a whole class of bug named precisely. A regression suite built on rendering one scene and hashing the film cannot see state that only accumulates across scene loads, however many films it pins. The suite is not weak; it is answering a different question, and knowing which question a test answers is the difference between a gate and a comfort.

Fourteen presets now set a focal length, across ten dispatch branches: The Gate, the three rich rooms and the exterior Reliquary at 26 or 28 mm; the four gate-family apertures at 34, 26, 26 and 24 mm; and the five-member gloss series, one branch, at 30 mm because its subject is the halo around the ball rather than the ball. Each of those numbers is a framing decision recorded in source with its own comment; together they are the reason the reset had to exist.

The dolly zoom, which is the leak's exception

Q in FPS capture pins a target at the current focus distance, stores the focal length and distance it started from, and then solves the focal length from the distance on every frame:

fnew = fref · ( dnew / dref )   clamped to [4, 1200] mm
A Hitchcock zoom, solved rather than keyframed — there are no keyframes anywhere in this program. Mouse look is locked out, only forward and back along the pinned axis are allowed, and you are stopped half a unit short of the target. Note the clamp range: the Camera panel's own focal-length slider runs 10 to 200 mm and cannot reach either end of it.

The order within a frame is input, update, render. The dolly solve is in input; the preset prologue's 35 mm reset is in render. So with the dolly zoom on, both the reset and a preset's own focal length survive exactly one frame before the solver overwrites them — from a reference captured before the load, against a target still pinned in the previous scene's coordinates. The leak this section reports fixed is fixed only while the dolly is off, and the page says so rather than leaving the paragraph above it standing unqualified.

And a correction to two of this site's own pages

Both of them call what Q draws a “read-out”. It is not. It is a cinematic HUD: letterbox bars top and bottom, pulsing accent lines, a crosshair, four corner brackets, a DOLLY ZOOM [Q] title and a live FL / ref pair in the bottom bar. Two things about it were wrong on this site until now, and one thing about it is wrong in the program:

  • By default, pressing Q shows none of it. The whole HUD sits inside renderPerformanceOverlay(), after that function's early return — and the performance overlay defaults to off, because the FPS figure moved to the menu bar. Turn View ▸ Performance on and the HUD appears; leave it off and the dolly zoom is invisible except in the panel.
  • Clean view hides none of it. That was already recorded as one of the six overlays that survive G, and it is correct — conditional, now, on the performance overlay being on in the first place.
  • The panel beside it says the overlay does not exist. The greyed Debug Overlay checkbox carries a disabled tooltip reading “no dolly overlay was ever written”. There are some fifty lines of one about a hundred and fifty lines further down the same file. The checkbox is inert — that part is true and honestly marked — but the sentence next to it is false, and it is exactly the kind of confident annotation that costs somebody an hour.

07 — The camera, inverted

Fifteen probes, 0.05 pixels, and a component that refuses

Most of this renderer walks from the eye, so the camera is a ray generator and nothing else. Two things here do not, and they need the camera run the other way: world point in, pixel out. That inverse is where the camera stops being a viewpoint in the most literal sense — it becomes a function whose correctness another component has to establish before it is allowed to use it.

  • Strategy 17, the camera sphere, is the camera-side dual of the light sweep: it sweeps the camera subpath into a manifold and splats the result onto the film. It produces a whole-image estimate per pass rather than a value per pixel, which is why it is the one member of the family that never reaches the per-pixel sampler at all — and the one strategy that opts out of correlated batching, because it returns before the branch.
  • The rasterised gather inverts the projection for a different reason: to bound each primitive in screen space and walk scanlines, instead of tracing every pixel against every primitive. That bound is only conservative if the projection really is the inverse of the ray the gather generates.

So it checks, and the check is the part of this page that earns it. Five pixel positions — the centre, two off-centre, two near corners — are turned into rays by the real cam.generateRay. Three points are taken along each ray at 0.5, 7 and 250 units. All fifteen are projected back. If any lands more than 0.05 pixels from where it started, the rasteriser refuses:

Probe grid5 pixel positions × 3 distances = 15
Toleranceworst residual < 0.05 px
On failurerefuses; names the camera; the tracing gather runs
Film consequencenone — the two paths are md5-identical when both run

Read the guard's own comment and the reason it exists is a camera, not a bug: “a camera whose generateRay is not the pinhole formula (PP always passes a ZERO lens sample, under which ThinLensCamera happens to collapse onto the pinhole ray — CHECKED here rather than assumed, and a finite-aperture camera primitive would break it).” The word doing the work is happens. The collapse in §04 is a coincidence of the zero lens sample, the author knew it was a coincidence, and rather than write it down as an invariant he wrote a fifteen-probe test that fires every time the raster path starts.

That is the shape of this whole project's relationship with its own camera. The correct thing is not to assume the pinhole; it is to measure that the pinhole assumption holds, at run time, and give up the optimisation rather than the answer when it does not. A component that would rather be slow than wrong is worth more than one that is fast and usually right, and it is why the finite-aperture camera primitive the guard's own comment names as the thing that would break it can be added later without silently corrupting anything.

The comment ends “It was fired on purpose; see ppMakeProj.” The guard was not written and hoped for. Somebody broke the projection deliberately to watch it refuse. That is the difference between a check and a decoration, and it is the same standard the byte-identity harness on How I know the pictures are right is built to.

08 — Written, and read by nothing

Motion blur is one field away and zero renderers away

Three things in the camera are finished on the writing side and have no reader. They are listed together because they share a shape: somebody built the producer, nobody built the consumer, and nothing in the interface admits it.

  • Shutter. shutterOpen and shutterClose exist, and every one of the three ray generators consumes them — each writes ray.time as a mix of the two, drawing a random number to do it. Nothing downstream reads ray.time. The draw is not wasted, as it happens: the estimator's correlated mode counts it as part of the three-float camera prefix it has to reproduce bit for bit, so a field nothing reads is load-bearing for a byte-identity guarantee. absent
  • Chromatic aberration. One boolean in the camera header, no other reference in the program. absent
  • F12 / Render ▸ Take Snapshot. Logs "Snapshot requested" and returns. The only working image export in the program is the comparison report's 8-bit PNG writer, argued on The instrument panel. absent

And the surface every screenshot on this site is taken through

One more inventory belongs here, because it is the largest thing on this site that has never been named: the interface itself. Every capture instruction on every page — press G, open this panel, read that line — is an instruction about a custom widget library and nine standalone panels that no page has ever described. They make no argument, so they get a ledger rather than a section.

PanelWhat is in itGrade
FancyWidgetsNineteen Fancy* entry points in one 2,859-line header — toggle, knob, slider, an emission slider with log, linear and signed modes, collapsing header, progress bar, radio group, colour badge, spinner, tooltip marker, separator text, mini plot, vec3 input, meter, LED, dropdown, tab bar — plus box groups, a coloured tab item and the knob and toggle row helpers, over a palette of eight named styles (Default, Ocean, Ember, Violet, Mint, Mono, Neon, Gold), of which the toggle carries all eight and the knob seven: there is no KnobStyle_Neon. It is why every screenshot on this site looks the way it doesseen
UI Test panel and tabTwo different things under one name. The floating panel is a small demo — two toggles and a knob in the default style, beside a node-editor sample. The tab is the real gallery: six knob styles, three knob sizes at 36×18, 56×30 and 80×42 pixels, all eight toggle styles, meters, LEDs and progress bars — the widget library rendering itself. Six knob styles, not eight: the gallery omits Gold, and there is no Neon knob to omit, so the catalogue's count for this tab counts two styles it does not drawseen
Style EditorOne of 22 collapsing-header styles, set globally and then overridden in four named scopes — Render Settings, Scene, Properties, Camera — where −1 means “follow the global” and one button puts all four back to it. Below that, small-widget vertical alignment in absolute pixels (−4 to 8, default 2.0) and again as a relative factor (−0.5 to 0.5, default 0.12), and a box-label grouping mode with its own colourseen
Scene HierarchyParented multi-face entities with per-face leaves, visibility icons, right-click hide/show, a Scene Lights section that lists the environment light as a selectable entry, and a Scene Nodes section that focuses the node editor on a clickseen
PropertiesFour tabs and two panels-within-the-panel. Where an entity becomes physical — argued at length on Ten integrators, one sceneseen
Properties ▸ TransformPosition, scale and rotation, all three of them per primitive type and all three of them applied to the whole selection group, so dragging one face of a box moves, scales or turns all six. A sphere's “Scale” is three fields averaged back into one radius — there are no ellipsoids — and a plane gets a normal and an offset instead of a rotation. The rotation control is three fields of which the third does nothing: pitch and yaw are recovered from the primitive's normal, roll is written as zero, and only pitch and yaw are read back. There is exactly one rotated box in the repository, and it is built in C++seen
ConsoleLevel filter from All to Suppress (which also silences stderr), a substring filter across body, technique and topic, Clear, Copy All, and per-line copy to clipboardseen
Performance overlayTwo numbers, FPS and milliseconds, in a borderless floating window — and, as §06 found, the dolly HUD drawn from the same function, which is why it is not as dull as it looksseen
Render Settings shellFour colour-coded categories opening 21 tabs, auto-wrapping to the window width, remembering the last tab you used per categoryseen
Active Integrators panelDefaults visible and has no menu entry, so closing it leaves no route back short of deleting the layout fileseen
Docking and layout persistenceA full dockspace with a default layout built once; the arrangement survives restart through imgui.ini, with no filename override setseen

Why a ledger and not a section. Every other page here exists because a subsystem makes an argument — about bias, about variance, about what a number means. This one does not: it is thirty widgets and ten panels, all working, all seen, none of them research. Listing them is the honest thing, expanding them would be padding, and the distinction between those two is the editorial rule this site is built on.