Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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

fieldeffect
thresholdLuminance a pixel must reach to throw a flare. A soft knee below it fades a source in rather than popping it on.
flareIntensityScales 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 / ghostSpacingHow many internal reflections are drawn, and how far apart they sit along the optical axis.
haloWidth / haloIntensityRadius and brightness of the halo ring.
streakIntensity / streakLengthThe anamorphic streak a cylindrical element spreads along one axis.
chromaShiftHow far apart the three channels are pulled inside a ghost.
grainIntensity / grainSizeDepth of the grain, and the size of one grain in pixels.
grainResponseShape of the grain's response to luminance. Higher values narrow it toward the midtones.
dirtIntensity / dirtScaleHow 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