The measurement device · RadianceLab
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.
01 — What a control has to reach
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:
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.
| Control | What it reaches | Grade |
|---|---|---|
| Focal length | Field of view for every renderer — the megakernel through a uniform, the other CPU integrators by rebuilding a pinhole from camera.fovY() | seen |
| Pinhole size | A uniform, and through it the BDPT strategy count: a finite aperture adds a connection strategy to the MIS denominator | seen |
| f-stop / focus distance | Two uniforms; the megakernel's own thin-lens ray. GPU only | seen |
| Aperture shape and its five parameters | One uniform and a rejection sampler in pt_aperture.glsl. GPU only, and not the enum of the same name in src/camera/ | seen |
| Focus indicator | The display image, after tonemap. A tint, not a measurement — the float film never sees it | never viewed |
| Ctrl+1 / 2 / 3 | The C++ camera class, which the megakernel does not read. Two log strings are the only consumers of the result | seen |
| Tilt-shift block | Nothing. The Scheimpflug ray is written and correct; no field it needs has a writer | inert, honestly marked (WIP) |
| Film size (43 mm) | Every field of view in the program — and it has no slider anywhere | seen |
| Shutter open / close | ray.time, which nothing downstream reads | absent |
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
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:
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.
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
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:
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.
What the roster calls DOF visualisation is one collapsing block in the Camera panel, spelled DOF Visualization there, and it holds three controls:
| Control | Range | Grade |
|---|---|---|
| Focus Indicator | Off · Green = In Focus · Red = Out of Focus | never viewed |
| Focus Sharpness | 0.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 else | never viewed |
| Auto-Focus (click viewport) | On, with an Instant sub-checkbox; otherwise the focus distance eases to the clicked point | seen |
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.
04 — The census
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?
Three consequences follow, and the third is the one that makes the section worth writing.
| Object | State | Why |
|---|---|---|
| ThinLensCamera's aperture sampling | never executes | Its 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 ray | never executes | The 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::apertureShape | unreachable | The 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 |
| chromaticAberration | absent | One 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
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.
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.
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
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().
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.
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:
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.
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:
07 — The camera, inverted
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.
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:
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
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.
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.
| Panel | What is in it | Grade |
|---|---|---|
| FancyWidgets | Nineteen 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 does | seen |
| UI Test panel and tab | Two 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 draw | seen |
| Style Editor | One 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 colour | seen |
| Scene Hierarchy | Parented 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 click | seen |
| Properties | Four tabs and two panels-within-the-panel. Where an entity becomes physical — argued at length on Ten integrators, one scene | seen |
| Properties ▸ Transform | Position, 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 |
| Console | Level 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 clipboard | seen |
| Performance overlay | Two 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 looks | seen |
| Render Settings shell | Four colour-coded categories opening 21 tabs, auto-wrapping to the window width, remembering the last tab you used per category | seen |
| Active Integrators panel | Defaults visible and has no menu entry, so closing it leaves no route back short of deleting the layout file | seen |
| Docking and layout persistence | A full dockspace with a default layout built once; the arrangement survives restart through imgui.ini, with no filename override set | seen |
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.