@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.
Files changed (53) hide show
  1. package/CHANGELOG.json +3796 -3739
  2. package/README.md +1 -1
  3. package/dist/index.js +12 -2
  4. package/dist/render/backend/webgl2/renderer.d.ts +15 -0
  5. package/dist/render/backend/webgl2/renderer.js +28 -7
  6. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +46 -0
  7. package/dist/render/backend/webgpu/gpuDrivenPass.js +105 -0
  8. package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
  9. package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
  10. package/dist/render/backend/webgpu/renderer.js +17 -4
  11. package/dist/render/depthConvention.d.ts +19 -0
  12. package/dist/render/depthConvention.js +21 -0
  13. package/dist/render/livePointShadowSet.js +8 -0
  14. package/dist/render/pointShadowBudget.js +59 -5
  15. package/dist/render/pointShadowImage.d.ts +40 -0
  16. package/dist/render/pointShadowImage.js +20 -0
  17. package/dist/render/pointShadowMap.d.ts +4 -0
  18. package/dist/render/pointShadowMap.js +10 -0
  19. package/dist/render/pointShadowSystem.js +9 -0
  20. package/dist/render/renderQuality.d.ts +27 -4
  21. package/dist/render/renderQuality.js +1 -1
  22. package/dist/render/shaders/flat/main.d.ts +1 -1
  23. package/dist/render/shaders/flat/main.js +104 -10
  24. package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
  25. package/dist/render/shaders/flat/pointShadow.js +84 -46
  26. package/dist/render/shaders/flat/preamble.js +70 -9
  27. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
  28. package/dist/render/shaders/generated/flat.wgsl.js +32 -32
  29. package/dist/render/shaders/sky.d.ts +10 -6
  30. package/dist/render/shaders/sky.js +35 -2
  31. package/dist/render/textLayout.d.ts +58 -0
  32. package/dist/render/textLayout.js +83 -0
  33. package/dist/render/textRenderer.js +7 -3
  34. package/package.json +6 -6
  35. package/src/index.ts +12 -2
  36. package/src/render/backend/webgl2/renderer.ts +28 -7
  37. package/src/render/backend/webgpu/gpuDrivenPass.ts +105 -0
  38. package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
  39. package/src/render/backend/webgpu/renderer.ts +26 -4
  40. package/src/render/depthConvention.ts +22 -0
  41. package/src/render/livePointShadowSet.ts +7 -0
  42. package/src/render/pointShadowBudget.ts +58 -5
  43. package/src/render/pointShadowImage.ts +51 -0
  44. package/src/render/pointShadowMap.ts +13 -0
  45. package/src/render/pointShadowSystem.ts +8 -0
  46. package/src/render/renderQuality.ts +28 -5
  47. package/src/render/shaders/flat/main.ts +104 -10
  48. package/src/render/shaders/flat/pointShadow.ts +84 -46
  49. package/src/render/shaders/flat/preamble.ts +70 -9
  50. package/src/render/shaders/generated/flat.wgsl.ts +32 -32
  51. package/src/render/shaders/sky.ts +37 -2
  52. package/src/render/textLayout.ts +88 -0
  53. package/src/render/textRenderer.ts +22 -3
@@ -15,7 +15,40 @@
15
15
  import { MAX_SHADOW_FILTER_TAPS } from '../../renderQuality.ts';
16
16
  import { OUTPUT_TRANSFORM_GLSL } from '../outputTransform.ts';
17
17
 
