Builtin Library
readme.md
lensFx system
The lens the scene is photographed through, and the film behind it.
A rendered frame is what a perfect pinhole would record. A real photograph is made by a stack of glass elements with dust on the front, and recorded on a medium with a physical grain — and most of what reads as "photographic" comes from those imperfections rather than from the scene.
Using it
local id = entity.spawn("lens").id
entity(id).component.add("LensFx", {
flareIntensity = 1.0,
grainIntensity = 0.03,
dirtIntensity = 0.5,
})
Every field is live. Setting flareIntensity and grainIntensity both to 0
releases the passes without losing the rest. For runtime control (a photo mode,
a quality slider) drive the module:
local lensFx = require("@builtin::systems.lensFx.lensFx")
lensFx.set({ flareIntensity = 1.0, grainIntensity = 0.03 })
lensFx.tick(dt) -- advances the clock the grain is drawn against
lensFx.get()
lensFx.active()
lensFx.clear()
Driving the module directly means owning tick too: grain is a fresh field
every frame, and a value that has not changed is not re-pushed, so the frame's
own time cannot arrive through set. The component does this for you.
What the knobs do
| field | effect |
|---|---|
threshold | Luminance a pixel must reach to throw a flare. A soft knee below it fades a source in rather than popping it on. |
flareIntensity | Scales the whole flare — ghosts, halo and streak together — around the shares the lens is built with. 1 is that lens; 0 turns it off. |
ghostCount / ghostSpacing | How many internal reflections are drawn, and how far apart they sit along the optical axis. |
haloWidth / haloIntensity | Radius and brightness of the halo ring. |
streakIntensity / streakLength | The anamorphic streak a cylindrical element spreads along one axis. |
chromaShift | How far apart the three channels are pulled inside a ghost. |
grainIntensity / grainSize | Depth of the grain, and the size of one grain in pixels. |
grainResponse | Shape of the grain's response to luminance. Higher values narrow it toward the midtones. |
dirtIntensity / dirtScale | How much the grime on the front element varies the flare, and how fine the smudges are. |
How it renders
lensOptics.renderFeature runs three compute passes after lighting.
The first reduces the frame to the parts bright enough to have an optical response, at quarter resolution, behind a soft-knee threshold so a source fades in instead of popping.
The second builds the flare from those bright parts. A flare is not painted at the light — it is what the lens does with the light. Each air-glass interface reflects a little of the incoming cone back down the barrel, and those reflections land on the sensor mirrored through the optical axis. That is the whole geometry: sampling the bright buffer at points reflected through the centre of the frame. It is also why the ghosts sweep across the frame as a light moves rather than sitting on top of it, and why a source near the centre produces ghosts bunched near the centre. The halo is the widest of those reflections wrapping into a ring, the streak is a cylindrical element spreading a highlight along one axis, and each ghost pulls its three channels to slightly different radii because glass disperses.
Each of those carries a share of its source, and that share is what bounds
the effect. A reflection returns a fraction of the cone that entered the lens,
so a ghost lands about a hundredth of the light its source put in; the halo
lands less, being the same reflection spread around a ring, and the streak less
again, being one smeared along a whole axis. The bright buffer holds light, so a
share of it is light: a lamp at 20 flares ten times as hard as one at 2. What a
feature lands is its share times flareIntensity, so at the lens as built —
intensity 1, the largest share a hundredth — a source flares at a fraction of
what it shines. That is why an HDR night scene throws a flare off its lamps and
its signs instead of laying white over the frame: the lens scatters the light it
is given rather than inventing any. lfx_features names the three shares;
flareIntensity scales them together, so an intensity far above 1 is asking for
a flare brighter than its source.
The third composites. The flare is added through a dirt mask, so the grime modulates the flare and leaves the scene itself untouched — a clean lens shows the same picture, just without the bloom-driven grime lighting up. Then the grain: a field redrawn every frame, sized in pixels because silver halide crystals are a fixed physical size, and strongest in the midtones because shadows hold too little exposed silver to vary and highlights are saturated. That midtone peak is why film grain reads as texture in skin and skies rather than as uniform noise over the whole frame.
The flare is gathered at quarter resolution and read back bilinearly. It is a low-frequency effect, so the resolution costs it nothing, and the four-fold reduction is what keeps a many-tap ghost chain affordable.
Files
lensFx.package/
LensFx.component/ -- the lens and film, authored and persisted
lensFx.module/ -- .set / .tick / .get / .clear / .active
lensOptics.renderFeature/ -- the three compute passes