Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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

fieldeffect
colorWhat 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.
radiusHow 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.
strengthHow 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