18
- export const POINTSHADOW_GLSL = `float pointShadow(
18
+ export const POINTSHADOW_GLSL = `/**
19
+ * How much of one tap's occlusion survives its own penumbra.
20
+ *
21
+ * **How much of a shadow survives, as well as how soft it is.** A penumbra does not merely blur
22
+ * with distance from its caster, it *takes over*: far enough from the occluder, relative to how
23
+ * big the source is, every point on the receiver can see part of the flame and there is no umbra
24
+ * left at all. The shadow is gone, not blurry. Without it, two columns lit by a brazier threw
25
+ * full-strength edges fifteen metres across the terrace, and the gap between them read as a
26
+ * spotlight cone with straight sides rather than as a fire.
27
+ *
28
+ * **Per tap, and it was once for the whole filter, taken from the centre tap alone.** That is a
29
+ * blocker search of one sample standing in for twelve, and it is right wherever the taps agree —
30
+ * inside an umbra they all find the same occluder, so this returns exactly what the shared value
31
+ * did, and the umbra is unchanged to the bit. It is wrong exactly where they disagree, which is
32
+ * the silhouette: there the centre ray *misses*, so the search reports no occluder, the spread is
33
+ * zero and the fade is switched off — while the taps that do land on the caster then draw at full
34
+ * strength. What that paints is a hard one-texel line around a shadow whose interior has
35
+ * correctly faded to nothing.
36
+ *
37
+ * Reported as shadows "faded out but NOT shadow outlines, generating long lines around the
38
+ * world", and on a brazier as a bare square outline lying on the ground with no shadow inside it.
39
+ *
40
+ * **What it costs** is two multiplies and a divide per occluded tap, against a tap that has
41
+ * already paid for a texture fetch. **What would make it wrong** is a filter wide enough that one
42
+ * tap's occluder is not the occluder a neighbouring tap is estimating for, which is the same
43
+ * limit the shared value had and is what \`MAX_FILTER_RADIUS\` bounds.
44
+ */
45
+ float tapStrength(float receiverDistance, float occluderDistance, float sourceRadius) {
46
+ float spread = max(receiverDistance - occluderDistance, 0.0) / max(occluderDistance, 0.05);
47
+ float penumbra = clamp(sourceRadius * spread * PENUMBRA_FADE, 0.0, 1.0);
48
+ return 1.0 - penumbra * penumbra;
49
+ }
50
+
51
+ float pointShadow(
19
52
  highp sampler2DArray maps,
20
53
  float layer,
21
54
  vec3 toFrag,
@@ -98,26 +131,8 @@ export const POINTSHADOW_GLSL = `float pointShadow(
98
131
  float radius = clamp(sourceRadius * spread, 0.01, MAX_FILTER_RADIUS) + 0.012;
99
132
 
100
133
  /*
101
- * How much of a shadow survives, as well as how soft it is.
102
- *
103
- * This is the half that was missing, and it is why widening the filter never
104
- * fixed anything. A penumbra does not merely blur with distance from its
105
- * caster — it *takes over*. Far enough from the occluder, relative to how big
106
- * the source is, every point on the receiver can see part of the flame and
107
- * there is no umbra left at all: the shadow is gone, not blurry.
108
- *
109
- * Without it, two columns lit by a brazier threw full-strength edges fifteen
110
- * metres across the terrace, and the gap between them read as a spotlight cone
111
- * with straight sides rather than as a fire. Three attempts in, the note that
112
- * unlocked it was that the pool should still fade naturally at its rim — a
113
- * description of the falloff, not of the blur.
114
- */
115
- float penumbra = clamp(sourceRadius * spread * PENUMBRA_FADE, 0.0, 1.0);
116
- float strength = 1.0 - penumbra * penumbra;
117
-
118
- /*
119
- * Taps are placed on a disk *perpendicular to the light ray*, rotated per
120
- * fragment.
134
+ * Taps are placed on a disk *perpendicular to the light ray*, turned by half a golden
135
+ * angle on every other column of pixels.
121
136
  *
122
137
  * Offsetting the ray by fixed 3D vectors was the earlier approach and it has
123
138
  * two faults that only show on a wide filter. The offsets are not
@@ -127,8 +142,33 @@ export const POINTSHADOW_GLSL = `float pointShadow(
127
142
  * tap pattern — reported, on a brazier behind a rank of columns, as strange
128
143
  * straight lines across the light pool.
129
144
  *
130
- * A per-fragment rotation turns that banding into a fine dither, which the eye
131
- * reads as smooth — the standard cure, and it costs one hash.
145
+ * **The cure for that was a per-fragment hash, and it is what made the penumbra
146
+ * stipple.** Twelve taps are a coarse estimate of how much of the emitter this point can
147
+ * see; turning them by an unrelated angle at every pixel makes each pixel's error
148
+ * independent of its neighbour's, which is the definition of noise. Something has to
149
+ * average that back out, and this engine's lit pass is the last thing to touch the frame —
150
+ * there is no blur and no temporal resolve behind it, so the noise reached the screen. Two
151
+ * games reported the same coarse cross-hatch across a light pool.
152
+ *
153
+ * **So the turn comes from a two-pixel tile instead, and the pair is averaged afterwards.**
154
+ * A pixel's own x parity picks one of two tap sets half a golden angle apart, and
155
+ * \`main\` folds the pair together once the light loop is over — see the resolve there. The
156
+ * pair's twenty-four taps are the filter; each pixel pays for twelve of them.
157
+ *
158
+ * **A *column* pair rather than a 2x2 square, and that was measured rather than assumed.**
159
+ * Four turns on a square tile, resolved with both derivatives, came back at 3.88 against this
160
+ * pair's 2.46 on the same build — because the second derivative hands the lower row of a quad
161
+ * the upper row's difference on hardware that answers a coarse derivative, and with four turns
162
+ * that is the wrong difference. Two turns keyed on the column alone make it the right one,
163
+ * which is why the tile's shape and the resolve are one decision rather than two.
164
+ *
165
+ * **Neither half works alone**, which is the same pairing \`ambientOcclusion.ts\` describes
166
+ * between its rotation tile and its blur. Measured on \`demo/dev/pointshadow.html\` at
167
+ * \`?exposure=6\`, as mean absolute luminance difference to the right and down neighbour over
168
+ * the mover's penumbra, on WebGL2: **4.60** for the per-fragment hash this replaces, **2.88**
169
+ * for this tile with no resolve — and a one-pixel vertical comb in the picture, which is the
170
+ * tile itself on screen — against **2.37** for the pair resolved. A frame with the shadow
171
+ * switched off measures 0.81, and WebGPU answers 4.56 and 2.33 for the same two builds.
132
172
  *
133
173
  * **Unchanged by the move to an octahedral map, and that is the point.** The offsets are
134
174
  * angular perturbations of a direction, so every tap is still a direction and octEncode is
@@ -139,17 +179,19 @@ export const POINTSHADOW_GLSL = `float pointShadow(
139
179
  vec3 up = abs(fwd.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
140
180
  vec3 tu = normalize(cross(up, fwd));
141
181
  vec3 tv = cross(fwd, tu);
142
- // Interleaved gradient noise: cheap, and stable enough per pixel that the
143
- // dither does not crawl while the camera moves.
144
- float angle = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))))
145
- * 6.28318530718;
182
+ /*
183
+ * Integer arithmetic, because this is a lattice: a shader compiler is free to implement
184
+ * \`mod(x, 2.0)\` as a multiply by a reciprocal, and a GPU here answers 3 for \`3.0 % 3.0\`.
185
+ * \`gl_FragCoord.x\` is the pixel's centre, so truncating it is the pixel's own column.
186
+ */
187
+ float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;
146
188
  float ca = cos(angle);
