Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commitdd348ac6c3

readme.md

reflections system

Screen-space reflections — a polished floor reflects what stands on it, wet ground reflects what is above it, and metal picks up its surroundings, all from geometry the camera can already see.

Environment reflections describe a surface's surroundings in general. They cannot show the character walking across the floor, because that character is not in the environment — it arrived this frame. Screen-space reflections read the frame the engine has just drawn, so anything visible can appear in anything reflective, with nothing authored per surface.

What that buys and what it costs are the same thing: only what is on screen can be reflected. A reflection of something behind the camera, or off the edge of the frame, is not there to find. So reflections fade out toward the screen edge rather than cutting off, and where a ray finds nothing the surface keeps the shading it already had.

Using it

Reflections are a render feature. List it on the scene's RenderFeatures entity with the settings it should run under, and it persists with the scene:

local id = entity.spawn("render features").id
entity(id).component.add("RenderFeatures", {
    features = {
        { feature = "@builtin::systems.reflections.ssr", settings = {
            intensity = 1.0,    -- how much of the reflection reaches the image
            maxDistance = 40,   -- how far a reflection ray travels, world units
            quality = "high",   -- low | medium | high | ultra
        } },
    },
})

Every setting is live: edit one in the inspector and the reflections follow. renderer.feature.schema("@builtin::systems.reflections.ssr") lists every knob with its default.

For code that adjusts reflections at runtime — a quality slider, a cutscene, weather turning pavement wet — the module is the hand on the same knobs:

local ssr = require("@builtin::systems.reflections.ssr")

ssr.set({ intensity = 0.6, quality = "medium" })  -- any subset; the rest is kept
ssr.get()                                         -- the settings in force
ssr.active()                                      -- are the passes running
ssr.clear()                                       -- the passes down

set brings the feature up when none is live, and an intensity of 0 takes it down; the same feature is found rather than stacked when RenderFeatures already runs it.

What the knobs do

fieldeffect
intensityHow much of the reflection reaches the image, in [0, 1]. 0 turns reflections off and releases the passes.
maxDistanceHow far a reflection ray travels, in world units.
thicknessHow thick a surface is assumed to be, in world units. A ray passing behind one by more than this went past it rather than onto it.
roughnessCutoffPerceptual roughness up to which a surface takes the full screen-space reflection. Above it the reflection hands over to the probes and the sky, and is gone 0.1 higher.
qualitylow / medium / high / ultra — how many steps the march spends and how many neighbouring rays a pixel gathers.
edgeFadeHow far from the edge of the screen reflections fade out, in UV.

roughnessCutoff is the one to understand first. Every pixel traces one ray drawn from the lobe its roughness describes, and gathers the rays of its neighbours that stand on the same surface, so a rough floor shows a bright sign as the soft, stretched streak its lobe spreads the sign into, and a mirror shows it sharp. Up to the cutoff a surface takes the full screen-space reflection. Above it the reflection hands over to the probes and the sky, and it is gone 0.1 higher, so the hand-over has a soft edge in place of a line. The default, 0.75, covers wet asphalt and polished stone. A scene that states a lower cutoff (0.4 was the old default) leaves those surfaces to the probes.

Where the screen holds only part of a surface's lobe (the lobe reaches off the edge of the frame, or behind the camera) the reflection covers the part it found and the probe or sky fills the rest.

maxDistance and quality trade against each other: quality sets the step count, so a long distance at a low quality samples coarsely and reflections thin out at range. They thin rather than disappear because the hit refinement adapts to the step size on its own.

How much of a reflection shows through is the surface's own Fresnel term, taken from the G-buffer's metallic. Metal takes nearly all of it; a dielectric floor takes a few percent head-on and much more at a grazing angle — which is why a wet road reads dry underfoot and mirror-like down the street.

How it renders

ssr.renderFeature runs two compute passes at the afterDepthPrepass seam, before anything shades. ssr_trace draws one ray per pixel from the pixel's GGX lobe (a visible-normal sample; a mirror, below roughness 0.1, keeps its one reflection ray) and marches it against this frame's depth. The surface's normal and roughness come from the G-buffer as the previous frame left them, reprojected through the previous view-projection. The pass writes where each ray hit and how confident the hit is, or the direction it travelled when it found nothing. A rough pixel's ray marches fewer steps, because the next pass blurs what the rays find.

ssr_resolve gathers each pixel's lobe over its own ray and the rays of up to twelve neighbours (4 at low and medium, 8 at high, 12 at ultra), weighing every ray against the pixel's own lobe and counting a neighbour only where it shows the same surface. The light at a hit is read from the colour the previous frame ended with, where the hit stood then. The resolve accumulates the result over frames in a history target (one image pair per view), holds that history to the spread of the rays it gathered, and writes the scene's reflection channel, @scene.reflection: the light of the rays that found something on screen, and the share of the lobe those rays cover.

A material reads that channel the same frame, at its own pixel, inside its environment term: it mixes the traced light over the probe and the sky by the share of the lobe the screen answered, so what the screen did not find comes from the environment, and the material's Fresnel, roughness and tint weigh the result into the pixel as they weigh any environment. The reflection stands where the camera is now; only the light in it is a frame old. The reflection capture pass shows it. Where the trace found nothing the channel holds zero and the material reflects its environment alone.

A camera drawing a diagnostic pass holds readings in its colour target rather than light, so the passes have no light to read from that frame: such a frame is drawn lit first, unpresented, the passes run for that, and the diagnostic reads the channel gathered for where the camera stands now. A capture drawn once, or a camera drawn again from a new station, is drawn lit first for the same reason, so its first frame holds a reflection (without accumulated history, so it shows more grain than a continuously drawn view). The march steps uniformly in screen space and compares in world space; the full account, including why thickness is tested after refinement rather than during the march, is in the render feature's own readme.

Each view that draws the feature holds three screen-sized images of its own: the rays and the history pair, about 50 MB at 1080p for the viewport.

The pass takes its camera from @frame.camera, so reflections are correct in offscreen captures and render-to-texture cameras, not only the live viewport.

The demo scene

The scene @builtin::systems.reflections.demo.polished_floor, loaded with the scene.load tool, is a polished stone floor with three coloured pillars standing on it, under an afternoon sky, with the feature listed on the scene's render_features entity. The floor reflects the pillars and a player walking across it; delete the entity and it mirrors the sky alone. demo/polished_floor.scene/build.luau is the whole scene as code, materials included.

Files

reflections.package/
  ssr.renderFeature/                 -- the trace and resolve passes, and the settings they take
  ssr_trace.computeShader/           -- draws and marches one lobe-sampled ray per pixel
  ssr_resolve.computeShader/         -- gathers each pixel's lobe, accumulates, writes the reflection channel
  ssr.module/                        -- ssr.set / .get / .clear / .active, the runtime hand on them
  ScreenSpaceReflections.component/  -- the older one-feature component; RenderFeatures is the list
  demo/polished_floor.scene/         -- the showcase: a polished floor, three pillars, the feature on one entity