Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

readme.md

contactShadows system

Short screen-space shadow traces that supply the small-scale contact a shadow map cannot resolve.

A shadow map has a texel size. Contact finer than that texel — a chair leg on a floor, a prop on a table, fingers on a desk — falls between texels and is simply not there, which is why small objects read as hovering slightly above the surface they are resting on. No amount of shadow resolution fixes it, because the missing detail is below the sampling scale.

This traces a short ray through the depth buffer from each shaded pixel toward the light and darkens the pixel when the ray passes behind visible geometry. It adds the contact term; the shadow map keeps doing everything else.

Using it

local id = entity.spawn("contact_shadows").id
entity(id).component.add("ContactShadows", {
    strength = 0.9,   -- how dark a fully occluded contact goes, 0..1
    length = 0.35,    -- trace length in WORLD units
    steps = 16,       -- samples along the trace
})

Every field is live. Setting strength to 0 turns it off without losing the rest. For runtime control (a quality slider, a cutscene) drive the module:

local contactShadows = require("@builtin::systems.contactShadows.contactShadows")

contactShadows.set({ strength = 0.9, length = 0.35 })
contactShadows.get()
contactShadows.active()
contactShadows.lights()   -- the lights this frame's traces aim at
contactShadows.clear()

What the knobs do

fieldeffect
strengthHow dark a fully occluded contact goes, in [0, 1]. 0 turns it off and releases the pass.
lengthTrace length in world units. This is the scale of contact supplied, so it scales with the scene rather than the screen.
stepsSamples along the trace. More resolves finer occluders, at linear cost.
biasNormal offset before the march starts, so a surface does not read as its own occluder.
thicknessWorld-space thickness assumed for an occluder. Past it the ray has emerged out the far side and stops counting.
localLightsWhether the scene's point, spot and distant lights supply contact of their own alongside the sun.
maxTracedLightsHow many of them a single pixel may trace toward — the heaviest contributors at that pixel. Each one costs that pixel another march, so this is the per-pixel ceiling.

lightDirection tracks the scene's sun on its own, re-read every frame. Pin it through contactShadows.set when a scene wants the sun's trace aimed elsewhere; a later set that names no direction goes back to tracking the sun.

Lights

A lamp on a desk grounds the props under it the same way the sun does, and the same shadow-map texel that loses the sun's contact loses its. So every light the scene holds supplies contact: the sun, and the point, spot and distant lights, each traced toward and each weighted by the share of the pixel's light it put there — its intensity and colour luminance against the same distance attenuation and cone the renderer shades it with. A pixel darkens by the share of its light the traces found blocked, which is what keeps a lamp's contact out of the regions that lamp does not reach.

A local light's ray also stops at the light itself rather than running the full length: geometry past a lamp stands between nothing.

The pool holds point, spot and distant lights. An area light shades from a surface rather than a point, and the trace takes a single direction per light, so contact under one comes from the sun and whatever punctual lights share the scene with it.

The module selects the pool once a frame — every distant light, then the local lights whose sphere of influence touches the view, ranked by what each is worth at the camera — and that rank is what a scene holding more lights than the pool has rows spends them on. contactShadows.lights() reads back exactly what it selected. The view it selects against is the main camera's, so an offscreen camera looking somewhere else traces toward the lights the main view holds.

At each pixel the pass then weighs the whole pool — intensity and colour luminance against that pixel's own N·L, distance attenuation and cone — and marches toward the maxTracedLights heaviest. So a lamp across the room that ranked into the pool does not spend the budget of a pixel the lamp beside it is lighting.

How it renders

contactShadows.renderFeature runs two compute passes after lighting.

The first marches the rays — one per light reaching the pixel. Each sample along a ray is projected back to the screen and compared, in world space, against what the depth buffer holds there — the ray is occluded while it is behind visible geometry and still within thickness of it. The march is jittered per pixel so the step pattern lands as noise rather than visible stair-stepping, and a surface facing away from the light is skipped outright: it is already unlit, so there is no contact term to add and tracing would only produce self-shadow acne.

The second filters that result with a depth-aware blur and composites it. The composite scales the contact term by how lit the pixel already is — @scene.material against the shaded colour estimates the lighting already applied — so a region the shadow map has already darkened takes little further darkening and the two shadow sources do not stack.

The forward view-projection needed to project a sample back to the screen is derived from the inverse the frame provides, rather than passed in from script, so an offscreen capture or an RTT camera projects through its own matrix.

The normal buffer is the deferred G-buffer, so this needs the deferred path (the default).

Files

contactShadows.package/
  ContactShadows.component/     -- the scene's contact shadows, authored and persisted
  contactShadows.module/        -- .set / .get / .clear / .active / .lights / .refresh
  contactShadows.renderFeature/ -- the two compute passes