147
189
  float sa = sin(angle);
148
190
 
149
191
  float lit = 0.0;
150
192
  for (int i = 0; i < ${MAX_SHADOW_FILTER_TAPS}; i++) {
151
193
  if (i >= uShadowFilterTaps) break;
152
- vec2 disk = PCF_OFFSETS[i].xy;
194
+ vec2 disk = PCF_OFFSETS[i];
153
195
  vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);
154
196
  // Perpendicular to the ray, and *not* scaled by its length: the offsets are
155
197
  // an angular perturbation of a direction, which is what the encoding takes.
@@ -164,14 +206,13 @@ export const POINTSHADOW_GLSL = `float pointShadow(
164
206
  if (dist - bias > storedDistance) {
165
207
  // A point shadow cannot outlive the finite light that casts it, so its
166
208
  // occlusion fades smoothly before the light's far plane.
167
- lit += 1.0 - shadowReach(max(dist - storedDistance, 0.0), far);
209
+ float reach = shadowReach(max(dist - storedDistance, 0.0), far);
210
+ lit += 1.0 - reach * tapStrength(dist, storedDistance, sourceRadius);
168
211
  } else {
169
212
  lit += 1.0;
170
213
  }
171
214
  }
172
- float shaded = lit / float(max(uShadowFilterTaps, 1));
173
- // Fade the whole shadow toward lit as the penumbra swallows it.
174
- return mix(1.0, shaded, strength);
215
+ return lit / float(max(uShadowFilterTaps, 1));
175
216
  }
176
217
 
177
218
  /**
@@ -262,25 +303,20 @@ float areaShadow(
262
303
  float widthX = min(halfX * spread, MAX_FILTER_RADIUS) + 0.012;
263
304
  float widthY = min(halfY * spread, MAX_FILTER_RADIUS) + 0.012;
264
305
 
265
- /* The narrower half extent, for the reason in the header: it is what keeps a strip's umbra. */
266
- float penumbra = clamp(min(halfX, halfY) * spread * PENUMBRA_FADE, 0.0, 1.0);
267
- float strength = 1.0 - penumbra * penumbra;
268
-
269
306
  /*
270
- * Rotated per fragment inside the unit disk *before* the ellipse maps it, so the dither that
271
- * breaks up twelve aligned taps survives the anisotropy. Interleaved gradient noise, one hash;
272
- * without it a wide filter bands along the tap pattern — reported on a brazier behind a rank of
273
- * columns as straight lines across the light pool.
307
+ * Turned inside the unit disk *before* the ellipse maps it, so the half-turn that separates
308
+ * the two halves of a pixel pair survives the anisotropy. The same two-pixel tile the round
309
+ * filter uses and for the same reasons — see \`pointShadow\`, which carries the measurement;
310
+ * a rectangle's shadow stippled exactly as a lamp's did, because it is the same twelve taps.
274
311
  */
275
- float angle = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))))
276
- * 6.28318530718;
312
+ float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;
277
313
  float ca = cos(angle);
278
314
  float sa = sin(angle);
279
315
 
280
316
  float lit = 0.0;
281
317
  for (int i = 0; i < ${MAX_SHADOW_FILTER_TAPS}; i++) {
282
318
  if (i >= uShadowFilterTaps) break;
283
- vec2 disk = PCF_OFFSETS[i].xy;
319
+ vec2 disk = PCF_OFFSETS[i];
284
320
  vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);
285
321
  vec3 sampleDir = toFrag + alongX * (turned.x * widthX) + alongY * (turned.y * widthY);
286
322
  float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;
@@ -292,13 +328,15 @@ float areaShadow(
292
328
  float storedDistance = stored * far;
293
329
  if (dist - bias > storedDistance) {
294
330
  /* An area light's occlusion cannot outlive the map that carries it either. */
295
- lit += 1.0 - shadowReach(max(dist - storedDistance, 0.0), far);
331
+ float reach = shadowReach(max(dist - storedDistance, 0.0), far);
332
+ /* The narrower half extent, for the reason in the header: it is what keeps a strip's
333
+ umbra. Per tap, for the reason \`tapStrength\` gives. */
334
+ lit += 1.0 - reach * tapStrength(dist, storedDistance, min(halfX, halfY));
296
335
  } else {
297
336
  lit += 1.0;
298
337
  }
299
338
  }
300
- float shaded = lit / float(max(uShadowFilterTaps, 1));
301
- return mix(1.0, shaded, strength);
339
+ return lit / float(max(uShadowFilterTaps, 1));
302
340
  }
303
341
  #endif
304
342
 
@@ -803,7 +803,30 @@ uniform float uAreaLightTwoSided[MAX_AREA_LIGHTS];
803
803
  #if POINT_SHADOWS
804
804
  /** Which array layer holds this light's shadow, or -1 for none. */
805
805
  uniform int uPointShadowLayer[MAX_LIGHTS];
806
- uniform float uPointShadowFar[MAX_LIGHTS];
806
+ /*
807
+ * **The projection each map was rendered under: \`xyz\` where from, \`w\` how far.**
808
+ *
809
+ * The origin is the half that is new and it is **not** the light's position. A light may drift
810
+ * \`pointShadowRebakeDistance\` from its image before the image is re-rendered, because
811
+ * re-baking six faces of the static world on every frame of a flicker is what made a brazier
812
+ * the most expensive object in a scene. The ray below is built from this point rather than from
813
+ * \`uLightPos\`, so it is the ray the picture was drawn along and the drift costs nothing.
814
+ *
815
+ * **It rides with the far plane because a row is a row.** A default-block \`float[N]\` spends a
816
+ * whole uniform vector per element and uses one of its four components, so the far plane was
817
+ * already paying for three floats it threw away — see \`uniformVectorBudget.ts\`. Declared as a
818
+ * separate \`vec3\` array this would have cost sixteen more rows on each of the two sets, and
819
+ * the ladder answers a 256-vector phone by halving \`MAX_LIGHTS\`: the measured price of a
820
+ * separate array was **eight lights becoming four**. Folded in, the whole fix is free.
821
+ *
822
+ * **What it gives up** is that the shadow stops tracking the flame *within* the tolerance — a
823
+ * flicker no longer nudges the shadow it casts, because the image it is read from did not move
824
+ * either. That is the trade the tolerance was always making, now made honestly instead of by
825
+ * reading a picture from the wrong place. **What would make it wrong** is a tolerance large
826
+ * enough that a shadow visibly lags its light, which is a reason to lower
827
+ * \`pointShadowRebakeDistance\` rather than to sample from a point the image cannot answer for.
828
+ */
829
+ uniform vec4 uPointShadowProjection[MAX_LIGHTS];
807
830
  uniform float uPointShadowNear[MAX_LIGHTS];
808
831
  /** Emitter radius per light — drives how soft its shadows are. */
809
832
  uniform float uPointShadowSize[MAX_LIGHTS];
@@ -834,7 +857,8 @@ uniform highp sampler2DArray uPointShadows; // wgsl:share shadow
834
857
  * light owns at most one of the two at a time.
835
858
  */
836
859
  uniform int uLivePointShadowLayer[MAX_LIGHTS];
837
- uniform float uLivePointShadowFar[MAX_LIGHTS];
860
+ /** The live pair's own projection, by the same rule. See \`uPointShadowProjection\`. */
861
+ uniform vec4 uLivePointShadowProjection[MAX_LIGHTS];
838
862
  uniform float uLivePointShadowNear[MAX_LIGHTS];
839
863
  uniform float uLivePointShadowSize[MAX_LIGHTS];
840
864
  uniform float uLivePointShadowWeight[MAX_LIGHTS];
@@ -883,16 +907,53 @@ uniform float uLiveAreaShadowWeight[MAX_AREA_LIGHTS];
883
907
  * where a bias means what it looks like it means.
884
908
  */
885
909
  /**
886
- * Sample offsets forming a rough sphere, for filtering a cubemap shadow.
887
- * Perturbing the *direction* is how PCF works on a cube — there is no 2D UV
888
- * neighbourhood to step across the way there is for the sun's cascade.
910
+ * Twelve places on a disk across the light ray, for filtering an omnidirectional shadow.
911
+ *
912
+ * **These were eight, and the count is why a penumbra was stippled.** The list used to be the
913
+ * eight corners and six faces of a cube, carried as \`vec3\` from the era when a tap offset a
914
+ * direction by a 3D vector. Nothing has read the third component since the map became
915
+ * octahedral — both filters take \`.xy\` — and flattening a cube's corners onto a plane makes
916
+ * pairs of them coincide: \`(1, 1, 1)\` and \`(1, 1, -1)\` are one 2D offset, four times over. So
917
+ * twelve taps asked eight questions and answered four of them twice, and every one of the eight
918
+ * sat on the rim at radius 1 or 1.414 with nothing in between. A coverage estimate from a ring
919
+ * moves in steps of two twelfths as the penumbra's edge sweeps a doubled pair across it, which
920
+ * is the size of the speckle that was reported.
921
+ *
922
+ * **The replacement is the golden-angle disk this repository already uses for ambient
923
+ * occlusion** — angle \`i\` turns of 137.5 degrees, radius \`sqrt((k + 0.5) / 12)\` — so the taps
924
+ * are spread evenly over the area rather than crowded on the rim or at the centre, and no two
925
+ * coincide. The nearest pair is 0.355 apart where the old set had a pair at zero.
926
+ *
927
+ * **The radii are dealt round rather than taken in order, and that is what makes a short filter
928
+ * work.** \`shadowFilterTaps\` is 4, 8 or 12 and the loop below simply stops early, so every
929
+ * prefix of this list is a filter somebody ships: in spiral order the first four taps would be
930
+ * the four innermost and a low profile would quietly get a filter a third of the width it asked
931
+ * for. Interleaved, the first four already span 0.21 to 0.91 of the disk's reach and sit around
932
+ * its centre rather than to one side, which is a small filter rather than a narrow one.
933
+ *
934
+ * **What it gives up** is a little softness. The old set put two thirds of its taps at the
935
+ * outermost radius, so it read the penumbra's widest part twice as often as an even disk does
936
+ * and drew a shadow edge softer than the emitter's size calls for; an even disk is the honest
937
+ * estimate and is very slightly crisper. **What would make it wrong** is a caller wanting the
938
+ * filter's *reach* changed — that is \`MAX_FILTER_RADIUS\`, and the outermost tap here is 1.384
939
+ * against the old 1.414 precisely so this change does not move it.
889
940
  */
890
941
  #if POINT_SHADOWS
891
- const vec3 PCF_OFFSETS[${MAX_SHADOW_FILTER_TAPS}] = vec3[${MAX_SHADOW_FILTER_TAPS}](
892
- vec3( 1.0, 1.0, 1.0), vec3( 1.0, -1.0, -1.0), vec3(-1.0, 1.0, -1.0), vec3(-1.0, -1.0, 1.0),
893
- vec3( 1.0, -1.0, 1.0), vec3(-1.0, 1.0, 1.0), vec3( 1.0, 1.0, -1.0), vec3(-1.0, -1.0, -1.0),
894
- vec3( 1.0, 0.0, 0.0), vec3(-1.0, 0.0, 0.0), vec3( 0.0, 1.0, 0.0), vec3( 0.0, -1.0, 0.0)
942
+ const vec2 PCF_OFFSETS[${MAX_SHADOW_FILTER_TAPS}] = vec2[${MAX_SHADOW_FILTER_TAPS}](
943
+ vec2( 0.289, 0.000), vec2(-0.767, 0.703), vec2( 0.067, -0.761), vec2( 0.766, 0.999),
944
+ vec2(-0.492, -0.087), vec2( 0.943, -0.600), vec2(-0.225, 0.836), vec2(-0.610, -1.174),
945
+ vec2( 0.606, 0.221), vec2(-1.100, 0.454), vec2( 0.406, -0.867), vec2( 0.414, 1.321)
895
946
  );
947
+ /**
948
+ * The angle between the two tap sets a pair of neighbouring pixels uses. See \`pointShadow\`.
949
+ *
950
+ * Half the golden angle, because the offsets above are a golden-angle spiral: turning the whole
951
+ * set by half a step drops the second pixel's taps between the first pixel's arms rather than
952
+ * on top of them. Measured on the pair, the closest two of the twenty-four are 0.305 apart,
953
+ * against 0.175 for a half turn and 0.233 for a whole golden angle — so this is the choice that
954
+ * makes the pair's combined filter as even as the twelve it is built from.
955
+ */
956
+ const float PCF_PAIR_TURN = 1.19998161;
896
957
  #endif
897
958
 
898
959
  /** A finite point-light shadow loses strength with the light that casts it. */`;