pointShadowFactor function
double
pointShadowFactor(
- ShaderBindings b,
- Vector3 world,
- Vector3 normal,
- int lightIndex,
- FragmentContext c,
PointShadowFactor: how lit a point is by the light that owns the atlas.
Implementation
double pointShadowFactor(
ShaderBindings b,
Vector3 world,
Vector3 normal,
int lightIndex,
FragmentContext c,
) {
final slotEntry = b.vec4(
'PointShadow',
'slots',
Vector4(-1, 0, 0, 0),
at: lightIndex,
);
if (slotEntry.x < 0.0) return 1.0;
final slot = (slotEntry.x + 0.5).floor();
final params = b.vec4('PointShadow', 'params', Vector4.zero());
final strength = params.z;
if (strength <= 0.0) return 1.0;
final light = b.vec4('PointShadow', 'lights', Vector4.zero(), at: slot);
final lightPos = Vector3(light.x, light.y, light.z);
// Offset along the normal, scaled by how steeply the surface leans away.
// A soft kernel on a tilted surface straddles a depth gradient, so a flat
// offset that clears the surface head-on leaves acne at a grazing angle. The
// cap matters: uncapped, the lift detaches the shadow from its caster.
final toLight = lightPos - world;
final toLightLength = math.max(toLight.length, 1e-6);
final nDotL = math.max(normal.dot(toLight / toLightLength), 0.15);
final slope = math.min(
math.sqrt(math.max(1.0 - nDotL * nDotL, 0.0)) / (nDotL * nDotL),
8.0,
);
// The world width of one texel of the face this fragment lands on, which is
// the error a normal offset exists to clear: a texel covers a patch of
// surface, the patch is recorded at one distance, and a fragment elsewhere in
// it compares against a distance measured somewhere it is not. It grows with
// range, so the offset is in texels rather than in metres — see the note on
// `ShadowSettings.pointNormalOffset` for what a fixed one did to the floor
// under the golden teapot.
final params3 = b.vec4('PointShadow', 'params3', Vector4.zero());
final texel = 2.0 * toLightLength * math.max(slotEntry.z, 1e-4) * params3.y;
final origin = world + normal * (texel * params.w * (1.0 + slope));
final toFragment = origin - lightPos;
final distance = toFragment.length;
final range = math.max(light.w, 1e-4);
if (distance >= range) return 1.0;
// The dominant axis picks the face, in the order the renderer wrote them:
// +X, -X, +Y, -Y, +Z, -Z, left to right then top to bottom. A spot has one
// column and no choice to make — and asking anyway would send most of a
// downlight's cone to column 3, which for a spot is deliberately blank.
final int face;
if (slotEntry.y >= 0.5) {
face = 0;
} else {
final a = Vector3(
toFragment.x.abs(),
toFragment.y.abs(),
toFragment.z.abs(),
);
if (a.x >= a.y && a.x >= a.z) {
face = toFragment.x > 0.0 ? 0 : 1;
} else if (a.y >= a.z) {
face = toFragment.y > 0.0 ? 2 : 3;
} else {
face = toFragment.z > 0.0 ? 4 : 5;
}
}
final matrix = b.mat4('PointShadow', 'faces', at: slot * 6 + face);
final Vector4 clip = matrix * Vector4(origin.x, origin.y, origin.z, 1.0);
if (clip.w <= 0.0) return 1.0;
final ndcX = clip.x / clip.w;
final ndcY = clip.y / clip.w;
if (ndcX.abs() > 1.0 || ndcY.abs() > 1.0) return 1.0;
// v is flipped, as the directional map does it: the texture's origin is at
// the top, where row zero of the render target is.
final u = ndcX * 0.5 + 0.5;
final vv = 0.5 - ndcY * 0.5;
final tileX = face.toDouble();
final tileY = slot.toDouble();
final inset = params.x;
final receiver = distance - params.y;
// One rotation per fragment, shared by the search and the filter, so eight
// samples read as a soft edge rather than as eight copies of the silhouette.
final noise = fract(
52.9829189 * fract(c.coord.x * 0.06711056 + c.coord.y * 0.00583715),
);
final angle = noise * 6.28318530718;
final ca = math.cos(angle);
final sa = math.sin(angle);
final params2 = b.vec4('PointShadow', 'params2', Vector4.zero());
final lightRadius = params2.y;
final minRadius = params2.x;
final maxRadius = params2.z;
// The tangent of half the frustum's opening angle, which turns a world width
// into a fraction of a tile. Exactly one for a cube face — ninety degrees —
// and guarded because zero is what an unwritten channel holds, and a division
// by it would come back NaN rather than merely wrong.
final tanHalf = math.max(slotEntry.z, 1e-4);
// `params2.w`, the debug channel: paint the penumbra estimate into the
// surface buffer instead of the normal.
//
// **Transcribed rather than left to the two GPU backends**, which is the
// point of this rasteriser existing. The quantity that settles an argument
// about contact hardening never leaves this function, and it was readable on
// Impeller and on WebGL and silently absent here — so a developer debugging
// it headlessly, on the backend that needs no device, read the ordinary
// surface buffer as the penumbra estimate. That is the exact failure this
// channel was added to end.
final debug = params2.w > 0.5;
/// The filter's radius, as a fraction of the face tile's width.
double radius;
if (lightRadius <= 0.0) {
radius = minRadius;
if (debug) {
// The centre tap, for the reason the GLSL gives where it does the same:
// an unfilled channel clamps −1 to zero, which is the same green as a
// blocker touching the surface — the most alarming answer available, and
// a whole theory was once built on it.
c.debugSurface = _penumbraDebug(
radius,
atlasDistance(b, u, vv, 0.0, 0.0, tileX, tileY, range, inset),
maxRadius,
range,
);
}
} else {
// The blocker search runs at the widest penumbra allowed: a blocker
// outside that circle cannot widen the result, and searching narrower
// would miss the very blockers that make an edge soft.
var sum = 0.0;
var count = 0.0;
for (var i = 0; i < 8; i++) {
final p = diskTaps[i];
final ox = (p[0] * ca - p[1] * sa) * maxRadius;
final oy = (p[0] * sa + p[1] * ca) * maxRadius;
final stored = atlasDistance(
b,
u,
vv,
ox,
oy,
tileX,
tileY,
range,
inset,
);
if (stored >= range * 0.999) continue;
if (stored >= receiver) continue;
sum += stored;
count += 1.0;
}
// Nothing in front of this fragment anywhere in the search.
if (count < 0.5) {
// Blue, which is a different answer from "found something very close" —
// and telling those two apart is most of the question the channel exists
// to answer.
if (debug) c.debugSurface = Vector3(0.0, 0.0, 1.0);
return 1.0;
}
final blocker = math.max(sum / count, 1e-4);
final worldWidth =
lightRadius * math.max(receiver - blocker, 0.0) / blocker;
// `2 * r` is the span of a right-angled frustum at distance r; in general
// it is `2 * r * tan(θ/2)`. A narrower cone covers less world per tile, so
// the same width is a larger fraction of it.
radius = (worldWidth / (2.0 * receiver * tanHalf)).clamp(
minRadius,
maxRadius,
);
if (debug) {
c.debugSurface = _penumbraDebug(radius, blocker, maxRadius, range);
}
}
double tap(double ox, double oy) {
final stored = atlasDistance(b, u, vv, ox, oy, tileX, tileY, range, inset);
// Nothing was drawn in that direction by either map.
if (stored >= range * 0.999) return 1.0;
return receiver > stored ? 0.0 : 1.0;
}
var lit = tap(0.0, 0.0);
if (radius > 0.0) {
for (var i = 0; i < 8; i++) {
final p = diskTaps[i];
lit += tap(
(p[0] * ca - p[1] * sa) * radius,
(p[0] * sa + p[1] * ca) * radius,
);
}
lit /= 9.0;
}
return 1.0 + (lit - 1.0) * strength.clamp(0.0, 1.0);
}