@driftengine/core 4.8.6 → 4.8.7

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 (139) hide show
  1. package/CHANGELOG.json +71 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/render/anisotropicLobe.d.ts +10 -0
  6. package/dist/render/anisotropicLobe.js +19 -0
  7. package/dist/render/areaLights.d.ts +35 -0
  8. package/dist/render/areaLights.js +35 -0
  9. package/dist/render/backend/api.d.ts +33 -3
  10. package/dist/render/backend/webgl2/renderer.d.ts +50 -14
  11. package/dist/render/backend/webgl2/renderer.js +141 -30
  12. package/dist/render/backend/webgl2/sceneCaptureTarget.d.ts +29 -0
  13. package/dist/render/backend/webgl2/sceneCaptureTarget.js +96 -0
  14. package/dist/render/backend/webgpu/particlePass.js +2 -1
  15. package/dist/render/backend/webgpu/renderer.d.ts +35 -1
  16. package/dist/render/backend/webgpu/renderer.js +155 -16
  17. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  18. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  19. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  20. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  21. package/dist/render/backend/webgpu/surfaceTexturePass.js +34 -8
  22. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  23. package/dist/render/clusteredLights.d.ts +23 -5
  24. package/dist/render/clusteredLights.js +35 -8
  25. package/dist/render/driftLight/bake.d.ts +2 -0
  26. package/dist/render/driftLight/bake.js +1 -1
  27. package/dist/render/driftLight/falloff.d.ts +4 -2
  28. package/dist/render/driftLight/falloff.js +8 -2
  29. package/dist/render/gi/bounceLights.d.ts +3 -2
  30. package/dist/render/gi/bounceLights.js +8 -3
  31. package/dist/render/instanceCull.js +5 -0
  32. package/dist/render/instances.d.ts +19 -5
  33. package/dist/render/instances.js +9 -3
  34. package/dist/render/lightBudget.d.ts +12 -1
  35. package/dist/render/lightBudget.js +23 -3
  36. package/dist/render/lightmap.d.ts +23 -9
  37. package/dist/render/lightmap.js +40 -18
  38. package/dist/render/particlePool.d.ts +21 -0
  39. package/dist/render/particlePool.js +7 -0
  40. package/dist/render/pointLightSelection.d.ts +13 -0
  41. package/dist/render/pointLightSelection.js +3 -1
  42. package/dist/render/renderQuality.d.ts +8 -7
  43. package/dist/render/rendererHarness.js +3 -0
  44. package/dist/render/rgb9e5.d.ts +22 -0
  45. package/dist/render/rgb9e5.js +47 -0
  46. package/dist/render/sceneCapture.d.ts +29 -0
  47. package/dist/render/sceneCapture.js +36 -0
  48. package/dist/render/screenSpaceReflection.d.ts +27 -1
  49. package/dist/render/screenSpaceReflection.js +9 -0
  50. package/dist/render/shaders/bolt.d.ts +1 -1
  51. package/dist/render/shaders/bolt.js +7 -1
  52. package/dist/render/shaders/depth.js +11 -0
  53. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  54. package/dist/render/shaders/flat/anisotropic.js +37 -3
  55. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  56. package/dist/render/shaders/flat/areaLight.js +46 -0
  57. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  58. package/dist/render/shaders/flat/glassTint.js +3 -1
  59. package/dist/render/shaders/flat/index.d.ts +5 -0
  60. package/dist/render/shaders/flat/index.js +16 -2
  61. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  62. package/dist/render/shaders/flat/lobes.js +49 -43
  63. package/dist/render/shaders/flat/main.d.ts +1 -1
  64. package/dist/render/shaders/flat/main.js +57 -22
  65. package/dist/render/shaders/flat/models.d.ts +5 -2
  66. package/dist/render/shaders/flat/models.js +100 -16
  67. package/dist/render/shaders/flat/preamble.js +10 -3
  68. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  69. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  70. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  71. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  72. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -0
  73. package/dist/render/shaders/generated/flat.wgsl.js +54 -38
  74. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +11 -6
  75. package/dist/render/shaders/generated/ssrTrace.wgsl.js +11 -6
  76. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  77. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  78. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  79. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  80. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  81. package/dist/render/shaders/ssrTrace.js +20 -1
  82. package/dist/render/shaders/water.d.ts +1 -1
  83. package/dist/render/shaders/water.js +12 -3
  84. package/dist/render/skinScatter.d.ts +3 -1
  85. package/dist/render/skinScatter.js +3 -1
  86. package/dist/render/ssrPass.js +1 -0
  87. package/dist/render/surfaceModel.d.ts +29 -8
  88. package/dist/render/surfaceModel.js +33 -9
  89. package/dist/render/surfaceTexture.d.ts +25 -1
  90. package/dist/render/surfaceTexture.js +27 -5
  91. package/package.json +6 -6
  92. package/src/index.ts +6 -2
  93. package/src/render/anisotropicLobe.ts +29 -0
  94. package/src/render/areaLights.ts +58 -0
  95. package/src/render/backend/api.ts +49 -2
  96. package/src/render/backend/webgl2/renderer.ts +192 -38
  97. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  98. package/src/render/backend/webgpu/particlePass.ts +2 -1
  99. package/src/render/backend/webgpu/renderer.ts +194 -19
  100. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  101. package/src/render/backend/webgpu/surfaceTexturePass.ts +44 -14
  102. package/src/render/clusteredLights.ts +41 -8
  103. package/src/render/driftLight/bake.ts +3 -1
  104. package/src/render/driftLight/falloff.ts +13 -2
  105. package/src/render/gi/bounceLights.ts +12 -3
  106. package/src/render/instanceCull.ts +4 -0
  107. package/src/render/instances.ts +26 -6
  108. package/src/render/lightBudget.ts +40 -2
  109. package/src/render/lightmap.ts +64 -23
  110. package/src/render/particlePool.ts +23 -0
  111. package/src/render/pointLightSelection.ts +20 -1
  112. package/src/render/renderQuality.ts +8 -7
  113. package/src/render/rendererHarness.ts +3 -0
  114. package/src/render/rgb9e5.ts +52 -0
  115. package/src/render/sceneCapture.ts +56 -0
  116. package/src/render/screenSpaceReflection.ts +33 -1
  117. package/src/render/shaders/bolt.ts +7 -1
  118. package/src/render/shaders/depth.ts +11 -0
  119. package/src/render/shaders/flat/anisotropic.ts +37 -3
  120. package/src/render/shaders/flat/areaLight.ts +46 -0
  121. package/src/render/shaders/flat/glassTint.ts +9 -2
  122. package/src/render/shaders/flat/index.ts +21 -2
  123. package/src/render/shaders/flat/lobes.ts +50 -43
  124. package/src/render/shaders/flat/main.ts +57 -22
  125. package/src/render/shaders/flat/models.ts +100 -16
  126. package/src/render/shaders/flat/preamble.ts +10 -3
  127. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  128. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  129. package/src/render/shaders/generated/flat.wgsl.ts +54 -38
  130. package/src/render/shaders/generated/ssrTrace.wgsl.ts +11 -6
  131. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  132. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  133. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  134. package/src/render/shaders/ssrTrace.ts +20 -1
  135. package/src/render/shaders/water.ts +12 -3
  136. package/src/render/skinScatter.ts +3 -1
  137. package/src/render/ssrPass.ts +1 -0
  138. package/src/render/surfaceModel.ts +47 -10
  139. package/src/render/surfaceTexture.ts +69 -9
