@driftengine/core 4.1.3 → 4.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.json +3796 -3739
- package/README.md +1 -1
- package/dist/index.js +12 -2
- package/dist/render/backend/webgl2/renderer.d.ts +15 -0
- package/dist/render/backend/webgl2/renderer.js +28 -7
- package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +46 -0
- package/dist/render/backend/webgpu/gpuDrivenPass.js +105 -0
- package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
- package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
- package/dist/render/backend/webgpu/renderer.js +17 -4
- package/dist/render/depthConvention.d.ts +19 -0
- package/dist/render/depthConvention.js +21 -0
- package/dist/render/livePointShadowSet.js +8 -0
- package/dist/render/pointShadowBudget.js +59 -5
- package/dist/render/pointShadowImage.d.ts +40 -0
- package/dist/render/pointShadowImage.js +20 -0
- package/dist/render/pointShadowMap.d.ts +4 -0
- package/dist/render/pointShadowMap.js +10 -0
- package/dist/render/pointShadowSystem.js +9 -0
- package/dist/render/renderQuality.d.ts +27 -4
- package/dist/render/renderQuality.js +1 -1
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +104 -10
- package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
- package/dist/render/shaders/flat/pointShadow.js +84 -46
- package/dist/render/shaders/flat/preamble.js +70 -9
- package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
- package/dist/render/shaders/generated/flat.wgsl.js +32 -32
- package/dist/render/shaders/sky.d.ts +10 -6
- package/dist/render/shaders/sky.js +35 -2
- package/dist/render/textLayout.d.ts +58 -0
- package/dist/render/textLayout.js +83 -0
- package/dist/render/textRenderer.js +7 -3
- package/package.json +6 -6
- package/src/index.ts +12 -2
- package/src/render/backend/webgl2/renderer.ts +28 -7
- package/src/render/backend/webgpu/gpuDrivenPass.ts +105 -0
- package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
- package/src/render/backend/webgpu/renderer.ts +26 -4
- package/src/render/depthConvention.ts +22 -0
- package/src/render/livePointShadowSet.ts +7 -0
- package/src/render/pointShadowBudget.ts +58 -5
- package/src/render/pointShadowImage.ts +51 -0
- package/src/render/pointShadowMap.ts +13 -0
- package/src/render/pointShadowSystem.ts +8 -0
- package/src/render/renderQuality.ts +28 -5
- package/src/render/shaders/flat/main.ts +104 -10
- package/src/render/shaders/flat/pointShadow.ts +84 -46
- package/src/render/shaders/flat/preamble.ts +70 -9
- package/src/render/shaders/generated/flat.wgsl.ts +32 -32
- package/src/render/shaders/sky.ts +37 -2
- package/src/render/textLayout.ts +88 -0
- package/src/render/textRenderer.ts +22 -3
|
@@ -792,6 +792,27 @@ export const MAIN_GLSL = `void main() {
|
|
|
792
792
|
*/
|
|
793
793
|
float lightShade = 1.0;
|
|
794
794
|
|
|
795
|
+
/*
|
|
796
|
+
* **The lamps' light twice: once as it arrives, once as their shadows leave it.**
|
|
797
|
+
*
|
|
798
|
+
* Every point light's contribution used to go straight into \`lit\`. It is accumulated here
|
|
799
|
+
* instead, in two sums that differ only by the shadow term, so that after the loop there is
|
|
800
|
+
* a single number per channel saying how much of the lamps' light survived — and *that* is
|
|
801
|
+
* the thing the resolve below can average across a pair of pixels without touching albedo,
|
|
802
|
+
* a highlight, or anything else the frame has in it.
|
|
803
|
+
*
|
|
804
|
+
* **The two sums are not an approximation.** \`lampOpen\` multiplied by
|
|
805
|
+
* \`lampShadowed / lampOpen\` is \`lampShadowed\`, per channel, whatever mixture of lamps
|
|
806
|
+
* and shadows produced it. With the resolve switched off this is arithmetically the line it
|
|
807
|
+
* replaces; a lamp that is shadowed and a lamp beside it that is not keep their own shares.
|
|
808
|
+
*
|
|
809
|
+
* **What it costs** is two vector adds per light and six floats of live state. **What would
|
|
810
|
+
* make it wrong** is a term wanting to sit between the lamps and \`lit\` — a per-light
|
|
811
|
+
* tone map, say — because these two are summed before anything can get between them.
|
|
812
|
+
*/
|
|
813
|
+
vec3 lampOpen = vec3(0.0);
|
|
814
|
+
vec3 lampShadowed = vec3(0.0);
|
|
815
|
+
|
|
795
816
|
/*
|
|
796
817
|
* Which froxel this fragment is in.
|
|
797
818
|
*
|
|
@@ -968,7 +989,7 @@ export const MAIN_GLSL = `void main() {
|
|
|
968
989
|
#if POINT_SHADOWS
|
|
969
990
|
float grazing = 1.0 - ndl;
|
|
970
991
|
float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;
|
|
971
|
-
vec3
|
|
992
|
+
vec3 receiverPoint = vWorldPos + n * normalOffset;
|
|
972
993
|
|
|
973
994
|
/*
|
|
974
995
|
* uPointShadowWeight is how present the cubemap is, and it is the difference
|
|
@@ -1009,8 +1030,15 @@ export const MAIN_GLSL = `void main() {
|
|
|
1009
1030
|
pointShadow(
|
|
1010
1031
|
uPointShadows,
|
|
1011
1032
|
float(layer),
|
|
1012
|
-
|
|
1013
|
-
|
|
1033
|
+
/*
|
|
1034
|
+
* **From where the map was baked, not from where the light is.** The two are apart
|
|
1035
|
+
* by up to \`pointShadowRebakeDistance\` for anything that wanders, and both things
|
|
1036
|
+
* this asks the picture move with that gap: the distance it compares against the
|
|
1037
|
+
* stored one, and which texel the single-tap blocker search lands in. See
|
|
1038
|
+
* \`uPointShadowOrigin\`.
|
|
1039
|
+
*/
|
|
1040
|
+
receiverPoint - uPointShadowProjection[shadowRead].xyz,
|
|
1041
|
+
uPointShadowProjection[shadowRead].w,
|
|
1014
1042
|
uPointShadowNear[shadowRead],
|
|
1015
1043
|
uPointShadowSize[shadowRead]
|
|
1016
1044
|
),
|
|
@@ -1029,8 +1057,9 @@ export const MAIN_GLSL = `void main() {
|
|
|
1029
1057
|
liveOccl = pointShadow(
|
|
1030
1058
|
uPointShadows,
|
|
1031
1059
|
float(liveLayer),
|
|
1032
|
-
|
|
1033
|
-
|
|
1060
|
+
/* Its own bake origin, for the reason the static layer above gives. */
|
|
1061
|
+
receiverPoint - uLivePointShadowProjection[shadowRead].xyz,
|
|
1062
|
+
uLivePointShadowProjection[shadowRead].w,
|
|
1034
1063
|
uLivePointShadowNear[shadowRead],
|
|
1035
1064
|
uLivePointShadowSize[shadowRead]
|
|
1036
1065
|
);
|
|
@@ -1191,7 +1220,13 @@ export const MAIN_GLSL = `void main() {
|
|
|
1191
1220
|
*
|
|
1192
1221
|
* At metal 0 this is one multiply by 1.0 away from the expression it replaces.
|
|
1193
1222
|
*/
|
|
1194
|
-
|
|
1223
|
+
/*
|
|
1224
|
+
* Held back from \`lit\` until the loop is over, so the shadow can be resolved across a
|
|
1225
|
+
* pair of pixels first. See the block below the loop; nothing else about this term moved.
|
|
1226
|
+
*/
|
|
1227
|
+
vec3 lampDiffuse = albedo * lightColor * ndl * shape * lightWeight * (1.0 - metal);
|
|
1228
|
+
lampOpen += lampDiffuse;
|
|
1229
|
+
lampShadowed += lampDiffuse * shaded;
|
|
1195
1230
|
|
|
1196
1231
|
/*
|
|
1197
1232
|
* A lamp reflected in a polished surface, which point lights could not do at all.
|
|
@@ -1221,8 +1256,12 @@ export const MAIN_GLSL = `void main() {
|
|
|
1221
1256
|
vec3(1.0),
|
|
1222
1257
|
pow(1.0 - max(dot(toEyeLamp, lampHalfway), 0.0), 5.0) * metal
|
|
1223
1258
|
);
|
|
1224
|
-
|
|
1225
|
-
|
|
1259
|
+
/* Held back with the diffuse half, and shadowed by the same term it always was. */
|
|
1260
|
+
vec3 lampHighlight =
|
|
1261
|
+
lightColor * sphereLobe(max(dot(n, lampHalfway), 0.0), surfaceRoughness, lightSourceRadius, dist)
|
|
1262
|
+
* lampSpec * shape * lightWeight;
|
|
1263
|
+
lampOpen += lampHighlight;
|
|
1264
|
+
lampShadowed += lampHighlight * shaded;
|
|
1226
1265
|
}
|
|
1227
1266
|
/*
|
|
1228
1267
|
* The most any light in range shadows this point, for the emissive term below —
|
|
@@ -1370,7 +1409,10 @@ export const MAIN_GLSL = `void main() {
|
|
|
1370
1409
|
}
|
|
1371
1410
|
#endif
|
|
1372
1411
|
|
|
1373
|
-
|
|
1412
|
+
/* Held back with the point lamps', and shadowed by the same term it always was. */
|
|
1413
|
+
vec3 areaDiffuse = albedo * uAreaLightColor[a] * form * (1.0 - metal);
|
|
1414
|
+
lampOpen += areaDiffuse;
|
|
1415
|
+
lampShadowed += areaDiffuse * areaOccl;
|
|
1374
1416
|
|
|
1375
1417
|
if (vSpecular > 0.0 || metal > 0.0) {
|
|
1376
1418
|
/*
|
|
@@ -1395,11 +1437,63 @@ export const MAIN_GLSL = `void main() {
|
|
|
1395
1437
|
if (coverage > 0.0) {
|
|
1396
1438
|
vec2 areaDfg = envBrdfApprox(max(dot(n, toEyeArea), 0.0), surfaceRoughness);
|
|
1397
1439
|
vec3 areaSpec = specColor * areaDfg.x + vec3(areaDfg.y);
|
|
1398
|
-
|
|
1440
|
+
vec3 areaHighlight = areaSpec * uAreaLightColor[a] * coverage;
|
|
1441
|
+
lampOpen += areaHighlight;
|
|
1442
|
+
lampShadowed += areaHighlight * areaOccl;
|
|
1399
1443
|
}
|
|
1400
1444
|
}
|
|
1401
1445
|
}
|
|
1402
1446
|
|
|
1447
|
+
/*
|
|
1448
|
+
* **The other half of the shadow filter: the two pixels of a column pair, folded together.**
|
|
1449
|
+
*
|
|
1450
|
+
* \`pointShadow\` turns its twelve taps by half a golden angle on every other column, so a
|
|
1451
|
+
* pair of neighbouring pixels holds twenty-four places on the disk between them and neither
|
|
1452
|
+
* holds more than twelve. This is where the pair becomes one filter. \`dFdx\` of a value is
|
|
1453
|
+
* the difference across the pair, and half of it, signed by which of the two this pixel is,
|
|
1454
|
+
* is exactly the average of both — so each pixel ends up shaded by all twenty-four taps
|
|
1455
|
+
* while paying for twelve. It is the pairing \`ambientOcclusion.ts\` describes between its
|
|
1456
|
+
* rotation tile and its blur, at the smallest size that needs no second pass.
|
|
1457
|
+
*
|
|
1458
|
+
* **After both lamp loops rather than beside the taps, and that is why this block exists at
|
|
1459
|
+
* all.** A derivative is only defined where every pixel of the quad reaches it, and neither
|
|
1460
|
+
* loop above reaches anything uniformly: the point loop \`continue\`s on falloff and on
|
|
1461
|
+
* facing, and in the clustered arm its trip count is read from a froxel, so two neighbouring
|
|
1462
|
+
* pixels can be shading different lights with a froxel boundary drawn across the screen
|
|
1463
|
+
* where they stop agreeing. A \`dFdx\` in there is the 2026-08-07 rule's own case, and it
|
|
1464
|
+
* would also invite a compiler to flatten the loop. Measured, too: with the turn taken from
|
|
1465
|
+
* a per-fragment hash instead of the column, the same resolve made the penumbra *worse*
|
|
1466
|
+
* than no resolve at all — 3.14 against 3.00 — because the hardware here answers a coarse
|
|
1467
|
+
* derivative and the second row of a quad then borrows the first row's difference. The
|
|
1468
|
+
* column tile is what makes that borrowing correct, so the two halves are one mechanism.
|
|
1469
|
+
*
|
|
1470
|
+
* Both lamp kinds are in the sums, because a rectangle's shadow is filtered by the same
|
|
1471
|
+
* twelve taps and stippled the same way.
|
|
1472
|
+
*
|
|
1473
|
+
* **Clamped, and the clamp is a guard rather than tidying.** A fragment killed by the
|
|
1474
|
+
* cutout or the clip plane above leaves its lane without a value for its neighbour to
|
|
1475
|
+
* difference against, and GLSL ES says nothing about what comes back. What could reach the
|
|
1476
|
+
* frame is a shadow term outside 0..1 on the pixel beside a cutout hole, which the clamp
|
|
1477
|
+
* turns into a pixel that is merely fully lit or fully shadowed. **What would make this
|
|
1478
|
+
* wrong** is a consumer whose cutout edges are most of the frame — dense foliage drawn as
|
|
1479
|
+
* one alpha-tested sheet — where the honest answer is to resolve in a pass of its own,
|
|
1480
|
+
* against a shadow term written to a target.
|
|
1481
|
+
*
|
|
1482
|
+
* The floor under the divisor costs nothing and answers the case where no lamp reached this
|
|
1483
|
+
* pixel at all: both sums are zero there, so the ratio is zero and multiplying it back by
|
|
1484
|
+
* the floor adds nothing.
|
|
1485
|
+
*/
|
|
1486
|
+
vec3 lampOpenFloor = max(lampOpen, vec3(1e-6));
|
|
1487
|
+
vec3 lampShade = lampShadowed / lampOpenFloor;
|
|
1488
|
+
#if POINT_SHADOWS
|
|
1489
|
+
lampShade = clamp(
|
|
1490
|
+
lampShade - dFdx(lampShade) * (float(int(gl_FragCoord.x) & 1) - 0.5),
|
|
1491
|
+
0.0,
|
|
1492
|
+
1.0
|
|
1493
|
+
);
|
|
1494
|
+
#endif
|
|
1495
|
+
lit += lampOpenFloor * lampShade;
|
|
1496
|
+
|
|
1403
1497
|
/*
|
|
1404
1498
|
* Self-illuminated geometry, dimmed where a lamp's shadow crosses it.
|
|
1405
1499
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const POINTSHADOW_GLSL = "float pointShadow(\n highp sampler2DArray maps,\n float layer,\n vec3 toFrag,\n float far,\n float near,\n float sourceRadius\n) {\n /*\n * The receiver's own distance, and it is the *radius* now rather than a cube face's local Z.\n *\n * That difference removes two things. Every tap of an angular disk shares this one number,\n * where a cube needed each tap's own face-local Z recomputed \u2014 offsets changed it, and\n * comparing them all against the centre ray drew repeated wedges on large receivers. And the\n * stored value converts with a multiply instead of the perspective divide, which used to\n * magnify whatever error the sample arrived with, worst near the far plane.\n */\n float dist = length(toFrag);\n\n /*\n * A receiver nearer than the near plane is not shadowed at all.\n *\n * The near plane already says \"nothing this close to the source is rendered into the\n * map\", and a caller raises it so a fixture does not shadow its own housing. That was\n * only ever applied to *casters*, so a surface inside the same distance still went\n * through the comparison \u2014 against a map that, by the caller's own instruction, holds\n * nothing about anything near it.\n *\n * Reported on a ceiling with recessed strip lights hung a hand's width below it: shadows\n * that \"go on and off\" as the camera moves. The seams that made it unmissable are gone;\n * the gate stays, because comparing against a map that holds nothing is still wrong.\n *\n * The gate is the caller's own number rather than a new constant. A consumer that wants\n * the surface right beside its lamp to take a shadow can lower it, which is the same\n * knob and the same meaning it has always had for the other side of the transaction.\n */\n if (dist <= near) return 1.0;\n\n // Modest depth bias only. The heavy lifting is done by offsetting the\n // sample position along the surface normal before this is called \u2014 see the\n // call site \u2014 which is the cure for grazing receivers that depth bias alone\n // cannot fix without peter-panning.\n float bias = 0.04 + dist * 0.015;\n\n /*\n * Penumbra from the emitter's actual size.\n *\n * A shadow's softness is not a constant: it is set by how large the source is\n * and how far the caster stands from the surface it falls on. Find the\n * occluder with a centre tap, then widen the filter by\n * sourceRadius * (receiver - occluder) / occluder \u2014 the real geometry of a\n * penumbra. A bulb stays crisp; a flame goes soft with distance, which is\n * what makes a fire read as a fire rather than a large lamp.\n *\n * textureLod(..., 0.0), not the implicit-derivative texture() call this replaced.\n * pointShadowArray.ts allocates the array with texStorage3D at one storage level,\n * NEAREST both filters \u2014 there is no mip to select, so the two calls fetch the\n * identical texel. Not a quality trade; the output is bit-for-bit the same.\n *\n * It matters because implicit derivatives are only well-defined under *uniform*\n * control flow, and this sits inside the light loop below. See AGENTS.md, 2026-08-07.\n */\n float centreStored = textureLod(maps, vec3(octEncode(toFrag), layer), 0.0).r;\n float occluderDistance = centreStored >= 0.9999 ? dist : centreStored * far;\n float spread = max(dist - occluderDistance, 0.0) / max(occluderDistance, 0.05);\n /*\n * Softness comes from *fading the shadow*, not from widening the filter.\n *\n * Four attempts went into widening it, and the fourth is what identified why\n * none of them worked. The decisive observation was that the light pool looked\n * right until a column entered it. With no occluder the penumbra estimate is zero,\n * the filter stays at its floor, and the light pool is clean. The instant a\n * column is found the filter widens \u2014 and a wide angular filter on a *cube*\n * map is what crossed face seams. The artefact was never the columns' shadows;\n * it was the search for their penumbra.\n *\n * The map has no seams now, so the cap could open. It has not, in this change:\n * the storage moved and nothing else did, and the widening is a picture decision\n * taken against a tree that already renders correctly.\n */\n float radius = clamp(sourceRadius * spread, 0.01, MAX_FILTER_RADIUS) + 0.012;\n\n /*\n * How much of a shadow survives, as well as how soft it is.\n *\n * This is the half that was missing, and it is why widening the filter never\n * fixed anything. A penumbra does not merely blur with distance from its\n * caster \u2014 it *takes over*. Far enough from the occluder, relative to how big\n * the source is, every point on the receiver can see part of the flame and\n * there is no umbra left at all: the shadow is gone, not blurry.\n *\n * Without it, two columns lit by a brazier threw full-strength edges fifteen\n * metres across the terrace, and the gap between them read as a spotlight cone\n * with straight sides rather than as a fire. Three attempts in, the note that\n * unlocked it was that the pool should still fade naturally at its rim \u2014 a\n * description of the falloff, not of the blur.\n */\n float penumbra = clamp(sourceRadius * spread * PENUMBRA_FADE, 0.0, 1.0);\n float strength = 1.0 - penumbra * penumbra;\n\n /*\n * Taps are placed on a disk *perpendicular to the light ray*, rotated per\n * fragment.\n *\n * Offsetting the ray by fixed 3D vectors was the earlier approach and it has\n * two faults that only show on a wide filter. The offsets are not\n * perpendicular to the ray, so part of each one moves the sample along its own\n * depth instead of across the shadow; and twelve *fixed* taps at a large\n * radius align, so the penumbra breaks into hard straight bands following the\n * tap pattern \u2014 reported, on a brazier behind a rank of columns, as strange\n * straight lines across the light pool.\n *\n * A per-fragment rotation turns that banding into a fine dither, which the eye\n * reads as smooth \u2014 the standard cure, and it costs one hash.\n *\n * **Unchanged by the move to an octahedral map, and that is the point.** The offsets are\n * angular perturbations of a direction, so every tap is still a direction and octEncode is\n * defined on all of them. Nothing offsets in UV, so the map's border needs no wrap\n * arithmetic and a tap that would have crossed a cube join lands on the adjacent texel.\n */\n vec3 fwd = normalize(toFrag);\n vec3 up = abs(fwd.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);\n vec3 tu = normalize(cross(up, fwd));\n vec3 tv = cross(fwd, tu);\n // Interleaved gradient noise: cheap, and stable enough per pixel that the\n // dither does not crawl while the camera moves.\n float angle = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))))\n * 6.28318530718;\n float ca = cos(angle);\n float sa = sin(angle);\n\n float lit = 0.0;\n for (int i = 0; i < 12; i++) {\n if (i >= uShadowFilterTaps) break;\n vec2 disk = PCF_OFFSETS[i].xy;\n vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);\n // Perpendicular to the ray, and *not* scaled by its length: the offsets are\n // an angular perturbation of a direction, which is what the encoding takes.\n vec3 sampleDir = toFrag + (tu * turned.x + tv * turned.y) * radius;\n float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;\n if (stored >= 0.9999) {\n // Beyond the far plane nothing was rendered: treat as lit.\n lit += 1.0;\n continue;\n }\n float storedDistance = stored * far;\n if (dist - bias > storedDistance) {\n // A point shadow cannot outlive the finite light that casts it, so its\n // occlusion fades smoothly before the light's far plane.\n lit += 1.0 - shadowReach(max(dist - storedDistance, 0.0), far);\n } else {\n lit += 1.0;\n }\n }\n float shaded = lit / float(max(uShadowFilterTaps, 1));\n // Fade the whole shadow toward lit as the penumbra swallows it.\n return mix(1.0, shaded, strength);\n}\n\n/**\n * The same filter for a source that has a *shape* rather than a radius.\n *\n * **One map from the rectangle's centre, and the rectangle's own extent as the filter's shape.**\n * That is the whole difference from `pointShadow` above, and it is what makes the result read as\n * one wide emitter rather than as a lamp: a bulb's penumbra is round, so a row of nine of them\n * gives nine round shadows that pile up darker where they overlap, and a strip's penumbra is a\n * strip \u2014 soft along the cable, tight across it, one shadow with one soft edge.\n *\n * **The taps land on the emitter's silhouette as this fragment sees it**, which is the part a\n * scalar radius cannot express. The rectangle's two axes are projected onto the plane across the\n * light ray and each keeps its own width, so the sample pattern is an ellipse rather than a disk,\n * and a rectangle seen edge-on along one axis has no penumbra in that direction \u2014 which is correct\n * and is what a strip light actually does. Four dot products and two square roots more than the\n * round filter.\n *\n * **The fade is driven by the *narrower* projected half extent, and the reason is a strip.** A\n * penumbra does not only blur a shadow, it takes over: past the point where every part of the\n * receiver can see part of the emitter there is no umbra left. Driven by the wider extent, a\n * three-metre cable would delete its own shadow at a metre of caster clearance \u2014 but across the\n * cable the source is nearly a line, so an umbra band survives there and only the length is\n * smeared. `min` keeps that band; `max` would erase it.\n *\n * **What it costs** is a second copy of the tap loop, about 30 lines, compiled into the eight\n * permutations that carry point shadows. Folding the two into one loop was the alternative and was\n * not taken: the shared form has to multiply the disk offset by a width before scaling the axis\n * rather than after, which is a different rounding order on every tap of the *point* path \u2014 so the\n * saving is paid for by perturbing eight scenes that currently gate at zero pixels, in a change\n * about area lights. `docs/IMPROVEMENTS.md` carries the row.\n *\n * **What would make it wrong** is a rectangle much wider than its distance to the caster, where\n * different points on the emitter genuinely see different silhouettes and no single map from the\n * centre stands in for all of them \u2014 a ceiling-wide panel a hand's width above a table. The honest\n * answer there is several maps across the emitter, which is several bakes.\n */\nfloat areaShadow(\n highp sampler2DArray maps,\n float layer,\n vec3 toFrag,\n float far,\n float near,\n vec3 right,\n vec3 up,\n vec2 halfSize\n) {\n float dist = length(toFrag);\n /* The near plane says nothing this close to the emitter was rendered; see `pointShadow`. */\n if (dist <= near) return 1.0;\n\n float bias = 0.04 + dist * 0.015;\n\n /*\n * The blocker search, one tap along the centre ray, exactly as the round filter does it. Similar\n * triangles turn the two distances into how much wider than the emitter the penumbra is.\n *\n * `textureLod(..., 0.0)` rather than `texture()`: the array carries one storage level and\n * NEAREST both filters, so the two fetch the identical texel \u2014 and this sits inside the area\n * light loop, which is not provably uniform control flow. See AGENTS.md, 2026-08-07.\n */\n float centreStored = textureLod(maps, vec3(octEncode(toFrag), layer), 0.0).r;\n float occluderDistance = centreStored >= 0.9999 ? dist : centreStored * far;\n float spread = max(dist - occluderDistance, 0.0) / max(occluderDistance, 0.05);\n\n vec3 fwd = toFrag / dist;\n /*\n * Each axis with its along-the-ray component removed: the rectangle's outline flattened onto the\n * plane the taps move in. The lengths that come back are the foreshortening \u2014 1 for a rectangle\n * seen face-on, 0 for one seen exactly edge-on along that axis \u2014 so the two widths below carry\n * both the emitter's size and how much of it this fragment can see.\n */\n vec3 acrossX = right - fwd * dot(right, fwd);\n vec3 acrossY = up - fwd * dot(up, fwd);\n float seenX = length(acrossX);\n float seenY = length(acrossY);\n /*\n * A collapsed axis offsets nothing rather than offsetting along an arbitrary direction. Both\n * cannot collapse at once: `right` and `up` are perpendicular, so a ray parallel to one of\n * them is perpendicular to the other.\n */\n vec3 alongX = seenX > 1e-5 ? acrossX / seenX : vec3(0.0);\n vec3 alongY = seenY > 1e-5 ? acrossY / seenY : vec3(0.0);\n\n float halfX = halfSize.x * seenX;\n float halfY = halfSize.y * seenY;\n /* The same ceiling and the same floor the round filter uses, per axis. See MAX_FILTER_RADIUS. */\n float widthX = min(halfX * spread, MAX_FILTER_RADIUS) + 0.012;\n float widthY = min(halfY * spread, MAX_FILTER_RADIUS) + 0.012;\n\n /* The narrower half extent, for the reason in the header: it is what keeps a strip's umbra. */\n float penumbra = clamp(min(halfX, halfY) * spread * PENUMBRA_FADE, 0.0, 1.0);\n float strength = 1.0 - penumbra * penumbra;\n\n /*\n * Rotated per fragment inside the unit disk *before* the ellipse maps it, so the dither that\n * breaks up twelve aligned taps survives the anisotropy. Interleaved gradient noise, one hash;\n * without it a wide filter bands along the tap pattern \u2014 reported on a brazier behind a rank of\n * columns as straight lines across the light pool.\n */\n float angle = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))))\n * 6.28318530718;\n float ca = cos(angle);\n float sa = sin(angle);\n\n float lit = 0.0;\n for (int i = 0; i < 12; i++) {\n if (i >= uShadowFilterTaps) break;\n vec2 disk = PCF_OFFSETS[i].xy;\n vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);\n vec3 sampleDir = toFrag + alongX * (turned.x * widthX) + alongY * (turned.y * widthY);\n float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;\n if (stored >= 0.9999) {\n /* Beyond the far plane nothing was rendered: treat as lit. */\n lit += 1.0;\n continue;\n }\n float storedDistance = stored * far;\n if (dist - bias > storedDistance) {\n /* An area light's occlusion cannot outlive the map that carries it either. */\n lit += 1.0 - shadowReach(max(dist - storedDistance, 0.0), far);\n } else {\n lit += 1.0;\n }\n }\n float shaded = lit / float(max(uShadowFilterTaps, 1));\n return mix(1.0, shaded, strength);\n}\n#endif\n\nout vec4 outColor;\n\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n return linearToSrgb(c);\n}\n\n\n/**\n * A fixed Poisson disk avoids the grid-shaped edge left by square 3x3 PCF.\n * Directional and point shadows consume the same maximum filter-tap budget;\n * their texture sizes differ because one is a 2D projection of a frustum and one\n * is an octahedral projection of the whole sphere, not because one is a lower\n * quality tier.\n */\n#if DIRECTIONAL_SHADOWS\nconst vec2 DIRECTIONAL_PCF_OFFSETS[12] = vec2[12](\n vec2(-0.326, -0.406), vec2( 0.519, 0.767), vec2( 0.962, -0.195), vec2(-0.696, 0.457),\n vec2(-0.840, -0.074), vec2( 0.185, -0.893), vec2( 0.896, 0.412), vec2(-0.203, 0.621),\n vec2( 0.473, -0.480), vec2( 0.507, 0.064), vec2(-0.322, -0.933), vec2(-0.792, -0.598)\n);\n\n/** Anything outside the light frustum or beyond its far plane counts as lit. */";
|
|
1
|
+
export declare const POINTSHADOW_GLSL = "/**\n * How much of one tap's occlusion survives its own penumbra.\n *\n * **How much of a shadow survives, as well as how soft it is.** A penumbra does not merely blur\n * with distance from its caster, it *takes over*: far enough from the occluder, relative to how\n * big the source is, every point on the receiver can see part of the flame and there is no umbra\n * left at all. The shadow is gone, not blurry. Without it, two columns lit by a brazier threw\n * full-strength edges fifteen metres across the terrace, and the gap between them read as a\n * spotlight cone with straight sides rather than as a fire.\n *\n * **Per tap, and it was once for the whole filter, taken from the centre tap alone.** That is a\n * blocker search of one sample standing in for twelve, and it is right wherever the taps agree \u2014\n * inside an umbra they all find the same occluder, so this returns exactly what the shared value\n * did, and the umbra is unchanged to the bit. It is wrong exactly where they disagree, which is\n * the silhouette: there the centre ray *misses*, so the search reports no occluder, the spread is\n * zero and the fade is switched off \u2014 while the taps that do land on the caster then draw at full\n * strength. What that paints is a hard one-texel line around a shadow whose interior has\n * correctly faded to nothing.\n *\n * Reported as shadows \"faded out but NOT shadow outlines, generating long lines around the\n * world\", and on a brazier as a bare square outline lying on the ground with no shadow inside it.\n *\n * **What it costs** is two multiplies and a divide per occluded tap, against a tap that has\n * already paid for a texture fetch. **What would make it wrong** is a filter wide enough that one\n * tap's occluder is not the occluder a neighbouring tap is estimating for, which is the same\n * limit the shared value had and is what `MAX_FILTER_RADIUS` bounds.\n */\nfloat tapStrength(float receiverDistance, float occluderDistance, float sourceRadius) {\n float spread = max(receiverDistance - occluderDistance, 0.0) / max(occluderDistance, 0.05);\n float penumbra = clamp(sourceRadius * spread * PENUMBRA_FADE, 0.0, 1.0);\n return 1.0 - penumbra * penumbra;\n}\n\nfloat pointShadow(\n highp sampler2DArray maps,\n float layer,\n vec3 toFrag,\n float far,\n float near,\n float sourceRadius\n) {\n /*\n * The receiver's own distance, and it is the *radius* now rather than a cube face's local Z.\n *\n * That difference removes two things. Every tap of an angular disk shares this one number,\n * where a cube needed each tap's own face-local Z recomputed \u2014 offsets changed it, and\n * comparing them all against the centre ray drew repeated wedges on large receivers. And the\n * stored value converts with a multiply instead of the perspective divide, which used to\n * magnify whatever error the sample arrived with, worst near the far plane.\n */\n float dist = length(toFrag);\n\n /*\n * A receiver nearer than the near plane is not shadowed at all.\n *\n * The near plane already says \"nothing this close to the source is rendered into the\n * map\", and a caller raises it so a fixture does not shadow its own housing. That was\n * only ever applied to *casters*, so a surface inside the same distance still went\n * through the comparison \u2014 against a map that, by the caller's own instruction, holds\n * nothing about anything near it.\n *\n * Reported on a ceiling with recessed strip lights hung a hand's width below it: shadows\n * that \"go on and off\" as the camera moves. The seams that made it unmissable are gone;\n * the gate stays, because comparing against a map that holds nothing is still wrong.\n *\n * The gate is the caller's own number rather than a new constant. A consumer that wants\n * the surface right beside its lamp to take a shadow can lower it, which is the same\n * knob and the same meaning it has always had for the other side of the transaction.\n */\n if (dist <= near) return 1.0;\n\n // Modest depth bias only. The heavy lifting is done by offsetting the\n // sample position along the surface normal before this is called \u2014 see the\n // call site \u2014 which is the cure for grazing receivers that depth bias alone\n // cannot fix without peter-panning.\n float bias = 0.04 + dist * 0.015;\n\n /*\n * Penumbra from the emitter's actual size.\n *\n * A shadow's softness is not a constant: it is set by how large the source is\n * and how far the caster stands from the surface it falls on. Find the\n * occluder with a centre tap, then widen the filter by\n * sourceRadius * (receiver - occluder) / occluder \u2014 the real geometry of a\n * penumbra. A bulb stays crisp; a flame goes soft with distance, which is\n * what makes a fire read as a fire rather than a large lamp.\n *\n * textureLod(..., 0.0), not the implicit-derivative texture() call this replaced.\n * pointShadowArray.ts allocates the array with texStorage3D at one storage level,\n * NEAREST both filters \u2014 there is no mip to select, so the two calls fetch the\n * identical texel. Not a quality trade; the output is bit-for-bit the same.\n *\n * It matters because implicit derivatives are only well-defined under *uniform*\n * control flow, and this sits inside the light loop below. See AGENTS.md, 2026-08-07.\n */\n float centreStored = textureLod(maps, vec3(octEncode(toFrag), layer), 0.0).r;\n float occluderDistance = centreStored >= 0.9999 ? dist : centreStored * far;\n float spread = max(dist - occluderDistance, 0.0) / max(occluderDistance, 0.05);\n /*\n * Softness comes from *fading the shadow*, not from widening the filter.\n *\n * Four attempts went into widening it, and the fourth is what identified why\n * none of them worked. The decisive observation was that the light pool looked\n * right until a column entered it. With no occluder the penumbra estimate is zero,\n * the filter stays at its floor, and the light pool is clean. The instant a\n * column is found the filter widens \u2014 and a wide angular filter on a *cube*\n * map is what crossed face seams. The artefact was never the columns' shadows;\n * it was the search for their penumbra.\n *\n * The map has no seams now, so the cap could open. It has not, in this change:\n * the storage moved and nothing else did, and the widening is a picture decision\n * taken against a tree that already renders correctly.\n */\n float radius = clamp(sourceRadius * spread, 0.01, MAX_FILTER_RADIUS) + 0.012;\n\n /*\n * Taps are placed on a disk *perpendicular to the light ray*, turned by half a golden\n * angle on every other column of pixels.\n *\n * Offsetting the ray by fixed 3D vectors was the earlier approach and it has\n * two faults that only show on a wide filter. The offsets are not\n * perpendicular to the ray, so part of each one moves the sample along its own\n * depth instead of across the shadow; and twelve *fixed* taps at a large\n * radius align, so the penumbra breaks into hard straight bands following the\n * tap pattern \u2014 reported, on a brazier behind a rank of columns, as strange\n * straight lines across the light pool.\n *\n * **The cure for that was a per-fragment hash, and it is what made the penumbra\n * stipple.** Twelve taps are a coarse estimate of how much of the emitter this point can\n * see; turning them by an unrelated angle at every pixel makes each pixel's error\n * independent of its neighbour's, which is the definition of noise. Something has to\n * average that back out, and this engine's lit pass is the last thing to touch the frame \u2014\n * there is no blur and no temporal resolve behind it, so the noise reached the screen. Two\n * games reported the same coarse cross-hatch across a light pool.\n *\n * **So the turn comes from a two-pixel tile instead, and the pair is averaged afterwards.**\n * A pixel's own x parity picks one of two tap sets half a golden angle apart, and\n * `main` folds the pair together once the light loop is over \u2014 see the resolve there. The\n * pair's twenty-four taps are the filter; each pixel pays for twelve of them.\n *\n * **A *column* pair rather than a 2x2 square, and that was measured rather than assumed.**\n * Four turns on a square tile, resolved with both derivatives, came back at 3.88 against this\n * pair's 2.46 on the same build \u2014 because the second derivative hands the lower row of a quad\n * the upper row's difference on hardware that answers a coarse derivative, and with four turns\n * that is the wrong difference. Two turns keyed on the column alone make it the right one,\n * which is why the tile's shape and the resolve are one decision rather than two.\n *\n * **Neither half works alone**, which is the same pairing `ambientOcclusion.ts` describes\n * between its rotation tile and its blur. Measured on `demo/dev/pointshadow.html` at\n * `?exposure=6`, as mean absolute luminance difference to the right and down neighbour over\n * the mover's penumbra, on WebGL2: **4.60** for the per-fragment hash this replaces, **2.88**\n * for this tile with no resolve \u2014 and a one-pixel vertical comb in the picture, which is the\n * tile itself on screen \u2014 against **2.37** for the pair resolved. A frame with the shadow\n * switched off measures 0.81, and WebGPU answers 4.56 and 2.33 for the same two builds.\n *\n * **Unchanged by the move to an octahedral map, and that is the point.** The offsets are\n * angular perturbations of a direction, so every tap is still a direction and octEncode is\n * defined on all of them. Nothing offsets in UV, so the map's border needs no wrap\n * arithmetic and a tap that would have crossed a cube join lands on the adjacent texel.\n */\n vec3 fwd = normalize(toFrag);\n vec3 up = abs(fwd.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);\n vec3 tu = normalize(cross(up, fwd));\n vec3 tv = cross(fwd, tu);\n /*\n * Integer arithmetic, because this is a lattice: a shader compiler is free to implement\n * `mod(x, 2.0)` as a multiply by a reciprocal, and a GPU here answers 3 for `3.0 % 3.0`.\n * `gl_FragCoord.x` is the pixel's centre, so truncating it is the pixel's own column.\n */\n float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;\n float ca = cos(angle);\n float sa = sin(angle);\n\n float lit = 0.0;\n for (int i = 0; i < 12; i++) {\n if (i >= uShadowFilterTaps) break;\n vec2 disk = PCF_OFFSETS[i];\n vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);\n // Perpendicular to the ray, and *not* scaled by its length: the offsets are\n // an angular perturbation of a direction, which is what the encoding takes.\n vec3 sampleDir = toFrag + (tu * turned.x + tv * turned.y) * radius;\n float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;\n if (stored >= 0.9999) {\n // Beyond the far plane nothing was rendered: treat as lit.\n lit += 1.0;\n continue;\n }\n float storedDistance = stored * far;\n if (dist - bias > storedDistance) {\n // A point shadow cannot outlive the finite light that casts it, so its\n // occlusion fades smoothly before the light's far plane.\n float reach = shadowReach(max(dist - storedDistance, 0.0), far);\n lit += 1.0 - reach * tapStrength(dist, storedDistance, sourceRadius);\n } else {\n lit += 1.0;\n }\n }\n return lit / float(max(uShadowFilterTaps, 1));\n}\n\n/**\n * The same filter for a source that has a *shape* rather than a radius.\n *\n * **One map from the rectangle's centre, and the rectangle's own extent as the filter's shape.**\n * That is the whole difference from `pointShadow` above, and it is what makes the result read as\n * one wide emitter rather than as a lamp: a bulb's penumbra is round, so a row of nine of them\n * gives nine round shadows that pile up darker where they overlap, and a strip's penumbra is a\n * strip \u2014 soft along the cable, tight across it, one shadow with one soft edge.\n *\n * **The taps land on the emitter's silhouette as this fragment sees it**, which is the part a\n * scalar radius cannot express. The rectangle's two axes are projected onto the plane across the\n * light ray and each keeps its own width, so the sample pattern is an ellipse rather than a disk,\n * and a rectangle seen edge-on along one axis has no penumbra in that direction \u2014 which is correct\n * and is what a strip light actually does. Four dot products and two square roots more than the\n * round filter.\n *\n * **The fade is driven by the *narrower* projected half extent, and the reason is a strip.** A\n * penumbra does not only blur a shadow, it takes over: past the point where every part of the\n * receiver can see part of the emitter there is no umbra left. Driven by the wider extent, a\n * three-metre cable would delete its own shadow at a metre of caster clearance \u2014 but across the\n * cable the source is nearly a line, so an umbra band survives there and only the length is\n * smeared. `min` keeps that band; `max` would erase it.\n *\n * **What it costs** is a second copy of the tap loop, about 30 lines, compiled into the eight\n * permutations that carry point shadows. Folding the two into one loop was the alternative and was\n * not taken: the shared form has to multiply the disk offset by a width before scaling the axis\n * rather than after, which is a different rounding order on every tap of the *point* path \u2014 so the\n * saving is paid for by perturbing eight scenes that currently gate at zero pixels, in a change\n * about area lights. `docs/IMPROVEMENTS.md` carries the row.\n *\n * **What would make it wrong** is a rectangle much wider than its distance to the caster, where\n * different points on the emitter genuinely see different silhouettes and no single map from the\n * centre stands in for all of them \u2014 a ceiling-wide panel a hand's width above a table. The honest\n * answer there is several maps across the emitter, which is several bakes.\n */\nfloat areaShadow(\n highp sampler2DArray maps,\n float layer,\n vec3 toFrag,\n float far,\n float near,\n vec3 right,\n vec3 up,\n vec2 halfSize\n) {\n float dist = length(toFrag);\n /* The near plane says nothing this close to the emitter was rendered; see `pointShadow`. */\n if (dist <= near) return 1.0;\n\n float bias = 0.04 + dist * 0.015;\n\n /*\n * The blocker search, one tap along the centre ray, exactly as the round filter does it. Similar\n * triangles turn the two distances into how much wider than the emitter the penumbra is.\n *\n * `textureLod(..., 0.0)` rather than `texture()`: the array carries one storage level and\n * NEAREST both filters, so the two fetch the identical texel \u2014 and this sits inside the area\n * light loop, which is not provably uniform control flow. See AGENTS.md, 2026-08-07.\n */\n float centreStored = textureLod(maps, vec3(octEncode(toFrag), layer), 0.0).r;\n float occluderDistance = centreStored >= 0.9999 ? dist : centreStored * far;\n float spread = max(dist - occluderDistance, 0.0) / max(occluderDistance, 0.05);\n\n vec3 fwd = toFrag / dist;\n /*\n * Each axis with its along-the-ray component removed: the rectangle's outline flattened onto the\n * plane the taps move in. The lengths that come back are the foreshortening \u2014 1 for a rectangle\n * seen face-on, 0 for one seen exactly edge-on along that axis \u2014 so the two widths below carry\n * both the emitter's size and how much of it this fragment can see.\n */\n vec3 acrossX = right - fwd * dot(right, fwd);\n vec3 acrossY = up - fwd * dot(up, fwd);\n float seenX = length(acrossX);\n float seenY = length(acrossY);\n /*\n * A collapsed axis offsets nothing rather than offsetting along an arbitrary direction. Both\n * cannot collapse at once: `right` and `up` are perpendicular, so a ray parallel to one of\n * them is perpendicular to the other.\n */\n vec3 alongX = seenX > 1e-5 ? acrossX / seenX : vec3(0.0);\n vec3 alongY = seenY > 1e-5 ? acrossY / seenY : vec3(0.0);\n\n float halfX = halfSize.x * seenX;\n float halfY = halfSize.y * seenY;\n /* The same ceiling and the same floor the round filter uses, per axis. See MAX_FILTER_RADIUS. */\n float widthX = min(halfX * spread, MAX_FILTER_RADIUS) + 0.012;\n float widthY = min(halfY * spread, MAX_FILTER_RADIUS) + 0.012;\n\n /*\n * Turned inside the unit disk *before* the ellipse maps it, so the half-turn that separates\n * the two halves of a pixel pair survives the anisotropy. The same two-pixel tile the round\n * filter uses and for the same reasons \u2014 see `pointShadow`, which carries the measurement;\n * a rectangle's shadow stippled exactly as a lamp's did, because it is the same twelve taps.\n */\n float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;\n float ca = cos(angle);\n float sa = sin(angle);\n\n float lit = 0.0;\n for (int i = 0; i < 12; i++) {\n if (i >= uShadowFilterTaps) break;\n vec2 disk = PCF_OFFSETS[i];\n vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);\n vec3 sampleDir = toFrag + alongX * (turned.x * widthX) + alongY * (turned.y * widthY);\n float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;\n if (stored >= 0.9999) {\n /* Beyond the far plane nothing was rendered: treat as lit. */\n lit += 1.0;\n continue;\n }\n float storedDistance = stored * far;\n if (dist - bias > storedDistance) {\n /* An area light's occlusion cannot outlive the map that carries it either. */\n float reach = shadowReach(max(dist - storedDistance, 0.0), far);\n /* The narrower half extent, for the reason in the header: it is what keeps a strip's\n umbra. Per tap, for the reason `tapStrength` gives. */\n lit += 1.0 - reach * tapStrength(dist, storedDistance, min(halfX, halfY));\n } else {\n lit += 1.0;\n }\n }\n return lit / float(max(uShadowFilterTaps, 1));\n}\n#endif\n\nout vec4 outColor;\n\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n return linearToSrgb(c);\n}\n\n\n/**\n * A fixed Poisson disk avoids the grid-shaped edge left by square 3x3 PCF.\n * Directional and point shadows consume the same maximum filter-tap budget;\n * their texture sizes differ because one is a 2D projection of a frustum and one\n * is an octahedral projection of the whole sphere, not because one is a lower\n * quality tier.\n */\n#if DIRECTIONAL_SHADOWS\nconst vec2 DIRECTIONAL_PCF_OFFSETS[12] = vec2[12](\n vec2(-0.326, -0.406), vec2( 0.519, 0.767), vec2( 0.962, -0.195), vec2(-0.696, 0.457),\n vec2(-0.840, -0.074), vec2( 0.185, -0.893), vec2( 0.896, 0.412), vec2(-0.203, 0.621),\n vec2( 0.473, -0.480), vec2( 0.507, 0.064), vec2(-0.322, -0.933), vec2(-0.792, -0.598)\n);\n\n/** Anything outside the light frustum or beyond its far plane counts as lit. */";
|
|
@@ -14,7 +14,40 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { MAX_SHADOW_FILTER_TAPS } from '../../renderQuality.js';
|
|
16
16
|
import { OUTPUT_TRANSFORM_GLSL } from '../outputTransform.js';
|
|
17
|
-
export const POINTSHADOW_GLSL =
|
|
17
|
+
export const POINTSHADOW_GLSL = `/**
|
|
18
|
+
* How much of one tap's occlusion survives its own penumbra.
|
|
19
|
+
*
|
|
20
|
+
* **How much of a shadow survives, as well as how soft it is.** A penumbra does not merely blur
|
|
21
|
+
* with distance from its caster, it *takes over*: far enough from the occluder, relative to how
|
|
22
|
+
* big the source is, every point on the receiver can see part of the flame and there is no umbra
|
|
23
|
+
* left at all. The shadow is gone, not blurry. Without it, two columns lit by a brazier threw
|
|
24
|
+
* full-strength edges fifteen metres across the terrace, and the gap between them read as a
|
|
25
|
+
* spotlight cone with straight sides rather than as a fire.
|
|
26
|
+
*
|
|
27
|
+
* **Per tap, and it was once for the whole filter, taken from the centre tap alone.** That is a
|
|
28
|
+
* blocker search of one sample standing in for twelve, and it is right wherever the taps agree —
|
|
29
|
+
* inside an umbra they all find the same occluder, so this returns exactly what the shared value
|
|
30
|
+
* did, and the umbra is unchanged to the bit. It is wrong exactly where they disagree, which is
|
|
31
|
+
* the silhouette: there the centre ray *misses*, so the search reports no occluder, the spread is
|
|
32
|
+
* zero and the fade is switched off — while the taps that do land on the caster then draw at full
|
|
33
|
+
* strength. What that paints is a hard one-texel line around a shadow whose interior has
|
|
34
|
+
* correctly faded to nothing.
|
|
35
|
+
*
|
|
36
|
+
* Reported as shadows "faded out but NOT shadow outlines, generating long lines around the
|
|
37
|
+
* world", and on a brazier as a bare square outline lying on the ground with no shadow inside it.
|
|
38
|
+
*
|
|
39
|
+
* **What it costs** is two multiplies and a divide per occluded tap, against a tap that has
|
|
40
|
+
* already paid for a texture fetch. **What would make it wrong** is a filter wide enough that one
|
|
41
|
+
* tap's occluder is not the occluder a neighbouring tap is estimating for, which is the same
|
|
42
|
+
* limit the shared value had and is what \`MAX_FILTER_RADIUS\` bounds.
|
|
43
|
+
*/
|
|
44
|
+
float tapStrength(float receiverDistance, float occluderDistance, float sourceRadius) {
|
|
45
|
+
float spread = max(receiverDistance - occluderDistance, 0.0) / max(occluderDistance, 0.05);
|
|
46
|
+
float penumbra = clamp(sourceRadius * spread * PENUMBRA_FADE, 0.0, 1.0);
|
|
47
|
+
return 1.0 - penumbra * penumbra;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
float pointShadow(
|
|
18
51
|
highp sampler2DArray maps,
|
|
19
52
|
float layer,
|
|
20
53
|
vec3 toFrag,
|
|
@@ -97,26 +130,8 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
97
130
|
float radius = clamp(sourceRadius * spread, 0.01, MAX_FILTER_RADIUS) + 0.012;
|
|
98
131
|
|
|
99
132
|
/*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* This is the half that was missing, and it is why widening the filter never
|
|
103
|
-
* fixed anything. A penumbra does not merely blur with distance from its
|
|
104
|
-
* caster — it *takes over*. Far enough from the occluder, relative to how big
|
|
105
|
-
* the source is, every point on the receiver can see part of the flame and
|
|
106
|
-
* there is no umbra left at all: the shadow is gone, not blurry.
|
|
107
|
-
*
|
|
108
|
-
* Without it, two columns lit by a brazier threw full-strength edges fifteen
|
|
109
|
-
* metres across the terrace, and the gap between them read as a spotlight cone
|
|
110
|
-
* with straight sides rather than as a fire. Three attempts in, the note that
|
|
111
|
-
* unlocked it was that the pool should still fade naturally at its rim — a
|
|
112
|
-
* description of the falloff, not of the blur.
|
|
113
|
-
*/
|
|
114
|
-
float penumbra = clamp(sourceRadius * spread * PENUMBRA_FADE, 0.0, 1.0);
|
|
115
|
-
float strength = 1.0 - penumbra * penumbra;
|
|
116
|
-
|
|
117
|
-
/*
|
|
118
|
-
* Taps are placed on a disk *perpendicular to the light ray*, rotated per
|
|
119
|
-
* fragment.
|
|
133
|
+
* Taps are placed on a disk *perpendicular to the light ray*, turned by half a golden
|
|
134
|
+
* angle on every other column of pixels.
|
|
120
135
|
*
|
|
121
136
|
* Offsetting the ray by fixed 3D vectors was the earlier approach and it has
|
|
122
137
|
* two faults that only show on a wide filter. The offsets are not
|
|
@@ -126,8 +141,33 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
126
141
|
* tap pattern — reported, on a brazier behind a rank of columns, as strange
|
|
127
142
|
* straight lines across the light pool.
|
|
128
143
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
144
|
+
* **The cure for that was a per-fragment hash, and it is what made the penumbra
|
|
145
|
+
* stipple.** Twelve taps are a coarse estimate of how much of the emitter this point can
|
|
146
|
+
* see; turning them by an unrelated angle at every pixel makes each pixel's error
|
|
147
|
+
* independent of its neighbour's, which is the definition of noise. Something has to
|
|
148
|
+
* average that back out, and this engine's lit pass is the last thing to touch the frame —
|
|
149
|
+
* there is no blur and no temporal resolve behind it, so the noise reached the screen. Two
|
|
150
|
+
* games reported the same coarse cross-hatch across a light pool.
|
|
151
|
+
*
|
|
152
|
+
* **So the turn comes from a two-pixel tile instead, and the pair is averaged afterwards.**
|
|
153
|
+
* A pixel's own x parity picks one of two tap sets half a golden angle apart, and
|
|
154
|
+
* \`main\` folds the pair together once the light loop is over — see the resolve there. The
|
|
155
|
+
* pair's twenty-four taps are the filter; each pixel pays for twelve of them.
|
|
156
|
+
*
|
|
157
|
+
* **A *column* pair rather than a 2x2 square, and that was measured rather than assumed.**
|
|
158
|
+
* Four turns on a square tile, resolved with both derivatives, came back at 3.88 against this
|
|
159
|
+
* pair's 2.46 on the same build — because the second derivative hands the lower row of a quad
|
|
160
|
+
* the upper row's difference on hardware that answers a coarse derivative, and with four turns
|
|
161
|
+
* that is the wrong difference. Two turns keyed on the column alone make it the right one,
|
|
162
|
+
* which is why the tile's shape and the resolve are one decision rather than two.
|
|
163
|
+
*
|
|
164
|
+
* **Neither half works alone**, which is the same pairing \`ambientOcclusion.ts\` describes
|
|
165
|
+
* between its rotation tile and its blur. Measured on \`demo/dev/pointshadow.html\` at
|
|
166
|
+
* \`?exposure=6\`, as mean absolute luminance difference to the right and down neighbour over
|
|
167
|
+
* the mover's penumbra, on WebGL2: **4.60** for the per-fragment hash this replaces, **2.88**
|
|
168
|
+
* for this tile with no resolve — and a one-pixel vertical comb in the picture, which is the
|
|
169
|
+
* tile itself on screen — against **2.37** for the pair resolved. A frame with the shadow
|
|
170
|
+
* switched off measures 0.81, and WebGPU answers 4.56 and 2.33 for the same two builds.
|
|
131
171
|
*
|
|
132
172
|
* **Unchanged by the move to an octahedral map, and that is the point.** The offsets are
|
|
133
173
|
* angular perturbations of a direction, so every tap is still a direction and octEncode is
|
|
@@ -138,17 +178,19 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
138
178
|
vec3 up = abs(fwd.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
|
|
139
179
|
vec3 tu = normalize(cross(up, fwd));
|
|
140
180
|
vec3 tv = cross(fwd, tu);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
181
|
+
/*
|
|
182
|
+
* Integer arithmetic, because this is a lattice: a shader compiler is free to implement
|
|
183
|
+
* \`mod(x, 2.0)\` as a multiply by a reciprocal, and a GPU here answers 3 for \`3.0 % 3.0\`.
|
|
184
|
+
* \`gl_FragCoord.x\` is the pixel's centre, so truncating it is the pixel's own column.
|
|
185
|
+
*/
|
|
186
|
+
float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;
|
|
145
187
|
float ca = cos(angle);
|
|
146
188
|
float sa = sin(angle);
|
|
147
189
|
|
|
148
190
|
float lit = 0.0;
|
|
149
191
|
for (int i = 0; i < ${MAX_SHADOW_FILTER_TAPS}; i++) {
|
|
150
192
|
if (i >= uShadowFilterTaps) break;
|
|
151
|
-
vec2 disk = PCF_OFFSETS[i]
|
|
193
|
+
vec2 disk = PCF_OFFSETS[i];
|
|
152
194
|
vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);
|
|
153
195
|
// Perpendicular to the ray, and *not* scaled by its length: the offsets are
|
|
154
196
|
// an angular perturbation of a direction, which is what the encoding takes.
|
|
@@ -163,14 +205,13 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
163
205
|
if (dist - bias > storedDistance) {
|
|
164
206
|
// A point shadow cannot outlive the finite light that casts it, so its
|
|
165
207
|
// occlusion fades smoothly before the light's far plane.
|
|
166
|
-
|
|
208
|
+
float reach = shadowReach(max(dist - storedDistance, 0.0), far);
|
|
209
|
+
lit += 1.0 - reach * tapStrength(dist, storedDistance, sourceRadius);
|
|
167
210
|
} else {
|
|
168
211
|
lit += 1.0;
|
|
169
212
|
}
|
|
170
213
|
}
|
|
171
|
-
|
|
172
|
-
// Fade the whole shadow toward lit as the penumbra swallows it.
|
|
173
|
-
return mix(1.0, shaded, strength);
|
|
214
|
+
return lit / float(max(uShadowFilterTaps, 1));
|
|
174
215
|
}
|
|
175
216
|
|
|
176
217
|
/**
|
|
@@ -261,25 +302,20 @@ float areaShadow(
|
|
|
261
302
|
float widthX = min(halfX * spread, MAX_FILTER_RADIUS) + 0.012;
|
|
262
303
|
float widthY = min(halfY * spread, MAX_FILTER_RADIUS) + 0.012;
|
|
263
304
|
|
|
264
|
-
/* The narrower half extent, for the reason in the header: it is what keeps a strip's umbra. */
|
|
265
|
-
float penumbra = clamp(min(halfX, halfY) * spread * PENUMBRA_FADE, 0.0, 1.0);
|
|
266
|
-
float strength = 1.0 - penumbra * penumbra;
|
|
267
|
-
|
|
268
305
|
/*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
306
|
+
* Turned inside the unit disk *before* the ellipse maps it, so the half-turn that separates
|
|
307
|
+
* the two halves of a pixel pair survives the anisotropy. The same two-pixel tile the round
|
|
308
|
+
* filter uses and for the same reasons — see \`pointShadow\`, which carries the measurement;
|
|
309
|
+
* a rectangle's shadow stippled exactly as a lamp's did, because it is the same twelve taps.
|
|
273
310
|
*/
|
|
274
|
-
float angle =
|
|
275
|
-
* 6.28318530718;
|
|
311
|
+
float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;
|
|
276
312
|
float ca = cos(angle);
|
|
277
313
|
float sa = sin(angle);
|
|
278
314
|
|
|
279
315
|
float lit = 0.0;
|
|
280
316
|
for (int i = 0; i < ${MAX_SHADOW_FILTER_TAPS}; i++) {
|
|
281
317
|
if (i >= uShadowFilterTaps) break;
|
|
282
|
-
vec2 disk = PCF_OFFSETS[i]
|
|
318
|
+
vec2 disk = PCF_OFFSETS[i];
|
|
283
319
|
vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);
|
|
284
320
|
vec3 sampleDir = toFrag + alongX * (turned.x * widthX) + alongY * (turned.y * widthY);
|
|
285
321
|
float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;
|
|
@@ -291,13 +327,15 @@ float areaShadow(
|
|
|
291
327
|
float storedDistance = stored * far;
|
|
292
328
|
if (dist - bias > storedDistance) {
|
|
293
329
|
/* An area light's occlusion cannot outlive the map that carries it either. */
|
|
294
|
-
|
|
330
|
+
float reach = shadowReach(max(dist - storedDistance, 0.0), far);
|
|
331
|
+
/* The narrower half extent, for the reason in the header: it is what keeps a strip's
|
|
332
|
+
umbra. Per tap, for the reason \`tapStrength\` gives. */
|
|
333
|
+
lit += 1.0 - reach * tapStrength(dist, storedDistance, min(halfX, halfY));
|
|
295
334
|
} else {
|
|
296
335
|
lit += 1.0;
|
|
297
336
|
}
|
|
298
337
|
}
|
|
299
|
-
|
|
300
|
-
return mix(1.0, shaded, strength);
|
|
338
|
+
return lit / float(max(uShadowFilterTaps, 1));
|
|
301
339
|
}
|
|
302
340
|
#endif
|
|
303
341
|
|
|
@@ -791,7 +791,30 @@ uniform float uAreaLightTwoSided[MAX_AREA_LIGHTS];
|
|
|
791
791
|
#if POINT_SHADOWS
|
|
792
792
|
/** Which array layer holds this light's shadow, or -1 for none. */
|
|
793
793
|
uniform int uPointShadowLayer[MAX_LIGHTS];
|
|
794
|
-
|
|
794
|
+
/*
|
|
795
|
+
* **The projection each map was rendered under: \`xyz\` where from, \`w\` how far.**
|
|
796
|
+
*
|
|
797
|
+
* The origin is the half that is new and it is **not** the light's position. A light may drift
|
|
798
|
+
* \`pointShadowRebakeDistance\` from its image before the image is re-rendered, because
|
|
799
|
+
* re-baking six faces of the static world on every frame of a flicker is what made a brazier
|
|
800
|
+
* the most expensive object in a scene. The ray below is built from this point rather than from
|
|
801
|
+
* \`uLightPos\`, so it is the ray the picture was drawn along and the drift costs nothing.
|
|
802
|
+
*
|
|
803
|
+
* **It rides with the far plane because a row is a row.** A default-block \`float[N]\` spends a
|
|
804
|
+
* whole uniform vector per element and uses one of its four components, so the far plane was
|
|
805
|
+
* already paying for three floats it threw away — see \`uniformVectorBudget.ts\`. Declared as a
|
|
806
|
+
* separate \`vec3\` array this would have cost sixteen more rows on each of the two sets, and
|
|
807
|
+
* the ladder answers a 256-vector phone by halving \`MAX_LIGHTS\`: the measured price of a
|
|
808
|
+
* separate array was **eight lights becoming four**. Folded in, the whole fix is free.
|
|
809
|
+
*
|
|
810
|
+
* **What it gives up** is that the shadow stops tracking the flame *within* the tolerance — a
|
|
811
|
+
* flicker no longer nudges the shadow it casts, because the image it is read from did not move
|
|
812
|
+
* either. That is the trade the tolerance was always making, now made honestly instead of by
|
|
813
|
+
* reading a picture from the wrong place. **What would make it wrong** is a tolerance large
|
|
814
|
+
* enough that a shadow visibly lags its light, which is a reason to lower
|
|
815
|
+
* \`pointShadowRebakeDistance\` rather than to sample from a point the image cannot answer for.
|
|
816
|
+
*/
|
|
817
|
+
uniform vec4 uPointShadowProjection[MAX_LIGHTS];
|
|
795
818
|
uniform float uPointShadowNear[MAX_LIGHTS];
|
|
796
819
|
/** Emitter radius per light — drives how soft its shadows are. */
|
|
797
820
|
uniform float uPointShadowSize[MAX_LIGHTS];
|
|
@@ -822,7 +845,8 @@ uniform highp sampler2DArray uPointShadows; // wgsl:share shadow
|
|
|
822
845
|
* light owns at most one of the two at a time.
|
|
823
846
|
*/
|
|
824
847
|
uniform int uLivePointShadowLayer[MAX_LIGHTS];
|
|
825
|
-
|
|
848
|
+
/** The live pair's own projection, by the same rule. See \`uPointShadowProjection\`. */
|
|
849
|
+
uniform vec4 uLivePointShadowProjection[MAX_LIGHTS];
|
|
826
850
|
uniform float uLivePointShadowNear[MAX_LIGHTS];
|
|
827
851
|
uniform float uLivePointShadowSize[MAX_LIGHTS];
|
|
828
852
|
uniform float uLivePointShadowWeight[MAX_LIGHTS];
|
|
@@ -871,16 +895,53 @@ uniform float uLiveAreaShadowWeight[MAX_AREA_LIGHTS];
|
|
|
871
895
|
* where a bias means what it looks like it means.
|
|
872
896
|
*/
|
|
873
897
|
/**
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
898
|
+
* Twelve places on a disk across the light ray, for filtering an omnidirectional shadow.
|
|
899
|
+
*
|
|
900
|
+
* **These were eight, and the count is why a penumbra was stippled.** The list used to be the
|
|
901
|
+
* eight corners and six faces of a cube, carried as \`vec3\` from the era when a tap offset a
|
|
902
|
+
* direction by a 3D vector. Nothing has read the third component since the map became
|
|
903
|
+
* octahedral — both filters take \`.xy\` — and flattening a cube's corners onto a plane makes
|
|
904
|
+
* pairs of them coincide: \`(1, 1, 1)\` and \`(1, 1, -1)\` are one 2D offset, four times over. So
|
|
905
|
+
* twelve taps asked eight questions and answered four of them twice, and every one of the eight
|
|
906
|
+
* sat on the rim at radius 1 or 1.414 with nothing in between. A coverage estimate from a ring
|
|
907
|
+
* moves in steps of two twelfths as the penumbra's edge sweeps a doubled pair across it, which
|
|
908
|
+
* is the size of the speckle that was reported.
|
|
909
|
+
*
|
|
910
|
+
* **The replacement is the golden-angle disk this repository already uses for ambient
|
|
911
|
+
* occlusion** — angle \`i\` turns of 137.5 degrees, radius \`sqrt((k + 0.5) / 12)\` — so the taps
|
|
912
|
+
* are spread evenly over the area rather than crowded on the rim or at the centre, and no two
|
|
913
|
+
* coincide. The nearest pair is 0.355 apart where the old set had a pair at zero.
|
|
914
|
+
*
|
|
915
|
+
* **The radii are dealt round rather than taken in order, and that is what makes a short filter
|
|
916
|
+
* work.** \`shadowFilterTaps\` is 4, 8 or 12 and the loop below simply stops early, so every
|
|
917
|
+
* prefix of this list is a filter somebody ships: in spiral order the first four taps would be
|
|
918
|
+
* the four innermost and a low profile would quietly get a filter a third of the width it asked
|
|
919
|
+
* for. Interleaved, the first four already span 0.21 to 0.91 of the disk's reach and sit around
|
|
920
|
+
* its centre rather than to one side, which is a small filter rather than a narrow one.
|
|
921
|
+
*
|
|
922
|
+
* **What it gives up** is a little softness. The old set put two thirds of its taps at the
|
|
923
|
+
* outermost radius, so it read the penumbra's widest part twice as often as an even disk does
|
|
924
|
+
* and drew a shadow edge softer than the emitter's size calls for; an even disk is the honest
|
|
925
|
+
* estimate and is very slightly crisper. **What would make it wrong** is a caller wanting the
|
|
926
|
+
* filter's *reach* changed — that is \`MAX_FILTER_RADIUS\`, and the outermost tap here is 1.384
|
|
927
|
+
* against the old 1.414 precisely so this change does not move it.
|
|
877
928
|
*/
|
|
878
929
|
#if POINT_SHADOWS
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
930
|
+
const vec2 PCF_OFFSETS[${MAX_SHADOW_FILTER_TAPS}] = vec2[${MAX_SHADOW_FILTER_TAPS}](
|
|
931
|
+
vec2( 0.289, 0.000), vec2(-0.767, 0.703), vec2( 0.067, -0.761), vec2( 0.766, 0.999),
|
|
932
|
+
vec2(-0.492, -0.087), vec2( 0.943, -0.600), vec2(-0.225, 0.836), vec2(-0.610, -1.174),
|
|
933
|
+
vec2( 0.606, 0.221), vec2(-1.100, 0.454), vec2( 0.406, -0.867), vec2( 0.414, 1.321)
|
|
883
934
|
);
|
|
935
|
+
/**
|
|
936
|
+
* The angle between the two tap sets a pair of neighbouring pixels uses. See \`pointShadow\`.
|
|
937
|
+
*
|
|
938
|
+
* Half the golden angle, because the offsets above are a golden-angle spiral: turning the whole
|
|
939
|
+
* set by half a step drops the second pixel's taps between the first pixel's arms rather than
|
|
940
|
+
* on top of them. Measured on the pair, the closest two of the twenty-four are 0.305 apart,
|
|
941
|
+
* against 0.175 for a half turn and 0.233 for a whole golden angle — so this is the choice that
|
|
942
|
+
* makes the pair's combined filter as even as the twelve it is built from.
|
|
943
|
+
*/
|
|
944
|
+
const float PCF_PAIR_TURN = 1.19998161;
|
|
884
945
|
#endif
|
|
885
946
|
|
|
886
947
|
/** A finite point-light shadow loses strength with the light that casts it. */`;
|