Builtin Library
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
| field | effect |
|---|---|
intensity | How much of the reflection reaches the image, in [0, 1]. 0 turns reflections off and releases the passes. |
maxDistance | How far a reflection ray travels, in world units. |
thickness | How 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. |
roughnessCutoff | Perceptual 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. |
quality | low / medium / high / ultra — how many steps the march spends and how many neighbouring rays a pixel gathers. |
edgeFade | How 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