@@ -53,6 +53,29 @@ export interface ParticleInstances {
53
53
  capacity: number;
54
54
  }
55
55
 
56
+ /**
57
+ * Floats one particle takes in a buffer a caller's own compute writes (`DeviceParticles`), in this
58
+ * order: position 3, half-width 1, roll 1, colour 3, opacity 1, age 1, seed 1, velocity 3, a
59
+ * sprite's frame 1 and its half-height 1 — `ParticleInstances`' streams, interleaved as the pool's
60
+ * own upload interleaves them. 64 bytes a particle.
61
+ */
62
+ export const DEVICE_PARTICLE_FLOATS = 16;
63
+
64
+ /**
65
+ * Particles a caller's own compute shader wrote into a buffer of its own (`registerCompute`), drawn
66
+ * where they lie by `drawDeviceParticles` rather than read back and uploaded again. WebGPU only.
67
+ *
68
+ * **Not sorted**, as a pool's are where its batch asks: the device holds them, so an alpha-blended
69
+ * set that must be drawn far to near sorts itself. A slot with a half-width of 0 draws nothing, which
70
+ * is how a fixed-size buffer holds fewer live particles than it has room for.
71
+ */
72
+ export interface DeviceParticles {
73
+ /** A buffer with `VERTEX` usage, `DEVICE_PARTICLE_FLOATS` floats a particle. */
74
+ readonly buffer: GPUBuffer;
75
+ /** How many particles from its start to draw; no more than the buffer holds. */
76
+ readonly count: number;
77
+ }
78
+
56
79
  function createParticleInstances(capacity: number): ParticleInstances {
57
80
  return {
58
81
  positions: new Float32Array(capacity * 3),
@@ -1,6 +1,10 @@
1
1
  /** Allocation-free selection and packing of point lights for a fixed shader budget. */
2
2
 
3
- import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from './clusteredLights.ts';
3
+ import {
4
+ POINT_LIGHT_COS_INNER,
5
+ POINT_LIGHT_COS_OUTER,
6
+ lightFalloffExponentOf,
7
+ } from './clusteredLights.ts';
4
8
  import { MAX_POINT_LIGHTS, POINT_SHADOW_POOL } from './lightBudget.ts';
5
9
  import { gatherLights } from './lightGrid.ts';
6
10
  import type { LightGrid } from './lightGrid.ts';
@@ -198,6 +202,14 @@ export interface PointLightSource {
198
202
  * whose uniform budget has none to spare.
199
203
  */
200
204
  inLightField?: boolean;
205
+ /**
206
+ * How the light fades with distance when it names its own way: `(1 - (d/R)^2)^n` with no distance
207
+ * term, `R` its radius and `n` this. Unreal's falloff for a light without inverse-square falloff,
208
+ * so a rig authored there — a fill of brightness 160 across 164 m, a key at exponent 8 — lights
209
+ * the space it was authored for rather than one that ends a few metres out. Absent or 0, the
210
+ * frame's own falloff (`pointLightFalloff`), which is every light before 4.8.7.
211
+ */
212
+ falloffExponent?: number;
201
213
  }
202
214
 
203
215
  export interface PointLightBuffer {
@@ -256,6 +268,11 @@ export interface PointLightBuffer {
256
268
  readonly directions: Float32Array;
257
269
  /** Two per slot: the cosine of the inner cone angle, then of the outer. */
258
270
  readonly coneCos: Float32Array;
271
+ /**
272
+ * One per slot: the light's own falloff exponent, or 0 for the frame's falloff. See
273
+ * `PointLightSource.falloffExponent`.
274
+ */
275
+ readonly falloffExponents: Float32Array;
259
276
  /** One per slot: a row of the photometric atlas, or −1 for a light with no profile. */
260
277
  readonly iesProfiles: Float32Array;
261
278
  /** Internal insertion-sort storage; exposed only to keep one flat object. */
@@ -307,6 +324,7 @@ export function createPointLightBuffer(capacity: number = MAX_POINT_LIGHTS): Poi
307
324
  weights: new Float32Array(shaded),
308
325
  directions: new Float32Array(shaded * 3),
309
326
  coneCos: new Float32Array(shaded * 2),
327
+ falloffExponents: new Float32Array(shaded),
310
328
  /* −1 rather than 0, because row 0 is a real fixture and a light that asked for none must not
311
329
  be shaped by whichever profile happened to load first. */
312
330
  iesProfiles: new Float32Array(shaded).fill(-1),
@@ -587,6 +605,7 @@ function choose(
587
605
  * Restricting it to spots would be a smaller feature for no saving.
588
606
  */
589
607
  out.iesProfiles[slot] = light.iesProfile ?? -1;
608
+ out.falloffExponents[slot] = lightFalloffExponentOf(light.falloffExponent);
590
609
  }
591
610
  out.count = chosen;
592
611
 
@@ -219,13 +219,14 @@ export interface RenderQuality {
219
219
  * **`'pre-integrated'`, the default**: the lit stage's fit alone — a terminator softened and
220
220
  * reddened by the surface's curvature, light through thin parts, a penumbra each channel crosses
221
221
  * as far as it travels — at no cost beyond the skin pipeline itself. **`'screen-space'`** adds
222
- * Burley's blur in the picture as well: a skin draw writes its diffuse to a target of its own (it
223
- * is drawn twice to do so), which is spread across the pixels its scatter distance covers and
224
- * added back before anything blended is drawn, so light crosses a shadow's edge and a nostril's
225
- * rim in the picture rather than in a fit. See `skinBlur.ts`.
226
- *
227
- * Construction-time: two half-float targets the size of the frame, and a multisampled one under
228
- * multisampling, made at the first skin draw. It needs the composite (`screenEffects`), which is
222
+ * Burley's blur in the picture as well: a skin draw writes the light its diffuse is made of to a
223
+ * target of its own and its colour to another (it is drawn three times to do so), and the light is
224
+ * spread across the pixels its scatter distance covers, multiplied by the colour and added back
225
+ * before anything blended is drawn, so light crosses a shadow's edge and a nostril's rim in the
226
+ * picture rather than in a fit. See `skinBlur.ts`.
227
+ *
228
+ * Construction-time: three half-float targets the size of the frame, and two multisampled ones
229
+ * under multisampling, made at the first skin draw. It needs the composite (`screenEffects`), which is
229
230
  * where the frame is still linear light to add to; without one, skin is pre-integrated and the
230
231
  * renderer says so once. What would make the default wrong is a close-up face that reads as wax
231
232
  * at a shadow's edge — that is what the blur is for.
@@ -119,6 +119,9 @@ export function recordingGl(
119
119
  ONE: 1,
120
120
  ONE_MINUS_SRC_ALPHA: 0x0303,
121
121
  TRIANGLES: 0x0004,
122
+ /* The winding a pass names, so a test can tell which one it chose. */
123
+ CW: 0x0900,
124
+ CCW: 0x0901,
122
125
  TEXTURE0: 0x84c0,
123
126
  DEPTH_BUFFER_BIT: 0x00000100,
124
127
  DEPTH_TEST: 0x0b71,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * rgb9e5: three 9-bit mantissas over one shared 5-bit exponent, an unsigned float colour in four
3
+ * bytes. `EXT_texture_shared_exponent`'s encoding, the one a GPU's `rgb9e5ufloat` reads, written
4
+ * out so a lightmap page can hold its irradiance in four bytes a texel against a half float's eight.
5
+ *
6
+ * **What it gives up**: precision in the smaller channels, which share the largest one's exponent —
7
+ * a channel a thousandth of its neighbour keeps about one bit — and anything negative or past
8
+ * 65,408, which it clamps. Light is neither, and a bake's colours rarely span more than a few
9
+ * octaves within one texel, so a lightmap loses nothing visible; a value that needs each channel
10
+ * exact wants a format of its own.
11
+ *
12
+ * The bits, low to high: red in 0–8, green in 9–17, blue in 18–26, the exponent in 27–31, biased by
13
+ * 15, and a value is `mantissa * 2^(exponent - 15 - 9)`.
14
+ */
15
+
16
+ const BIAS = 15;
17
+ const MANTISSA_BITS = 9;
18
+ const MANTISSA_VALUES = 1 << MANTISSA_BITS;
19
+ const MAX_EXPONENT = 31;
20
+
21
+ /** The largest value the format holds: 511/512 of 2^16. */
22
+ export const RGB9E5_MAX = ((MANTISSA_VALUES - 1) / MANTISSA_VALUES) * 2 ** (MAX_EXPONENT - BIAS);
23
+
24
+ const clampChannel = (v: number): number => (v > 0 ? Math.min(v, RGB9E5_MAX) : 0);
25
+
26
+ /** One colour packed into its 32 bits, rounding to nearest; NaN and negatives are zero. */
27
+ export function packRgb9e5(red: number, green: number, blue: number): number {
28
+ const r = clampChannel(red);
29
+ const g = clampChannel(green);
30
+ const b = clampChannel(blue);
31
+ const largest = Math.max(r, g, b);
32
+ let exponent = Math.max(-BIAS - 1, Math.floor(Math.log2(largest))) + 1 + BIAS;
33
+ let scale = 2 ** (exponent - BIAS - MANTISSA_BITS);
34
+ /* Rounding the largest channel up to 512 needs one more exponent, which the spec allows for. */
35
+ if (Math.floor(largest / scale + 0.5) === MANTISSA_VALUES) {
36
+ exponent += 1;
37
+ scale *= 2;
38
+ }
39
+ /* Inline rather than a helper: a page packs millions of texels, and a closure a call is one each. */
40
+ const mr = Math.min(MANTISSA_VALUES - 1, Math.floor(r / scale + 0.5));
41
+ const mg = Math.min(MANTISSA_VALUES - 1, Math.floor(g / scale + 0.5));
42
+ const mb = Math.min(MANTISSA_VALUES - 1, Math.floor(b / scale + 0.5));
43
+ return (mr | (mg << 9) | (mb << 18) | (exponent << 27)) >>> 0;
44
+ }
45
+
46
+ /** A packed colour's three channels, into `out[at…at+2]`. */
47
+ export function unpackRgb9e5(bits: number, out: { [index: number]: number }, at = 0): void {
48
+ const scale = 2 ** ((bits >>> 27) - BIAS - MANTISSA_BITS);
49
+ out[at] = (bits & 511) * scale;
50
+ out[at + 1] = ((bits >>> 9) & 511) * scale;
51
+ out[at + 2] = ((bits >>> 18) & 511) * scale;
52
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A scene capture: a texture the world is drawn into from a camera of the caller's, which a material
3
+ * then shows like any image — a screen in the world showing what a camera elsewhere sees, a monitor,
4
+ * a security feed. The planar reflection's pass, for a caller's camera rather than a mirrored one.
5
+ *
6
+ * **It holds radiance, not display pixels**, as a probe does: drawn with the output transform held
7
+ * off, at the colour format a probe takes — half floats under `hdrScene`, bytes otherwise — and
8
+ * sampled linear, so a material showing it as its `emissive` emits the light the capture saw and the
9
+ * frame's grade then applies once, to the screen as to everything around it.
10
+ *
11
+ * **What it gives up**: it is the mesh pass alone, as a probe bake is — the sky, water, particles and
12
+ * anything else a scene draws after its world are in the capture only if the callback draws them;
13
+ * no temporal resolve, bloom or other effect after the world runs on it; and it has no mip chain, so
14
+ * a screen seen small and far shimmers where a mipmapped image would not.
15
+ */
16
+
17
+ /** What a capture's texture is made from, in place of an image: its size and colour format. */
18
+ export interface SceneCaptureTexels {
19
+ readonly kind: 'scene-capture';
20
+ readonly width: number;
21
+ readonly height: number;
22
+ /** Half floats, which hold light past one; bytes otherwise. */
23
+ readonly float: boolean;
24
+ }
25
+
26
+ /** The largest side a capture may have, the size every WebGL2 device must take. */
27
+ export const MAX_SCENE_CAPTURE_SIZE = 4096;
28
+
29
+ /** A capture's description, refused by name where a side is not a whole number of pixels it can be. */
30
+ export function sceneCaptureTexels(
31
+ width: number,
32
+ height: number,
33
+ float: boolean,
34
+ ): SceneCaptureTexels {
35
+ for (const [name, side] of [
36
+ ['width', width],
37
+ ['height', height],
38
+ ] as const) {
39
+ if (!Number.isInteger(side) || side < 1 || side > MAX_SCENE_CAPTURE_SIZE) {
40
+ throw new Error(
41
+ `createSceneCapture: ${name} must be a whole number from 1 to ${MAX_SCENE_CAPTURE_SIZE}, ` +
42
+ `not ${String(side)}`,
43
+ );
44
+ }
45
+ }
46
+ return { kind: 'scene-capture', width, height, float };
47
+ }
48
+
49
+ /** Whether a texture source is a capture's description rather than an image. */
50
+ export function isSceneCaptureTexels(source: unknown): source is SceneCaptureTexels {
51
+ return (
52
+ typeof source === 'object' &&
53
+ source !== null &&
54
+ (source as { kind?: unknown }).kind === 'scene-capture'
55
+ );
56
+ }
@@ -350,9 +350,30 @@ export function towardCameraFade(direction: ReadonlyVec3, viewDirection: Readonl
350
350
  return Math.min(1, Math.max(0, (along + 1) / 2));
351
351
  }
352
352
 
353
+ /**
354
+ * What a `ReflectiveSurface` is made with: its box, which way the surfaces inside it face, and how
355
+ * much and how far they reflect. Every number but the box has a default; see each field.
356
+ */
353
357
  export interface ReflectiveSurfaceOptions extends DecalBox {
354
- /** How much of what the ray finds lands on the surface, 0 to 1. */
358
+ /**
359
+ * How much of what the ray finds lands on the surface, 0 to 1: one share over the box, or with
360
+ * `fresnel` the surface's reflectance head-on.
361
+ */
355
362
  readonly strength?: number;
363
+ /**
364
+ * Whether `strength` is the surface's reflectance head-on, F0, rather than one share over the
365
+ * box. Each pixel then returns `F0 · A + B` of what its ray finds, the split-sum environment BRDF
366
+ * at its own view and `roughness` — Karis's fit, the one the lit stage reflects its environment
367
+ * by — so a floor returns more toward the horizon than underfoot, and the share follows the
368
+ * camera as Unreal weighs its reflections. **False by default**, the one share as before.
369
+ *
370
+ * **What it gives up**: the pass has no material buffer, so the roughness and F0 are the box's,
371
+ * not each pixel's, and nothing occludes the reflection where the lit stage's ambient occlusion
372
+ * would. A floor of one material is what it is for.
373
+ */
374
+ readonly fresnel?: boolean;
375
+ /** The surface's roughness, 0 to 1, that `fresnel` weighs by. 0, a polished surface, if absent. */
376
+ readonly roughness?: number;
356
377
  /** How far a ray may travel, world metres. */
357
378
  readonly reachM?: number;
358
379
  /** How deep a depth sample is treated as being. See `ScreenSpaceMarch.thicknessM`. */
@@ -382,6 +403,9 @@ export class ReflectiveSurface {
382
403
  readonly tint = new Float32Array(3);
383
404
 
384
405
  strength: number;
406
+ /** See `ReflectiveSurfaceOptions.fresnel`. */
407
+ fresnel: boolean;
408
+ roughness: number;
385
409
  reachM: number;
386
410
  thicknessM: number;
387
411
  steps: number;
@@ -405,6 +429,8 @@ export class ReflectiveSurface {
405
429
  /* Half, because a mirror is the rare case: a wet floor, polished stone and a car bonnet all
406
430
  return a fraction of what lands on them, and a consumer who wants a mirror asks for one. */
407
431
  this.strength = options.strength ?? 0.5;
432
+ this.fresnel = options.fresnel ?? false;
433
+ this.roughness = options.roughness ?? 0;
408
434
  /* Eight metres covers a room and a street corner. Further costs steps rather than accuracy,
409
435
  since the step is the reach divided by them. */
410
436
  this.reachM = options.reachM ?? 8;
@@ -469,6 +495,8 @@ export interface RecordedReflection {
469
495
  readonly axis: Float32Array;
470
496
  readonly tint: Float32Array;
471
497
  strength: number;
498
+ fresnel: boolean;
499
+ roughness: number;
472
500
  reachM: number;
473
501
  thicknessM: number;
474
502
  steps: number;
@@ -482,6 +510,8 @@ function newRecord(): RecordedReflection {
482
510
  axis: new Float32Array(3),
483
511
  tint: new Float32Array(3),
484
512
  strength: 0.5,
513
+ fresnel: false,
514
+ roughness: 0,
485
515
  reachM: 8,
486
516
  thicknessM: 0.25,
487
517
  steps: 24,
@@ -537,6 +567,8 @@ export class ReflectionQueue {
537
567
  record.axis.set(surface.axis);
538
568
  record.tint.set(surface.tint);
539
569
  record.strength = Math.min(1, Math.max(0, surface.strength));
570
+ record.fresnel = surface.fresnel;
571
+ record.roughness = Math.min(1, Math.max(0, surface.roughness));
540
572
  record.reachM = Math.max(0, surface.reachM);
541
573
  record.thicknessM = Math.max(0, surface.thicknessM);
542
574
  record.steps = Math.min(32, Math.max(1, Math.round(surface.steps)));
@@ -107,7 +107,13 @@ void main() {
107
107
  * the glow. A single falloff gives either a hard line with no atmosphere or a
108
108
  * soft smudge with no filament.
109
109
  */
110
- float across = 1.0 - abs(vSide);
110
+ /*
111
+ * Clamped, because a multisampled target shades a pixel on the quad's edge at its centre, which
112
+ * may lie outside the quad: there 'vSide' runs past one, 'across' goes negative, and 'pow' of a
113
+ * negative number is NaN. One NaN pixel is a dark speck; bloom then spreads it into a white disc
114
+ * the size of the blur. Found by a fight drawing short arcs at 4 samples.
115
+ */
116
+ float across = clamp(1.0 - abs(vSide), 0.0, 1.0);
111
117
  float core = pow(across, 10.0);
112
118
  /*
113
119
  * A *wide* halo, deliberately. This exponent is the single biggest control over
@@ -101,6 +101,12 @@ void main() {
101
101
  #endif
102
102
  #if INSTANCED
103
103
  mat4 model = mat4(aInstanceModel0, aInstanceModel1, aInstanceModel2, aInstanceModel3);
104
+ /* The bottom row is the instance's texture cell, rebuilt as the row it stands for: see flat. */
105
+ vec4 instanceCell = vec4(model[0].w, model[1].w, model[2].w, model[3].w);
106
+ model[0].w = 0.0;
107
+ model[1].w = 0.0;
108
+ model[2].w = 0.0;
109
+ model[3].w = 1.0;
104
110
  #else
105
111
  mat4 model = uModel;
106
112
  #endif
@@ -125,7 +131,12 @@ void main() {
125
131
  vLightPosition = uLightViewProj * vec4(bent, world.w);
126
132
  gl_Position = vLightPosition;
127
133
  #if CUTOUT
134
+ #if INSTANCED
135
+ /* A cutout instance's shadow is cut by its own cell, as its picture is. */
136
+ vUv = vec3((aUv.xy * instanceCell.xy + instanceCell.zw) * uUvScale + uUvOffset, aUv.z);
137
+ #else
128
138
  vUv = vec3(aUv.xy * uUvScale + uUvOffset, aUv.z);
139
+ #endif
129
140
  vAlphaCutout = uAlphaCutout;
130
141
  #endif
131
142
  #if GLASS
@@ -30,7 +30,16 @@ void anisotropicSurface() {
30
30
  direction.x * uModelParams[0].y - direction.y * uModelParams[0].z,
31
31
  direction.x * uModelParams[0].z + direction.y * uModelParams[0].y
32
32
  );
33
- aAlong = normalize(mTangent * turned.x + mBitangent * turned.y);
33
+ vec3 along = mTangent * turned.x + mBitangent * turned.y;
34
+ /*
35
+ * A mesh with neither tangents nor texture coordinates has no frame to take a direction from,
36
+ * and normalising the zero it gives was a NaN that drew the whole surface black. Any direction in
37
+ * the surface stands in, the one across the world's up, so the highlight is stretched somewhere.
38
+ */
39
+ if (dot(along, along) < 1e-12) {
40
+ along = cross(abs(mNormal.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0), mNormal);
41
+ }
42
+ aAlong = normalize(along);
34
43
  aAcross = cross(mNormal, aAlong);
35
44
  aStrength = strength;
36
45
  }
@@ -50,12 +59,37 @@ float anisotropicLobe(vec3 h, float sourceRadius, float dist) {
50
59
  return energy / max(d * d, 1e-8);
51
60
  }
52
61
 
62
+ /**
63
+ * The same stretch as GGX's own BRDF, \`π · D · Vis · N·L\`, for a surface whose highlight is
64
+ * physical: the distribution normalised, and Smith's height-correlated masking stretched with it
65
+ * (Heitz 2014, as Filament writes it). \`anisotropicPhysicalLobe\` in anisotropicLobe.ts.
66
+ */
67
+ float anisotropicPhysical(vec3 l, vec3 h, float ndl, float sourceRadius, float dist) {
68
+ float alpha = max(mRoughness * mRoughness, MIN_LOBE_ALPHA);
69
+ float alongWidth = alpha + (1.0 - alpha) * aStrength * aStrength;
70
+ float grow = sourceRadius / max(2.0 * dist, 1e-3);
71
+ float along = clamp(alongWidth + grow, alongWidth, 1.0);
72
+ float across = clamp(alpha + grow, alpha, 1.0);
73
+ float x = dot(aAlong, h) / along;
74
+ float y = dot(aAcross, h) / across;
75
+ float z = dot(mNormal, h);
76
+ float d = x * x + y * y + z * z;
77
+ float distribution = 1.0 / max(3.14159265 * along * across * d * d, 1e-8);
78
+ float ndv = max(dot(mNormal, mToEye), 1e-4);
79
+ float viewed = ndl * length(vec3(along * dot(aAlong, mToEye), across * dot(aAcross, mToEye), ndv));
80
+ float lit = ndv * length(vec3(along * dot(aAlong, l), across * dot(aAcross, l), ndl));
81
+ return 3.14159265 * distribution * (0.5 / max(viewed + lit, 1e-5)) * ndl;
82
+ }
83
+
53
84
  void anisotropicLight(vec3 l, float sourceRadius, float dist) {
54
85
  float ndl = max(dot(mNormal, l), 0.0);
55
86
  vec3 h = normalize(l + mToEye);
56
- vec3 tint = mix(mSpecColor, vec3(1.0), pow(1.0 - max(dot(mToEye, h), 0.0), 5.0) * mMetal);
87
+ vec3 tint = highlightTint(mSpecColor, max(dot(mToEye, h), 0.0), mMetal);
57
88
  mDiffuse = mAlbedo * ndl * (1.0 - mMetal);
58
- mSpecular = ndl > 0.0 ? tint * anisotropicLobe(h, sourceRadius, dist) : vec3(0.0);
89
+ float lobe = physicalSpecular()
90
+ ? anisotropicPhysical(l, h, ndl, sourceRadius, dist)
91
+ : anisotropicLobe(h, sourceRadius, dist);
92
+ mSpecular = ndl > 0.0 ? tint * lobe : vec3(0.0);
59
93
  }
60
94
 
61
95
  /* The environment's normal, bent toward the stretch by its strength and the surface's roughness. */
@@ -23,6 +23,52 @@ float areaSignedForm(vec3 n, vec3 centre, vec3 right, vec3 up, vec2 halfSize) {
23
23
  return quadFormFactor(n, vWorldPos, c0, c1, c2, c3);
24
24
  }
25
25
 
26
+ /**
27
+ * How far along one of a rectangle's axes a fragment still sees it past the barn door on that axis's
28
+ * positive edge: \`p\` the fragment's place along the axis, \`height\` its height above the
29
+ * rectangle's plane, \`halfSize\` the rectangle's half extent, and the door's tip \`tipOut\` beyond
30
+ * the edge and \`tipUp\` above the plane. Points of the rectangle past the answer are hidden.
31
+ *
32
+ * Above the tip, the tip's shadow on the plane as seen from the fragment is the limit. Below it, a
33
+ * fragment beside the door and outside its line is hidden from all of it, and one inside the door's
34
+ * reach is hidden from none of it.
35
+ */
36
+ float barnDoorLimit(float p, float height, float halfSize, float tipOut, float tipUp) {
37
+ float tipAt = halfSize + tipOut;
38
+ if (height > tipUp) return (height * tipAt - tipUp * p) / (height - tipUp);
39
+ if (p > halfSize && height * tipOut < tipUp * (p - halfSize)) return -1e9;
40
+ return 1e9;
41
+ }
42
+
43
+ /**
44
+ * The part of a rectangle a fragment sees past its barn doors, written into \`centre\` and
45
+ * \`halfSize\` as the smaller rectangle that is left, or false where the doors hide all of it.
46
+ * \`doors\` is the cosine of their angle from the normal and their length; a length of 0 is no doors.
47
+ * Each axis is clipped by its own two doors, which is what makes this four projections and a min.
48
+ */
49
+ bool areaBarnDoors(inout vec3 centre, vec3 right, vec3 up, inout vec2 halfSize, vec2 doors) {
50
+ if (doors.y <= 0.0) return true;
51
+ vec3 toFragment = vWorldPos - centre;
52
+ float height = dot(toFragment, cross(right, up));
53
+ /* Behind the emitting side: nothing to clip, and the form factor already answers it. */
54
+ if (height <= 0.0) return true;
55
+ float tipOut = doors.y * sqrt(max(1.0 - doors.x * doors.x, 0.0));
56
+ float tipUp = doors.y * doors.x;
57
+ vec2 p = vec2(dot(toFragment, right), dot(toFragment, up));
58
+ vec2 hi = vec2(
59
+ min(halfSize.x, barnDoorLimit(p.x, height, halfSize.x, tipOut, tipUp)),
60
+ min(halfSize.y, barnDoorLimit(p.y, height, halfSize.y, tipOut, tipUp))
61
+ );
62
+ vec2 lo = vec2(
63
+ max(-halfSize.x, -barnDoorLimit(-p.x, height, halfSize.x, tipOut, tipUp)),
64
+ max(-halfSize.y, -barnDoorLimit(-p.y, height, halfSize.y, tipOut, tipUp))
65
+ );
66
+ if (hi.x <= lo.x || hi.y <= lo.y) return false;
67
+ centre += right * (0.5 * (lo.x + hi.x)) + up * (0.5 * (lo.y + hi.y));
68
+ halfSize = 0.5 * (hi - lo);
69
+ return true;
70
+ }
71
+
26
72
  /**
27
73
  * What one rectangle of colour \`color\` and form factor \`form\` adds to the lamps' two sums, open
28
74
  * and shadowed by \`occl\` and \`glass\`.
@@ -29,7 +29,12 @@ vec4 glassUnmix(vec4 mip, float centreClarity) {
29
29
  * about which optional code a lit program carries. See `litSwitchesGlsl`.
30
30
  */
31
31
  export type LitSwitch =
32
- 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT';
32
+ | 'GLASS_SHADOWS'
33
+ | 'CLUSTERED_LIGHTS'
34
+ | 'LIGHT_FIXTURES'
35
+ | 'SURFACE_EFFECTS'
36
+ | 'DRIFT_LIGHT'
37
+ | 'PHYSICAL_SPECULAR';
33
38
 
34
39
  /** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
35
40
  export function noLitSwitches(): Record<LitSwitch, boolean> {
@@ -39,6 +44,7 @@ export function noLitSwitches(): Record<LitSwitch, boolean> {
39
44
  LIGHT_FIXTURES: false,
40
45
  SURFACE_EFFECTS: false,
41
46
  DRIFT_LIGHT: false,
47
+ PHYSICAL_SPECULAR: false,
42
48
  };
43
49
  }
44
50
 
@@ -61,7 +67,8 @@ export interface LitSwitches {
61
67
  * first uses them — a measured profile or a cookie, a material with an effects table, a DriftLight
62
68
  * volume — and then on for good; see `PipelineCache.enable` and the WebGL2 renderer's rebuild.
63
69
  * Measured on RADV against the lit shader with glass already out: 6,852 instructions and 120
64
- * registers, 4,641 and 96 with all four out.
70
+ * registers, 4,641 and 96 with all four out. `PHYSICAL_SPECULAR` is a sixth, declared with the
71
+ * models so that theirs keep their ids (`modelsGlsl`).
65
72
  */
66
73
  export function litSwitchesGlsl(switches: LitSwitches): string {
67
74
  return /* glsl */ `
@@ -14,7 +14,7 @@ import { CLOTH_BINDING_GLSL } from '../clothBinding.ts';
14
14
  import { SKINNING_GLSL } from '../skinning.ts';
15
15
  import { SURFACE_GLSL } from './surface.ts';
16
16
  import { MAIN_GLSL } from './main.ts';
17
- import { MODELS_GLSL } from './models.ts';
17
+ import { modelsGlsl } from './models.ts';
18
18
  import { AREA_LIGHT_GLSL } from './areaLight.ts';
19
19
  import { NORMAL_MAP_GLSL } from './normalMap.ts';
20
20
  import { DRIFT_LIGHT_GLSL } from './driftLight.ts';
@@ -245,6 +245,15 @@ void main() {
245
245
  #endif
246
246
  #if INSTANCED
247
247
  mat4 model = mat4(aInstanceModel0, aInstanceModel1, aInstanceModel2, aInstanceModel3);
248
+ /*
249
+ * **The bottom row is the instance's texture cell** (\`MeshInstances.uvRegions\`), which an
250
+ * affine placement leaves free: read, and rebuilt as the \`[0, 0, 0, 1]\` it stands for.
251
+ */
252
+ vec4 instanceCell = vec4(model[0].w, model[1].w, model[2].w, model[3].w);
253
+ model[0].w = 0.0;
254
+ model[1].w = 0.0;
255
+ model[2].w = 0.0;
256
+ model[3].w = 1.0;
248
257
  vec3 tint = aInstanceTint.rgb;
249
258
  if (LIGHTMAP_REGIONS) tint = vec3(1.0);
250
259
  #else
@@ -293,7 +302,12 @@ void main() {
293
302
  vColor = aColor * tint;
294
303
  vEmissive = aEmissive;
295
304
  vSpecular = aSpecular;
305
+ #if INSTANCED
306
+ /* The instance's cell first, then the material's scale and offset. */
307
+ vUv = vec3((aUv.xy * instanceCell.xy + instanceCell.zw) * uUvScale + uUvOffset, aUv.z);
308
+ #else
296
309
  vUv = vec3(aUv.xy * uUvScale + uUvOffset, aUv.z);
310
+ #endif
297
311
  vEmissiveColor = aEmissiveColor;
298
312
  vRoughness = aRoughness;
299
313
  vGrain = aGrain;
@@ -449,6 +463,11 @@ export interface FlatShaderOptions {
449
463
  readonly surfaceEffects?: boolean;
450
464
  /** Whether it reads a DriftLight volume. True when absent. See `litSwitchesGlsl`. */
451
465
  readonly driftLight?: boolean;
466
+ /**
467
+ * Whether it carries GGX's own highlight for a material that asks (`physicalSpecular`). True
468
+ * when absent. See `modelsGlsl`.
469
+ */
470
+ readonly physicalSpecular?: boolean;
452
471
  /**
453
472
  * How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
454
473
  *
@@ -593,7 +612,7 @@ export function flatFrag(options: FlatShaderOptions): string {
593
612
  DRIFT_LIGHT_GLSL,
594
613
  SURFACE_EFFECTS_GLSL,
595
614
  /* Depth zero and after the tangent frame and the lobes it calls; see models.ts. */
596
- MODELS_GLSL,
615
+ modelsGlsl(options.physicalSpecular ?? true),
597
616
  /* After the models, whose answer to a rectangle it asks for. See areaLight.ts. */
598
617
  AREA_LIGHT_GLSL,
599
618
  MAIN_GLSL,
@@ -30,6 +30,55 @@ export const EMISSIVE_SHADOW_SHARE = 0.55;
30
30
  */
31
31
  export const MIN_LOBE_ALPHA = 0.01;
32
32
 
33
+ /**
34
+ * Karis's fit to the split-sum environment BRDF, as GLSL: the lit stage's, and the screen-space
35
+ * reflection's when a surface asks for its Fresnel (`ReflectiveSurface.fresnel`). One text, so the
36
+ * two cannot drift.
37
+ */
38
+ export const ENV_BRDF_GLSL = `/**
39
+ * The other half of the split-sum approximation: what fraction of an environment a surface returns.
40
+ *
41
+ * The convolution decides *what* a surface reflects at a given roughness; this decides *how much*.
42
+ * Before it the amount was fresnel times one-minus-roughness, which is a curve somebody chose
43
+ * standing in for an integral.
44
+ *
45
+ * Karis's analytic fit to the environment BRDF, so there is **no lookup texture and no texture
46
+ * unit**: a few multiply-adds against roughness and the view angle. x scales the surface's own
47
+ * reflectance at normal incidence and y is the additive grazing term, so a smooth dielectric at
48
+ * f0 0.04 returns about 0.046 head-on and approaches 1 at the edge, which is what a dielectric
49
+ * does. At roughness 1 it returns about 0.016 rather than the zero one-minus-roughness gave, which
50
+ * is the substantive difference: a fully rough dielectric does reflect its surroundings.
51
+ *
52
+ * **It replaces the Fresnel factor as well as the thinning**, because the fit already contains the
53
+ * angular dependence; multiplying by a separate Fresnel term would apply it twice.
54
+ *
55
+ * **How much this is worth was measured, and it is worth less than it looks on a dark room.**
56
+ * On demo/dev/ibl.html?ladder=1 the whole ladder moved by at most 0.2 of 255 when the old term was
57
+ * replaced. That is not the term failing: at high roughness the surface reads the coarsest levels
58
+ * of the chain, and in a room of five dark walls and one bright one those levels are dark, so a
59
+ * weight three times larger against a near-zero radiance is still near zero. It is worth much more
60
+ * against a bright environment, which is what a loaded sky is and what no captured room here is.
61
+ *
62
+ * **A warning about reading that page, paid for once.** The ladder falls about five-fold from
63
+ * mirror to rough, and that reads as energy being lost. It is not: a mirror concentrates the one
64
+ * bright wall into a sharp image and a rough surface averages it against five dark ones, so the
65
+ * falloff is the room's own content. The normalised falloff also happens to track one-minus-
66
+ * roughness closely, which made the old term look like the cause — and removing that term entirely
67
+ * changed nothing, which is what settled it. Perturb before believing a match.
68
+ *
69
+ * **What it costs** is a fit rather than the integral: the published error is around one per cent
70
+ * of the tabulated term, far below what an eight-bit frame can show. **What would make it wrong**
71
+ * is a material model this shader does not have — a clearcoat or a sheen lobe carries its own BRDF
72
+ * and its own integral, and this fit is Trowbridge-Reitz with Smith masking and nothing else.
73
+ */
74
+ vec2 envBrdfApprox(float ndv, float roughness) {
75
+ const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
76
+ const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
77
+ vec4 r = roughness * c0 + c1;
78
+ float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y;
79
+ return vec2(-1.04, 1.04) * a004 + r.zw;
80
+ }`;
81
+
33
82
  export const LOBES_GLSL = `float shadowReach(float distance, float maxDistance) {
34
83
  return 1.0 - smoothstep(
35
84
  maxDistance * ${POINT_SHADOW_FADE_START.toFixed(2)},
@@ -156,49 +205,7 @@ float sphereLobe(float ndh, float roughness, float sourceRadius, float dist) {
156
205
  return energy * (w2 * w2) / max(d * d, 1e-8);
157
206
  }
158
207
 
159
- /**
160
- * The other half of the split-sum approximation: what fraction of an environment a surface returns.
161
- *
162
- * The convolution decides *what* a surface reflects at a given roughness; this decides *how much*.
163
- * Before it the amount was fresnel times one-minus-roughness, which is a curve somebody chose
164
- * standing in for an integral.
165
- *
166
- * Karis's analytic fit to the environment BRDF, so there is **no lookup texture and no texture
167
- * unit**: a few multiply-adds against roughness and the view angle. x scales the surface's own
168
- * reflectance at normal incidence and y is the additive grazing term, so a smooth dielectric at
169
- * f0 0.04 returns about 0.046 head-on and approaches 1 at the edge, which is what a dielectric
170
- * does. At roughness 1 it returns about 0.016 rather than the zero one-minus-roughness gave, which
171
- * is the substantive difference: a fully rough dielectric does reflect its surroundings.
172
- *
173
- * **It replaces the Fresnel factor as well as the thinning**, because the fit already contains the
174
- * angular dependence; multiplying by a separate Fresnel term would apply it twice.
175
- *
176
- * **How much this is worth was measured, and it is worth less than it looks on a dark room.**
177
- * On demo/dev/ibl.html?ladder=1 the whole ladder moved by at most 0.2 of 255 when the old term was
178
- * replaced. That is not the term failing: at high roughness the surface reads the coarsest levels
179
- * of the chain, and in a room of five dark walls and one bright one those levels are dark, so a
180
- * weight three times larger against a near-zero radiance is still near zero. It is worth much more
181
- * against a bright environment, which is what a loaded sky is and what no captured room here is.
182
- *
183
- * **A warning about reading that page, paid for once.** The ladder falls about five-fold from
184
- * mirror to rough, and that reads as energy being lost. It is not: a mirror concentrates the one
185
- * bright wall into a sharp image and a rough surface averages it against five dark ones, so the
186
- * falloff is the room's own content. The normalised falloff also happens to track one-minus-
187
- * roughness closely, which made the old term look like the cause — and removing that term entirely
188
- * changed nothing, which is what settled it. Perturb before believing a match.
189
- *
190
- * **What it costs** is a fit rather than the integral: the published error is around one per cent
191
- * of the tabulated term, far below what an eight-bit frame can show. **What would make it wrong**
192
- * is a material model this shader does not have — a clearcoat or a sheen lobe carries its own BRDF
193
- * and its own integral, and this fit is Trowbridge-Reitz with Smith masking and nothing else.
194
- */
195
- vec2 envBrdfApprox(float ndv, float roughness) {
196
- const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
197
- const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
198
- vec4 r = roughness * c0 + c1;
199
- float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y;
200
- return vec2(-1.04, 1.04) * a004 + r.zw;
201
- }
208
+ ${ENV_BRDF_GLSL}
202
209
 
203
210
  /**
204
211
  * How much of a prefiltered environment a surface returns, with the light that bounces more than