Builtin Library
readme.md
subsurfaceScattering system
Light that enters a surface at one point and leaves at another.
A surface shaded per-pixel answers only for the light that arrived at that pixel. Skin does not work that way: light entering the lit side keeps travelling beneath the surface and emerges past where a cosine has already reached zero, and it emerges red, because red travels furthest through flesh. That is what makes the terminator on a face soft and warm instead of a hard line, and it is why a face without it reads as plastic.
Using it
Mark the meshes that scatter — author it once and it persists with the scene:
entity(headId).component.add("SubsurfaceScattering", {
color = { 1.0, 0.3, 0.16 }, -- what light becomes beneath the surface
radius = 0.02, -- how far it travels, in world units
})
Every field is live: edit one in the inspector and the shading follows. Setting
strength to 0 leaves the frame exactly as it was lit while keeping the rest
authored.
For code that marks meshes at runtime — a character spawner, a cutscene — reach for the module directly:
local subsurface = require("@builtin::systems.subsurfaceScattering.subsurface")
subsurface.mark(headId, { color = { 1, 0.3, 0.16 }, radius = 0.02 })
subsurface.unmark(headId) -- stop scattering; the pass goes with the last one
subsurface.marked(headId) -- what it scatters
subsurface.list() -- every entity scattering
subsurface.setStrength(0.7) -- how much of the spread reaches the frame
subsurface.active() -- is the pass running
subsurface.clear() -- unmark everything
What the knobs do
| field | effect |
|---|---|
color | What light becomes beneath the surface. Its channels also set how far each one travels, so a tint weighted to red spreads red furthest — the warmth at the terminator is this. A channel at zero does not spread. |
radius | How far light travels beneath the surface before leaving, in world units over [0, 1]. Skin at human scale sits near 0.02; larger reads as wax or marble. |
strength | How much of the spread is mixed back into the frame, in [0, 1]. 0 leaves the shading exactly as it was lit. |
How it renders
The transport is a spread of exitant light across the surface, so it is computed on the frame that was just drawn.
Each marked mesh is drawn into a mask carrying its tint and radius — one draw per group of meshes sharing both, so meshes scattering differently keep their own numbers. Two passes then spread the lit colour along that mask, once across and once down. Two lines rather than a disc is what makes the reach cost twice the taps instead of their square.
Taps are confined to the mask and to a depth tolerance, so light spreads across one surface and stops at its silhouette rather than dragging the background into it. The radius is world-space, converted to a pixel span from each pixel's own depth, so a face keeps the same softness as the camera moves toward it.
The mask is drawn through a material pipeline built for the frame's own colour format, so it is 8-bit: the tint in RGB and the radius in alpha over [0, 1] world units. One group holds one radius, so that quantisation lands on the authored value rather than varying across a surface.
What it spreads
The frame's lit colour, which is diffuse and specular together. A strong specular highlight inside a marked silhouette is spread along with everything else.
Files
subsurfaceScattering.package/
SubsurfaceScattering.component/ -- the marked mesh, authored and persisted
subsurface.module/ -- mark / unmark / list / groups / strength / clear
sssDiffusion.renderFeature/ -- the mask, the two spreads and the composite
sssMask.shader/ -- fills a marked silhouette with its scattering