@driftengine/core 4.1.4 → 4.2.0

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 (47) hide show
  1. package/CHANGELOG.json +3812 -3770
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +7 -1
  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/pointShadowPass.d.ts +4 -0
  7. package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
  8. package/dist/render/backend/webgpu/renderer.js +11 -6
  9. package/dist/render/depthConvention.d.ts +19 -0
  10. package/dist/render/depthConvention.js +21 -0
  11. package/dist/render/livePointShadowSet.js +8 -0
  12. package/dist/render/pointShadowBudget.js +59 -5
  13. package/dist/render/pointShadowImage.d.ts +40 -0
  14. package/dist/render/pointShadowImage.js +20 -0
  15. package/dist/render/pointShadowMap.d.ts +4 -0
  16. package/dist/render/pointShadowMap.js +10 -0
  17. package/dist/render/pointShadowSystem.js +9 -0
  18. package/dist/render/renderQuality.d.ts +27 -4
  19. package/dist/render/renderQuality.js +1 -1
  20. package/dist/render/shaders/flat/main.d.ts +1 -1
  21. package/dist/render/shaders/flat/main.js +13 -5
  22. package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
  23. package/dist/render/shaders/flat/pointShadow.js +42 -30
  24. package/dist/render/shaders/flat/preamble.js +26 -2
  25. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
  26. package/dist/render/shaders/generated/flat.wgsl.js +24 -24
  27. package/dist/render/shaders/sky.d.ts +10 -6
  28. package/dist/render/shaders/sky.js +35 -2
  29. package/dist/render/textRenderer.js +23 -4
  30. package/package.json +6 -6
  31. package/src/index.ts +11 -1
  32. package/src/render/backend/webgl2/renderer.ts +28 -7
  33. package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
  34. package/src/render/backend/webgpu/renderer.ts +16 -10
  35. package/src/render/depthConvention.ts +22 -0
  36. package/src/render/livePointShadowSet.ts +7 -0
  37. package/src/render/pointShadowBudget.ts +58 -5
  38. package/src/render/pointShadowImage.ts +51 -0
  39. package/src/render/pointShadowMap.ts +13 -0
  40. package/src/render/pointShadowSystem.ts +8 -0
  41. package/src/render/renderQuality.ts +28 -5
  42. package/src/render/shaders/flat/main.ts +13 -5
  43. package/src/render/shaders/flat/pointShadow.ts +42 -30
  44. package/src/render/shaders/flat/preamble.ts +26 -2
  45. package/src/render/shaders/generated/flat.wgsl.ts +24 -24
  46. package/src/render/shaders/sky.ts +37 -2
  47. package/src/render/textRenderer.ts +23 -14
@@ -162,10 +162,33 @@ export interface RenderQuality {
162
162
  * sampled lights flickering at once accounted for 174 of the frame's 296 draw
163
163
  * calls, and for the game being unplayable exactly where lamps cluster.
164
164
  *
165
- * The default clears a flame's own wander with room to spare, because drift is
166
- * measured from the baked origin and a wander can be caught at either extreme
167
- * of its travel. A light that really moves still re-bakes; it just does so once
168
- * per this much movement instead of once per frame.
165
+ * **It was 0.4 until 4.1.5, chosen to clear a flame's whole wander with room to spare** — so
166
+ * a flame never re-baked at all, and its shadow of the static world stood still while the live
167
+ * map under the same light, which is re-rendered every frame because it holds the movers, swung
168
+ * with the flame. One light, two shadows, visibly disagreeing about where it was. Reported as
169
+ * the runner's shadow following the fire and the brazier's not.
170
+ *
171
+ * **A millimetre instead, which is as near to "whenever it moved" as makes sense**, so a shadow
172
+ * travels with its light rather than freezing between re-bakes. A centimetre was tried first and
173
+ * left a visible vibration: a light wandering on a curve moves less than that in a frame near
174
+ * the turning points of its travel, so the shadow ran smoothly through the fast part and stepped
175
+ * through the slow part. What the tolerance is still for is not re-baking on arithmetic noise.
176
+ *
177
+ * A light that does not move never goes stale at any positive value, so the whole cost falls on
178
+ * the lights that need it, and it is bounded rather than proportional: one such light takes a
179
+ * whole cube in a frame and the rest dribble under the ordinary face budget.
180
+ *
181
+ * **What makes that affordable is not this number.** The 174-of-296 draw calls this paragraph
182
+ * used to cite was measured before `pointShadowFacesPerFrame` capped the whole static bake, and
183
+ * before `planPointShadowBakes` learned to put a chronically stale map behind every cold and
184
+ * settling one. A flame now spends leftover budget and can never hold another light's first
185
+ * image hostage. One such light also finishes its cube inside a single frame rather than
186
+ * dribbling it over three, which is what makes the motion smooth instead of stepped; see
187
+ * `pointShadowBudget.ts`.
188
+ *
189
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
190
+ * fit the frame, where a stepped shadow is the better trade and this should go back up.
191
+ * `gpuTiming` is how to tell which, and it is a number rather than an opinion.
169
192
  */
170
193
  readonly pointShadowRebakeDistance: number;
171
194
  /**
@@ -815,7 +838,7 @@ export const DEFAULT_RENDER_QUALITY: Readonly<RenderQuality> = Object.freeze({
815
838
  directionalShadowDepthLayers: 2,
816
839
  directionalShadowMaxDistance: 6,
817
840
  directionalShadowMaxSlope: 3,
818
- pointShadowRebakeDistance: 0.4,
841
+ pointShadowRebakeDistance: 0.001,
819
842
  pointShadowFacesPerFrame: 2,
820
843
  /*
821
844
  * Twelve, which is `LIVE_POINT_SHADOW_MAPS` cubemaps of six faces: exactly what the live
@@ -990,7 +990,7 @@ export const MAIN_GLSL = `void main() {
990
990
  #if POINT_SHADOWS
991
991
  float grazing = 1.0 - ndl;
992
992
  float normalOffset = (0.06 + dist * 0.05) * grazing * grazing + 0.02;
993
- vec3 shadowFrom = (vWorldPos + n * normalOffset) - lightPos;
993
+ vec3 receiverPoint = vWorldPos + n * normalOffset;
994
994
 
995
995
  /*
996
996
  * uPointShadowWeight is how present the cubemap is, and it is the difference
@@ -1031,8 +1031,15 @@ export const MAIN_GLSL = `void main() {
1031
1031
  pointShadow(
1032
1032
  uPointShadows,
1033
1033
  float(layer),
1034
- shadowFrom,
1035
- uPointShadowFar[shadowRead],
1034
+ /*
1035
+ * **From where the map was baked, not from where the light is.** The two are apart
1036
+ * by up to \`pointShadowRebakeDistance\` for anything that wanders, and both things
1037
+ * this asks the picture move with that gap: the distance it compares against the
1038
+ * stored one, and which texel the single-tap blocker search lands in. See
1039
+ * \`uPointShadowOrigin\`.
1040
+ */
1041
+ receiverPoint - uPointShadowProjection[shadowRead].xyz,
1042
+ uPointShadowProjection[shadowRead].w,
1036
1043
  uPointShadowNear[shadowRead],
1037
1044
  uPointShadowSize[shadowRead]
1038
1045
  ),
@@ -1051,8 +1058,9 @@ export const MAIN_GLSL = `void main() {
1051
1058
  liveOccl = pointShadow(
1052
1059
  uPointShadows,
1053
1060
  float(liveLayer),
1054
- shadowFrom,
1055
- uLivePointShadowFar[shadowRead],
1061
+ /* Its own bake origin, for the reason the static layer above gives. */
1062
+ receiverPoint - uLivePointShadowProjection[shadowRead].xyz,
1063
+ uLivePointShadowProjection[shadowRead].w,
1056
1064
  uLivePointShadowNear[shadowRead],
1057
1065
  uLivePointShadowSize[shadowRead]
1058
1066
  );
@@ -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,
@@ -97,24 +130,6 @@ export const POINTSHADOW_GLSL = `float pointShadow(
97
130
  */
98
131
  float radius = clamp(sourceRadius * spread, 0.01, MAX_FILTER_RADIUS) + 0.012;
99
132
 
100
- /*
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
133
  /*
119
134
  * Taps are placed on a disk *perpendicular to the light ray*, turned by half a golden
120
135
  * angle on every other column of pixels.
@@ -191,14 +206,13 @@ export const POINTSHADOW_GLSL = `float pointShadow(
191
206
  if (dist - bias > storedDistance) {
192
207
  // A point shadow cannot outlive the finite light that casts it, so its
193
208
  // occlusion fades smoothly before the light's far plane.
194
- 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);
195
211
  } else {
196
212
  lit += 1.0;
197
213
  }
198
214
  }
199
- float shaded = lit / float(max(uShadowFilterTaps, 1));
200
- // Fade the whole shadow toward lit as the penumbra swallows it.
201
- return mix(1.0, shaded, strength);
215
+ return lit / float(max(uShadowFilterTaps, 1));
202
216
  }
203
217
 
204
218
  /**
@@ -289,10 +303,6 @@ float areaShadow(
289
303
  float widthX = min(halfX * spread, MAX_FILTER_RADIUS) + 0.012;
290
304
  float widthY = min(halfY * spread, MAX_FILTER_RADIUS) + 0.012;
291
305
 
292
- /* The narrower half extent, for the reason in the header: it is what keeps a strip's umbra. */
293
- float penumbra = clamp(min(halfX, halfY) * spread * PENUMBRA_FADE, 0.0, 1.0);
294
- float strength = 1.0 - penumbra * penumbra;
295
-
296
306
  /*
297
307
  * Turned inside the unit disk *before* the ellipse maps it, so the half-turn that separates
298
308
  * the two halves of a pixel pair survives the anisotropy. The same two-pixel tile the round
@@ -318,13 +328,15 @@ float areaShadow(
318
328
  float storedDistance = stored * far;
319
329
  if (dist - bias > storedDistance) {
320
330
  /* An area light's occlusion cannot outlive the map that carries it either. */
321
- 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));
322
335
  } else {
323
336
  lit += 1.0;
324
337
  }
325
338
  }
326
- float shaded = lit / float(max(uShadowFilterTaps, 1));
327
- return mix(1.0, shaded, strength);
339
+ return lit / float(max(uShadowFilterTaps, 1));
328
340
  }
329
341
  #endif
330
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];