@driftengine/core 4.8.3 → 4.8.4

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 (178) hide show
  1. package/CHANGELOG.json +71 -0
  2. package/README.md +1 -1
  3. package/dist/geometry/meshConcat.js +2 -0
  4. package/dist/index.d.ts +10 -4
  5. package/dist/index.js +16 -1
  6. package/dist/math/scalar.js +1 -1
  7. package/dist/render/anisotropicLobe.d.ts +7 -0
  8. package/dist/render/anisotropicLobe.js +33 -0
  9. package/dist/render/backend/api.d.ts +41 -4
  10. package/dist/render/backend/select.js +8 -0
  11. package/dist/render/backend/webgl2/clothTextures.d.ts +27 -0
  12. package/dist/render/backend/webgl2/clothTextures.js +93 -0
  13. package/dist/render/backend/webgl2/renderer.d.ts +136 -3
  14. package/dist/render/backend/webgl2/renderer.js +604 -63
  15. package/dist/render/backend/webgl2/skinScatter.d.ts +50 -0
  16. package/dist/render/backend/webgl2/skinScatter.js +256 -0
  17. package/dist/render/backend/webgpu/buffers.d.ts +25 -11
  18. package/dist/render/backend/webgpu/buffers.js +98 -49
  19. package/dist/render/backend/webgpu/clothSolvePack.d.ts +27 -0
  20. package/dist/render/backend/webgpu/clothSolvePack.js +129 -0
  21. package/dist/render/backend/webgpu/clothTextures.d.ts +68 -0
  22. package/dist/render/backend/webgpu/clothTextures.js +145 -0
  23. package/dist/render/backend/webgpu/compressedUpload.d.ts +18 -0
  24. package/dist/render/backend/webgpu/compressedUpload.js +38 -0
  25. package/dist/render/backend/webgpu/computeHost.d.ts +21 -0
  26. package/dist/render/backend/webgpu/computeHost.js +5 -0
  27. package/dist/render/backend/webgpu/depthPass.d.ts +16 -6
  28. package/dist/render/backend/webgpu/depthPass.js +39 -9
  29. package/dist/render/backend/webgpu/drawCommand.js +6 -2
  30. package/dist/render/backend/webgpu/flatPass.d.ts +38 -4
  31. package/dist/render/backend/webgpu/flatPass.js +109 -12
  32. package/dist/render/backend/webgpu/gpuSkinnedCloth.d.ts +74 -0
  33. package/dist/render/backend/webgpu/gpuSkinnedCloth.js +452 -0
  34. package/dist/render/backend/webgpu/motionPass.d.ts +20 -0
  35. package/dist/render/backend/webgpu/motionPass.js +106 -9
  36. package/dist/render/backend/webgpu/postPass.d.ts +3 -1
  37. package/dist/render/backend/webgpu/postPass.js +6 -3
  38. package/dist/render/backend/webgpu/renderer.d.ts +113 -5
  39. package/dist/render/backend/webgpu/renderer.js +498 -48
  40. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +33 -0
  41. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +469 -0
  42. package/dist/render/backend/webgpu/skinScatterPass.d.ts +51 -0
  43. package/dist/render/backend/webgpu/skinScatterPass.js +253 -0
  44. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  45. package/dist/render/backend/webgpu/surfaceTexturePass.js +37 -12
  46. package/dist/render/clothBindingData.d.ts +41 -0
  47. package/dist/render/clothBindingData.js +72 -0
  48. package/dist/render/clothPlacement.d.ts +25 -0
  49. package/dist/render/clothPlacement.js +85 -0
  50. package/dist/render/compressedSource.d.ts +66 -0
  51. package/dist/render/compressedSource.js +138 -0
  52. package/dist/render/cutoutCaster.d.ts +2 -0
  53. package/dist/render/cutoutCaster.js +2 -0
  54. package/dist/render/cutoutDither.d.ts +51 -0
  55. package/dist/render/cutoutDither.js +90 -0
  56. package/dist/render/eyeRefraction.d.ts +35 -0
  57. package/dist/render/eyeRefraction.js +40 -0
  58. package/dist/render/glCompressed.d.ts +27 -0
  59. package/dist/render/glCompressed.js +53 -0
  60. package/dist/render/hairLobes.d.ts +70 -0
  61. package/dist/render/hairLobes.js +120 -0
  62. package/dist/render/lightBudget.d.ts +22 -0
  63. package/dist/render/lightBudget.js +22 -0
  64. package/dist/render/mesh.d.ts +7 -0
  65. package/dist/render/mesh.js +24 -0
  66. package/dist/render/recon/motionVectors.d.ts +4 -2
  67. package/dist/render/recon/motionVectors.js +6 -3
  68. package/dist/render/renderQuality.d.ts +20 -0
  69. package/dist/render/renderQuality.js +5 -0
  70. package/dist/render/rendererHarness.js +4 -0
  71. package/dist/render/sceneTarget.d.ts +12 -0
  72. package/dist/render/sceneTarget.js +14 -0
  73. package/dist/render/shaders/clothBinding.d.ts +22 -0
  74. package/dist/render/shaders/clothBinding.js +80 -0
  75. package/dist/render/shaders/depth.js +42 -6
  76. package/dist/render/shaders/flat/anisotropic.d.ts +15 -0
  77. package/dist/render/shaders/flat/anisotropic.js +68 -0
  78. package/dist/render/shaders/flat/eye.d.ts +1 -0
  79. package/dist/render/shaders/flat/eye.js +81 -0
  80. package/dist/render/shaders/flat/hair.d.ts +1 -0
  81. package/dist/render/shaders/flat/hair.js +101 -0
  82. package/dist/render/shaders/flat/index.js +36 -2
  83. package/dist/render/shaders/flat/main.d.ts +1 -1
  84. package/dist/render/shaders/flat/main.js +113 -17
  85. package/dist/render/shaders/flat/models.d.ts +14 -0
  86. package/dist/render/shaders/flat/models.js +273 -0
  87. package/dist/render/shaders/flat/preamble.js +26 -24
  88. package/dist/render/shaders/flat/skin.d.ts +1 -0
  89. package/dist/render/shaders/flat/skin.js +114 -0
  90. package/dist/render/shaders/generated/depth.wgsl.d.ts +38 -16
  91. package/dist/render/shaders/generated/depth.wgsl.js +38 -16
  92. package/dist/render/shaders/generated/flat.wgsl.d.ts +1790 -1704
  93. package/dist/render/shaders/generated/flat.wgsl.js +1828 -1742
  94. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +48 -0
  95. package/dist/render/shaders/generated/skinBlur.wgsl.js +54 -0
  96. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  97. package/dist/render/shaders/recon/motion.wgsl.js +102 -0
  98. package/dist/render/shaders/skinBlur.d.ts +1 -0
  99. package/dist/render/shaders/skinBlur.js +103 -0
  100. package/dist/render/shaders/skinning.d.ts +3 -1
  101. package/dist/render/shaders/skinning.js +31 -4
  102. package/dist/render/shadowCasters.d.ts +13 -1
  103. package/dist/render/skinBlur.d.ts +41 -0
  104. package/dist/render/skinBlur.js +49 -0
  105. package/dist/render/skinScatter.d.ts +67 -0
  106. package/dist/render/skinScatter.js +80 -0
  107. package/dist/render/skinnedClothRun.d.ts +26 -0
  108. package/dist/render/skinnedClothRun.js +84 -0
  109. package/dist/render/surfaceModel.d.ts +129 -0
  110. package/dist/render/surfaceModel.js +152 -0
  111. package/dist/render/surfaceTexture.d.ts +44 -4
  112. package/dist/render/surfaceTexture.js +60 -46
  113. package/dist/render/vertexDefaults.js +7 -0
  114. package/package.json +6 -6
  115. package/src/geometry/meshConcat.ts +2 -0
  116. package/src/index.ts +40 -1
  117. package/src/math/scalar.ts +1 -1
  118. package/src/render/anisotropicLobe.ts +41 -0
  119. package/src/render/backend/api.ts +52 -3
  120. package/src/render/backend/select.ts +8 -0
  121. package/src/render/backend/webgl2/clothTextures.ts +120 -0
  122. package/src/render/backend/webgl2/renderer.ts +693 -64
  123. package/src/render/backend/webgl2/skinScatter.ts +309 -0
  124. package/src/render/backend/webgpu/buffers.ts +137 -63
  125. package/src/render/backend/webgpu/clothSolvePack.ts +161 -0
  126. package/src/render/backend/webgpu/clothTextures.ts +194 -0
  127. package/src/render/backend/webgpu/compressedUpload.ts +65 -0
  128. package/src/render/backend/webgpu/computeHost.ts +25 -0
  129. package/src/render/backend/webgpu/depthPass.ts +39 -7
  130. package/src/render/backend/webgpu/drawCommand.ts +6 -2
  131. package/src/render/backend/webgpu/flatPass.ts +145 -7
  132. package/src/render/backend/webgpu/gpuSkinnedCloth.ts +502 -0
  133. package/src/render/backend/webgpu/motionPass.ts +128 -9
  134. package/src/render/backend/webgpu/postPass.ts +5 -1
  135. package/src/render/backend/webgpu/renderer.ts +663 -43
  136. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +470 -0
  137. package/src/render/backend/webgpu/skinScatterPass.ts +309 -0
  138. package/src/render/backend/webgpu/surfaceTexturePass.ts +42 -12
  139. package/src/render/clothBindingData.ts +89 -0
  140. package/src/render/clothPlacement.ts +114 -0
  141. package/src/render/compressedSource.ts +198 -0
  142. package/src/render/cutoutCaster.ts +5 -0
  143. package/src/render/cutoutDither.ts +99 -0
  144. package/src/render/eyeRefraction.ts +72 -0
  145. package/src/render/glCompressed.ts +86 -0
  146. package/src/render/hairLobes.ts +188 -0
  147. package/src/render/lightBudget.ts +25 -0
  148. package/src/render/mesh.ts +23 -0
  149. package/src/render/recon/motionVectors.ts +6 -3
  150. package/src/render/renderQuality.ts +28 -0
  151. package/src/render/rendererHarness.ts +4 -0
  152. package/src/render/sceneTarget.ts +21 -0
  153. package/src/render/shaders/clothBinding.ts +81 -0
  154. package/src/render/shaders/depth.ts +42 -6
  155. package/src/render/shaders/flat/anisotropic.ts +68 -0
  156. package/src/render/shaders/flat/eye.ts +82 -0
  157. package/src/render/shaders/flat/hair.ts +102 -0
  158. package/src/render/shaders/flat/index.ts +36 -2
  159. package/src/render/shaders/flat/main.ts +113 -17
  160. package/src/render/shaders/flat/models.ts +286 -0
  161. package/src/render/shaders/flat/preamble.ts +26 -24
  162. package/src/render/shaders/flat/skin.ts +116 -0
  163. package/src/render/shaders/generated/depth.wgsl.ts +38 -16
  164. package/src/render/shaders/generated/flat.wgsl.ts +1828 -1742
  165. package/src/render/shaders/generated/skinBlur.wgsl.ts +56 -0
  166. package/src/render/shaders/recon/motion.wgsl.ts +102 -0
  167. package/src/render/shaders/skinBlur.ts +105 -0
  168. package/src/render/shaders/skinning.ts +31 -4
  169. package/src/render/shadowCasters.ts +13 -0
  170. package/src/render/skinBlur.ts +53 -0
  171. package/src/render/skinScatter.ts +92 -0
  172. package/src/render/skinnedClothRun.ts +128 -0
  173. package/src/render/surfaceModel.ts +274 -0
  174. package/src/render/surfaceTexture.ts +123 -63
  175. package/src/render/vertexDefaults.ts +7 -0
  176. package/dist/math/exact.d.ts +0 -76
  177. package/dist/math/exact.js +0 -327
  178. package/src/math/exact.ts +0 -340
@@ -0,0 +1,198 @@
1
+ /**
2
+ * A block-compressed image a caller hands to `createSurfaceTexture`, and the one decision about it
3
+ * both backends share: whether this device takes it as blocks.
4
+ *
5
+ * **Uploaded as it is, or not at all.** A BC texture costs a byte a texel on the GPU (BC7) or half
6
+ * of one (BC1) against four for RGBA, and keeps the mip chain its author built rather than one a box
7
+ * filter makes. A device that cannot sample the format — most phones, which have ASTC and ETC2 —
8
+ * gets nothing from the blocks, and the engine does not decode them here: core ships no decoder.
9
+ * The loader in `@driftengine/assets` asks `uploadsCompressed` first and decodes at load where the
10
+ * answer is no; a caller handing a source this device cannot take is refused by name.
11
+ *
12
+ * Backend-neutral, because both backends must agree on what they accept before either touches a
13
+ * device. `BlockFormat` is the same union `@driftengine/drft` names `BcFormat`; core imports nothing
14
+ * from that package, and a format added there without a twin here fails where the loader passes one
15
+ * to the other, at compile time.
16
+ */
17
+
18
+ /** BC1 to BC5 and BC7. BC6H and the signed forms are not among them. */
19
+ export type BlockFormat = 'bc1' | 'bc2' | 'bc3' | 'bc4' | 'bc5' | 'bc7';
20
+
21
+ /** A format as a device offers it: the colour space is part of the name, as WebGPU's are. */
22
+ export type CompressedTextureFormat =
23
+ 'bc1' | 'bc1-srgb' | 'bc2' | 'bc2-srgb' | 'bc3' | 'bc3-srgb' | 'bc4' | 'bc5' | 'bc7' | 'bc7-srgb';
24
+
25
+ /** Block-compressed texels with their stored mip chain, level 0 first. */
26
+ export interface CompressedTextureSource {
27
+ readonly format: BlockFormat;
28
+ readonly width: number;
29
+ readonly height: number;
30
+ readonly levels: readonly Uint8Array[];
31
+ }
32
+
33
+ /** What `createSurfaceTexture` takes: an image, or blocks. */
34
+ export type SurfaceSource = TexImageSource | CompressedTextureSource;
35
+
36
+ /**
37
+ * The layers as blocks, or null where every one is an image. An array mixing the two is refused:
38
+ * one GPU texture has one format.
39
+ */
40
+ export function compressedLayers(
41
+ sources: readonly SurfaceSource[],
42
+ ): readonly CompressedTextureSource[] | null {
43
+ let count = 0;
44
+ for (const source of sources) if (isCompressedSource(source)) count++;
45
+ if (count === 0) return null;
46
+ if (count !== sources.length) {
47
+ throw new Error(
48
+ 'createSurfaceTextureArray: some layers are blocks and some are images, and one texture has ' +
49
+ 'one format. Decode the blocks, or build the array from blocks alone.',
50
+ );
51
+ }
52
+ return sources as readonly CompressedTextureSource[];
53
+ }
54
+
55
+ /** Bytes per 4x4 block. */
56
+ export const BLOCK_BYTES: Readonly<Record<BlockFormat, number>> = {
57
+ bc1: 8,
58
+ bc2: 16,
59
+ bc3: 16,
60
+ bc4: 8,
61
+ bc5: 16,
62
+ bc7: 16,
63
+ };
64
+
65
+ /** Whether `source` is a compressed source rather than an image. */
66
+ export function isCompressedSource(source: unknown): source is CompressedTextureSource {
67
+ const candidate = source as { format?: unknown; levels?: unknown } | null;
68
+ return (
69
+ typeof candidate === 'object' &&
70
+ candidate !== null &&
71
+ typeof candidate.format === 'string' &&
72
+ Array.isArray(candidate.levels)
73
+ );
74
+ }
75
+
76
+ /** The format a device would have to offer, or null for one with no sRGB form. */
77
+ export function compressedFormatName(
78
+ format: BlockFormat,
79
+ srgb: boolean,
80
+ ): CompressedTextureFormat | null {
81
+ if (!srgb) return format;
82
+ return format === 'bc4' || format === 'bc5' ? null : `${format}-srgb`;
83
+ }
84
+
85
+ /**
86
+ * Whether a `width` by `height` source in `format` uploads as blocks on a device offering
87
+ * `available`. Level 0 must be whole blocks each way: WebGPU refuses any other size for a BC
88
+ * texture, and WebGL2's S3TC extension says the same, so a 6x6 image is decoded rather than
89
+ * refused. What would make that wrong is a device that pads; none is relied on here.
90
+ */
91
+ export function uploadsCompressed(
92
+ format: BlockFormat,
93
+ srgb: boolean,
94
+ width: number,
95
+ height: number,
96
+ available: readonly CompressedTextureFormat[],
97
+ ): boolean {
98
+ const name = compressedFormatName(format, srgb);
99
+ return name !== null && width % 4 === 0 && height % 4 === 0 && available.includes(name);
100
+ }
101
+
102
+ /** The bytes level `level` of a `width` by `height` image takes: whole blocks, one at least. */
103
+ function levelBytes(format: BlockFormat, width: number, height: number, level: number): number {
104
+ const w = Math.max(1, width >> level);
105
+ const h = Math.max(1, height >> level);
106
+ return Math.ceil(w / 4) * Math.ceil(h / 4) * BLOCK_BYTES[format];
107
+ }
108
+
109
+ /**
110
+ * How many levels the layers carry, after checking each is the size its place requires and every
111
+ * layer matches the first. Refuses by name: a short level uploaded anyway is a device error at
112
+ * submit, which on WebGPU is a frame that draws nothing and says so somewhere else.
113
+ */
114
+ export function compressedLevels(layers: readonly CompressedTextureSource[]): number {
115
+ const first = layers[0];
116
+ if (first === undefined)
117
+ throw new Error('createSurfaceTexture: an array needs at least one image');
118
+ const { format, width, height } = first;
119
+ const count = first.levels.length;
120
+ const most = Math.floor(Math.log2(Math.max(1, width, height))) + 1;
121
+ if (count < 1 || count > most) {
122
+ throw new Error(
123
+ `createSurfaceTexture: ${count} levels for a ${width}x${height} image, which has at least 1 ` +
124
+ `and at most ${most}`,
125
+ );
126
+ }
127
+ for (let layer = 0; layer < layers.length; layer++) {
128
+ const source = layers[layer] as CompressedTextureSource;
129
+ if (
130
+ source.format !== format ||
131
+ source.width !== width ||
132
+ source.height !== height ||
133
+ source.levels.length !== count
134
+ ) {
135
+ throw new Error(
136
+ `createSurfaceTexture: layer ${layer} is ${source.width}x${source.height} ` +
137
+ `${source.format.toUpperCase()} with ${source.levels.length} levels, and layer 0 is ` +
138
+ `${width}x${height} ${format.toUpperCase()} with ${count}. Every layer of an array is one ` +
139
+ 'format, size and chain on the device.',
140
+ );
141
+ }
142
+ for (let level = 0; level < count; level++) {
143
+ const want = levelBytes(format, width, height, level);
144
+ const got = (source.levels[level] as Uint8Array).length;
145
+ if (got !== want) {
146
+ throw new Error(
147
+ `createSurfaceTexture: level ${level} of a ${width}x${height} ${format.toUpperCase()} ` +
148
+ `image is ${want} bytes, and ${got} were given`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+ return count;
154
+ }
155
+
156
+ /** What a refusal names: the format, its colour space, and what the device offers instead. */
157
+ export function compressedRefusal(
158
+ format: BlockFormat,
159
+ srgb: boolean,
160
+ width: number,
161
+ height: number,
162
+ available: readonly CompressedTextureFormat[],
163
+ ): string {
164
+ const asked = `${format.toUpperCase()}${srgb ? ' sRGB' : ''} at ${width}x${height}`;
165
+ const offered = available.length === 0 ? 'no BC format' : available.join(', ');
166
+ return (
167
+ `createSurfaceTexture: this device cannot take ${asked} as blocks — it offers ${offered}, and ` +
168
+ 'a BC texture must be whole 4x4 blocks at level 0. Decode it first (decodeBc in ' +
169
+ '@driftengine/assets), or ask renderer.compressedFormats before choosing.'
170
+ );
171
+ }
172
+
173
+ /** The format name and level count blocks upload as here, or a refusal naming why they cannot. */
174
+ export function planBlocks(
175
+ blocks: readonly CompressedTextureSource[],
176
+ srgb: boolean,
177
+ compressed: readonly CompressedTextureFormat[],
178
+ ): { readonly name: CompressedTextureFormat; readonly levels: number } {
179
+ const { format, width, height } = blocks[0] as CompressedTextureSource;
180
+ const name = compressedFormatName(format, srgb);
181
+ if (name === null || !uploadsCompressed(format, srgb, width, height, compressed)) {
182
+ throw new Error(compressedRefusal(format, srgb, width, height, compressed));
183
+ }
184
+ return { name, levels: compressedLevels(blocks) };
185
+ }
186
+
187
+ /**
188
+ * `update` replaces pixels with an image, and a texture holding blocks has no pixels to replace:
189
+ * its format is the blocks', and an image would be a different texture. Refused rather than
190
+ * quietly reallocated, so the handle a draw holds keeps meaning what it meant.
191
+ */
192
+ export function refuseBlockUpdate(format: BlockFormat | null): void {
193
+ if (format === null) return;
194
+ throw new Error(
195
+ `updateSurfaceTexture: this texture holds ${format.toUpperCase()} blocks, and update replaces ` +
196
+ 'an image. Build a new texture for new contents.',
197
+ );
198
+ }
@@ -17,6 +17,8 @@ import type { SurfaceMaterial } from './surfaceTexture.ts';
17
17
  export interface CutoutCaster<Texture> {
18
18
  readonly albedo: Texture;
19
19
  readonly cutoff: number;
20
+ /** 1 for a dithered edge, which a caster draws with a pattern that does not move. */
21
+ readonly dithered: number;
20
22
  readonly u: number;
21
23
  readonly v: number;
22
24
  readonly uOffset: number;
@@ -26,6 +28,7 @@ export interface CutoutCaster<Texture> {
26
28
  const answer: {
27
29
  albedo: unknown;
28
30
  cutoff: number;
31
+ dithered: number;
29
32
  u: number;
30
33
  v: number;
31
34
  uOffset: number;
@@ -33,6 +36,7 @@ const answer: {
33
36
  } = {
34
37
  albedo: null,
35
38
  cutoff: 0,
39
+ dithered: 0,
36
40
  u: 1,
37
41
  v: 1,
38
42
  uOffset: 0,
@@ -49,6 +53,7 @@ export function cutoutOf<Texture>(
49
53
  if (albedo === null || !(cutoff > 0)) return null;
50
54
  answer.albedo = albedo;
51
55
  answer.cutoff = cutoff;
56
+ answer.dithered = material.cutoutMode === 'dithered' ? 1 : 0;
52
57
  answer.u = material.uScale ?? 1;
53
58
  answer.v = material.vScale ?? 1;
54
59
  answer.uOffset = material.uOffset ?? 0;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * A cutout whose edge is a share of the pixel rather than a line through it.
3
+ *
4
+ * **Why.** A hard alpha test draws a strand of hair, an eyelash or a fringe of leaves as a stair of
5
+ * whole pixels: a card's alpha falls off over a few texels and the test turns that into on and off.
6
+ * A dithered cutout keeps each pixel with a probability, the share of it the texture covers, and
7
+ * something downstream then averages the pattern back into coverage. What can average it depends on
8
+ * the frame, so the material asks for the smooth edge and the renderer decides, per frame, how:
9
+ *
10
+ * - **A temporal resolve (TAA or DriftTR)**: the pixel is kept against a threshold that moves every
11
+ * frame, and the resolve integrates the pattern over its history into coverage.
12
+ * - **A multisampled frame**: the share goes to the GPU as alpha-to-coverage, which turns it into
13
+ * that many of the pixel's samples.
14
+ * - **Neither**: the hard test, because a dither nothing resolves is grain on the screen.
15
+ *
16
+ * **What it gives up**: a dithered edge under a temporal resolve is soft by one history's worth of
17
+ * blur and ghosts slightly behind a fast card, which is the resolve's own trade; and under
18
+ * multisampling the share is quantised to the sample count, five levels at four samples. **What
19
+ * would make it wrong** is a pass that tests depth for equality against the main pass and discards
20
+ * differently from it; the caster and the lit pass take their keep from the same two functions here.
21
+ */
22
+
23
+ /** What a material asks for. `'hard'` is every cutout that shipped before this. */
24
+ export type CutoutMode = 'hard' | 'dithered';
25
+
26
+ /** What the frame does with it: 0, 1 and 2 in the shader's `uCutout.y`. */
27
+ export type CutoutResolve = 'hard' | 'dither' | 'coverage';
28
+
29
+ /** The shader's number for a resolve. */
30
+ export const CUTOUT_RESOLVE_CODE: Readonly<Record<CutoutResolve, number>> = Object.freeze({
31
+ hard: 0,
32
+ dither: 1,
33
+ coverage: 2,
34
+ });
35
+
36
+ /**
37
+ * How a cutout is drawn this frame. A temporal resolve is preferred over multisampling when a frame
38
+ * has both, because it resolves every share and multisampling only the sample count's.
39
+ */
40
+ export function resolveCutout(mode: CutoutMode, temporal: boolean, samples: number): CutoutResolve {
41
+ if (mode !== 'dithered') return 'hard';
42
+ if (temporal) return 'dither';
43
+ if (samples > 1) return 'coverage';
44
+ return 'hard';
45
+ }
46
+
47
+ /**
48
+ * The share of a pixel a cutout keeps, from its alpha: half at the cutoff, ramping over a band
49
+ * twice the distance to the nearer end. At a cutoff of a half that is alpha itself.
50
+ */
51
+ export function cutoutShare(alpha: number, cutoff: number): number {
52
+ const band = Math.max(2 * Math.min(cutoff, 1 - cutoff), 1e-4);
53
+ return Math.min(Math.max((alpha - cutoff) / band + 0.5, 0), 1);
54
+ }
55
+
56
+ /**
57
+ * The temporal step of the noise below, per frame: Jimenez's constant (2014), which moves the
58
+ * pattern across the tile rather than stepping each pixel's threshold.
59
+ *
60
+ * **A golden-ratio step per pixel was tried and measured, and it is not better.** It spreads a still
61
+ * pixel's thresholds evenly over time, which reads like the cure for grain; on a card of strands
62
+ * under TAA it left 12.9 levels of grain against this one's 11.7. The floor is the resolve's own: it
63
+ * keeps nine tenths of its history, so the newest frame's kept-or-not pattern is a tenth of the
64
+ * picture, about ten levels of grain at a half share whatever sequence chose it.
65
+ */
66
+ const NOISE_FRAME_STEP = 5.588238;
67
+
68
+ /**
69
+ * The threshold a pixel's share is compared against: interleaved gradient noise (Jimenez,
70
+ * "Next Generation Post Processing in Call of Duty: Advanced Warfare", 2014), offset by the frame so
71
+ * the pattern moves. `x` and `y` are pixel centres, as `gl_FragCoord.xy` is.
72
+ */
73
+ export function ditherThreshold(x: number, y: number, frame: number): number {
74
+ const px = x + NOISE_FRAME_STEP * frame;
75
+ const py = y + NOISE_FRAME_STEP * frame;
76
+ const inner = 0.06711056 * px + 0.00583715 * py;
77
+ const f = 52.9829189 * (inner - Math.floor(inner));
78
+ return f - Math.floor(f);
79
+ }
80
+
81
+ /**
82
+ * The two functions in GLSL, for the lit pass and every caster that keeps the same pixels.
83
+ *
84
+ * `cutoutKeeps(share, pixel, frame)` is the dithered test; `cutoutShare(alpha, cutoff)` is also what
85
+ * a coverage frame writes as alpha. Frame is a float the renderer counts and wraps, so the noise's
86
+ * argument stays small enough for a half-precision device.
87
+ */
88
+ export const CUTOUT_DITHER_GLSL = `
89
+ float cutoutShare(float alpha, float cutoff) {
90
+ float band = max(2.0 * min(cutoff, 1.0 - cutoff), 1e-4);
91
+ return clamp((alpha - cutoff) / band + 0.5, 0.0, 1.0);
92
+ }
93
+
94
+ bool cutoutKeeps(float share, vec2 pixel, float frame) {
95
+ vec2 p = pixel + vec2(${NOISE_FRAME_STEP.toFixed(6)} * frame);
96
+ float noise = fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
97
+ return share > noise;
98
+ }
99
+ `;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The eye model's refraction, as `shaders/flat/eye.ts` computes it: the reference its tests hold the
3
+ * arithmetic to, and nothing at run time calls it.
4
+ *
5
+ * **An iris is seen through a cornea**, a few millimetres of clear fluid above it with an index near
6
+ * water's, so it is not where it is painted: the eye's ray bends at the cornea toward the axis and
7
+ * meets the iris plane somewhere else, and the iris seems to lie deeper and to shift as the eye
8
+ * turns away. The lit stage moves the iris's texture coordinate by that shift before any map of
9
+ * the material is read, so the whole iris — colour, relief, its own channels — moves as one.
10
+ *
11
+ * **The shift**: refract the view at the cornea's surface, follow the refracted ray down until it
12
+ * has descended the cornea's height above the iris plane along the eye's axis, and take what it
13
+ * travelled across the plane. Head-on it is nothing; at an angle θ to a flat cornea it is
14
+ * `height · tan(asin(sin θ / ior))`. The plane is perpendicular to the axis, so a cornea seen
15
+ * almost edge-on sends the ray along it: the descent is floored, which bounds the shift at about a
16
+ * thousand times the height rather than letting it run to infinity at the limb.
17
+ */
18
+ import type { Vec3 } from '../math/color.ts';
19
+
20
+ /** Schlick's base for the cornea's highlight, at the model's default index 1.336. */
21
+ export const CORNEA_F0 = ((1 - 1.336) / (1 + 1.336)) ** 2;
22
+
23
+ /** How slowly a refracted ray may descend toward the iris plane before it is held there. */
24
+ export const EYE_MIN_DESCENT = 1e-3;
25
+
26
+ /** GLSL's `refract`, into `out`: the transmitted direction, or zeros past total internal reflection. */
27
+ export function refract(incident: Vec3, normal: Vec3, eta: number, out: Float64Array): void {
28
+ const d = normal[0] * incident[0] + normal[1] * incident[1] + normal[2] * incident[2];
29
+ const k = 1 - eta * eta * (1 - d * d);
30
+ if (k < 0) {
31
+ out.fill(0);
32
+ return;
33
+ }
34
+ const s = eta * d + Math.sqrt(k);
35
+ for (let c = 0; c < 3; c++) out[c] = eta * (incident[c] as number) - s * (normal[c] as number);
36
+ }
37
+
38
+ /**
39
+ * How far across the iris plane the eye's ray lands from where it entered the cornea, in metres,
40
+ * into `out`: `toEye` and `normal` at the cornea, `axis` the eye's, all unit vectors; `height` the
41
+ * cornea's height above the iris plane there.
42
+ */
43
+ export function eyeRefractionShift(
44
+ toEye: Vec3,
45
+ normal: Vec3,
46
+ axis: Vec3,
47
+ ior: number,
48
+ height: number,
49
+ out: Float64Array,
50
+ ): void {
51
+ const ray = new Float64Array(3);
52
+ refract([-toEye[0], -toEye[1], -toEye[2]], normal, 1 / ior, ray);
53
+ const descent = Math.max(
54
+ -((ray[0] as number) * axis[0] + (ray[1] as number) * axis[1] + (ray[2] as number) * axis[2]),
55
+ EYE_MIN_DESCENT,
56
+ );
57
+ const reach = height / descent;
58
+ const along =
59
+ ((ray[0] as number) * axis[0] + (ray[1] as number) * axis[1] + (ray[2] as number) * axis[2]) *
60
+ reach;
61
+ for (let c = 0; c < 3; c++) out[c] = (ray[c] as number) * reach - axis[c] * along;
62
+ }
63
+
64
+ /**
65
+ * Where the iris is, without a map: 1 inside `irisRadius` of the texture's centre, 0 outside, across
66
+ * an edge `edge` wide — a limbus, not a cut.
67
+ */
68
+ export function irisMask(u: number, v: number, irisRadius: number, edge: number): number {
69
+ const r = Math.hypot(u - 0.5, v - 0.5);
70
+ const t = Math.min(Math.max((r - (irisRadius - edge)) / (2 * edge), 0), 1);
71
+ return 1 - t * t * (3 - 2 * t);
72
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * BC on WebGL2: which formats this context samples, and the upload of a stored chain.
3
+ *
4
+ * **Four extensions, each a different subset**, and the names a device offers are read off them:
5
+ * `WEBGL_compressed_texture_s3tc` is BC1 to BC3, its `_srgb` twin their sRGB forms,
6
+ * `EXT_texture_compression_rgtc` BC4 and BC5, and `EXT_texture_compression_bptc` BC7 in both. A
7
+ * desktop GPU usually has all four and a phone none, which is why the loader decodes there.
8
+ *
9
+ * BC1 is uploaded as `COMPRESSED_RGBA_S3TC_DXT1_EXT` rather than the RGB one, so a three-colour
10
+ * block's fourth index is transparent here as it is on WebGPU, whose only BC1 is `bc1-rgba-unorm`.
11
+ *
12
+ * The stored levels are uploaded as they are and no chain is generated: `generateMipmap` cannot
13
+ * write a compressed format, and the chain its author stored is the better one anyway. A chain that
14
+ * stops before 1x1 sets `TEXTURE_MAX_LEVEL` to its last level, or the texture is incomplete and
15
+ * samples black.
16
+ */
17
+ import type { CompressedTextureFormat, CompressedTextureSource } from './compressedSource.ts';
18
+
19
+ /** The constants each extension defines, written out so a missing extension cannot be read for one. */
20
+ const INTERNAL: Readonly<Record<CompressedTextureFormat, number>> = {
21
+ bc1: 0x83f1, // COMPRESSED_RGBA_S3TC_DXT1_EXT
22
+ bc2: 0x83f2, // COMPRESSED_RGBA_S3TC_DXT3_EXT
23
+ bc3: 0x83f3, // COMPRESSED_RGBA_S3TC_DXT5_EXT
24
+ 'bc1-srgb': 0x8c4d, // COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT
25
+ 'bc2-srgb': 0x8c4e, // COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT
26
+ 'bc3-srgb': 0x8c4f, // COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT
27
+ bc4: 0x8dbb, // COMPRESSED_RED_RGTC1_EXT
28
+ bc5: 0x8dbd, // COMPRESSED_RED_GREEN_RGTC2_EXT
29
+ bc7: 0x8e8c, // COMPRESSED_RGBA_BPTC_UNORM_EXT
30
+ 'bc7-srgb': 0x8e8d, // COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT
31
+ };
32
+
33
+ const BY_EXTENSION: readonly (readonly [string, readonly CompressedTextureFormat[]])[] = [
34
+ ['WEBGL_compressed_texture_s3tc', ['bc1', 'bc2', 'bc3']],
35
+ ['WEBGL_compressed_texture_s3tc_srgb', ['bc1-srgb', 'bc2-srgb', 'bc3-srgb']],
36
+ ['EXT_texture_compression_rgtc', ['bc4', 'bc5']],
37
+ ['EXT_texture_compression_bptc', ['bc7', 'bc7-srgb']],
38
+ ];
39
+
40
+ /**
41
+ * The BC formats this context samples, enabling each extension that offers some. Enabling is what
42
+ * `getExtension` does, and a format is only legal in an upload once it has been called.
43
+ */
44
+ export function glCompressedFormats(gl: WebGL2RenderingContext): CompressedTextureFormat[] {
45
+ const out: CompressedTextureFormat[] = [];
46
+ for (const [name, formats] of BY_EXTENSION) {
47
+ if (gl.getExtension(name) !== null) out.push(...formats);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Upload `layers`, every one of `levels` levels, to the bound `TEXTURE_2D_ARRAY` as `name`, and cap
54
+ * the chain at what was stored. The caller has checked the sources and the device.
55
+ */
56
+ export function uploadCompressedArray(
57
+ gl: WebGL2RenderingContext,
58
+ name: CompressedTextureFormat,
59
+ layers: readonly CompressedTextureSource[],
60
+ levels: number,
61
+ ): void {
62
+ const first = layers[0] as CompressedTextureSource;
63
+ for (let level = 0; level < levels; level++) {
64
+ let size = 0;
65
+ for (const layer of layers) size += (layer.levels[level] as Uint8Array).length;
66
+ const data = new Uint8Array(size);
67
+ let at = 0;
68
+ for (const layer of layers) {
69
+ const bytes = layer.levels[level] as Uint8Array;
70
+ data.set(bytes, at);
71
+ at += bytes.length;
72
+ }
73
+ gl.compressedTexImage3D(
74
+ gl.TEXTURE_2D_ARRAY,
75
+ level,
76
+ INTERNAL[name],
77
+ Math.max(1, first.width >> level),
78
+ Math.max(1, first.height >> level),
79
+ layers.length,
80
+ 0,
81
+ data,
82
+ );
83
+ }
84
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_BASE_LEVEL, 0);
85
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAX_LEVEL, levels - 1);
86
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The hair model's lobes, as `shaders/flat/hair.ts` computes them: the reference its tests hold the
3
+ * arithmetic to, and nothing at run time calls it.
4
+ *
5
+ * **A strand is a rough dielectric cylinder, index 1.55, after Marschner et al. 2003 in the
6
+ * real-time form Karis gave in 2016.** Light leaves it three ways, each a longitudinal Gaussian in
7
+ * `sinθi + sinθo` — the angles of light and eye out of the plane normal to the strand — times an
8
+ * azimuthal term in φ, the turn between them around it:
9
+ *
10
+ * - **R**, reflected off the surface: white, and shifted toward the root by twice the tilt of the
11
+ * cuticle's scales, which point toward the tip;
12
+ * - **TT**, through the strand and out: the glow of a backlit head, shifted toward the tip by the
13
+ * tilt, half as wide, and tinted by the fibre for the path it took;
14
+ * - **TRT**, reflected once inside: the coloured second highlight, three tilts toward the tip and
15
+ * twice as wide;
16
+ *
17
+ * and a fourth, the light scattered through many strands, as a wrapped diffuse against a normal
18
+ * turned toward the eye across the strand, tinted by a root of the fibre's colour.
19
+ *
20
+ * **The tangent runs from root to tip**, and the tilt is positive for a strand authored that way. A
21
+ * card authored the other way round swaps its two highlights' ends, and takes a negative shift.
22
+ *
23
+ * **In this engine's light units.** A lamp's diffuse here is `albedo · cosθ` with no 1/π, so the
24
+ * published lobes, which are per unit irradiance, are multiplied by π; the scatter term's 1/π cancels.
25
+ * **What it gives up** is the fit's own: these are Karis's approximations to the azimuthal
26
+ * integrals, not the integrals, and they do not conserve energy exactly — the tests hold the sum
27
+ * under what a strand receives.
28
+ */
29
+ import type { Vec3 } from '../math/color.ts';
30
+
31
+ /** Schlick's base for a strand at index 1.55: `((1 − n) / (1 + n))²`. */
32
+ export const HAIR_F0 = ((1 - 1.55) / (1 + 1.55)) ** 2;
33
+
34
+ /**
35
+ * The narrowest longitudinal width a strand is drawn at, in radians.
36
+ *
37
+ * **Below every measured fibre**: Marschner gives the reflected lobe 5° to 10°, and this is about
38
+ * 3°, so no real hair is clamped. What it stops is a roughness of 0 making a Gaussian narrower than
39
+ * a pixel can sample — the speckle `MIN_LOBE_ALPHA` argues against for the standard lobe.
40
+ */
41
+ export const HAIR_MIN_BETA = 0.05;
42
+
43
+ /**
44
+ * The scatter term's answer to light arriving evenly from every side, per unit of it: the term's
45
+ * shape integrated over the sphere and divided by π, which is how this engine's lamp units add up.
46
+ *
47
+ * Derived: the wrapped half `(N·L + 1) / 4` averages 1/4 over the sphere, `1 − |T·L|` averages 1/2,
48
+ * and the shape is `mix(wrapped, 1 − |T·L|, 0.33)`, so its mean is 0.67 · 0.25 + 0.33 · 0.5 = 0.3325
49
+ * and its integral 4π times that. Over π: **1.33**.
50
+ */
51
+ export const HAIR_SCATTER_SPHERE = 1.33;
52
+
53
+ /** How much the scatter term leans toward the strand's own direction rather than the pseudo-normal. */
54
+ const KAJIYA_SHARE = 0.33;
55
+
56
+ /** A longitudinal lobe: a normalised Gaussian of width `beta` at `x` from its centre. */
57
+ export function hairLongitudinal(beta: number, x: number): number {
58
+ return Math.exp((-0.5 * x * x) / (beta * beta)) / (Math.sqrt(2 * Math.PI) * beta);
59
+ }
60
+
61
+ /** Schlick's Fresnel for the strand at a cosine, clamped to the unit interval. */
62
+ export function hairFresnel(cosine: number): number {
63
+ const c = 1 - Math.min(Math.max(cosine, 0), 1);
64
+ return HAIR_F0 + (1 - HAIR_F0) * c * c * c * c * c;
65
+ }
66
+
67
+ function dot(a: ArrayLike<number>, b: ArrayLike<number>): number {
68
+ return (
69
+ (a[0] as number) * (b[0] as number) +
70
+ (a[1] as number) * (b[1] as number) +
71
+ (a[2] as number) * (b[2] as number)
72
+ );
73
+ }
74
+
75
+ const clamp = (x: number, low: number, high: number): number => Math.min(Math.max(x, low), high);
76
+
77
+ /** Where `hairLight` leaves its answer: R, then TT, TRT and the scatter, each a colour. */
78
+ export const HAIR_OUT = { r: 0, tt: 1, trt: 4, scatter: 7, length: 10 } as const;
79
+
80
+ /**
81
+ * One light on a strand, in lamp units and unshadowed, into `out` (`HAIR_OUT`'s layout: R is grey,
82
+ * the rest colours). `tangent`, `toLight` and `toEye` are unit vectors but for a tangent that could
83
+ * not be made one, which comes in as zeros; `fibre` is the base colour.
84
+ *
85
+ * **A lamp with a size widens every lobe by the angle it subtends**, `sourceRadius / 2·dist` as
86
+ * `sphereLobe` takes it, and the lobes are normalised Gaussians, so a wider one carries the same
87
+ * light rather than more. The sun passes zeros.
88
+ */
89
+ export function hairLight(
90
+ tangent: ArrayLike<number>,
91
+ toLight: ArrayLike<number>,
92
+ toEye: ArrayLike<number>,
93
+ fibre: Vec3,
94
+ roughness: number,
95
+ shift: number,
96
+ scatter: number,
97
+ backlit: number,
98
+ sourceRadius: number,
99
+ dist: number,
100
+ out: Float64Array,
101
+ ): void {
102
+ const sinI = clamp(dot(tangent, toLight), -1, 1);
103
+ const sinO = clamp(dot(tangent, toEye), -1, 1);
104
+ /*
105
+ * The half difference angle, rounded to single precision as the device holds it: at its limit,
106
+ * light and eye at opposite ends of the strand, that is π/2 rounded up, whose cosine is −4.4e-8
107
+ * rather than double precision's +6e-17 — and the transmitted powers divide by it. The floor is
108
+ * for the device's number, and the rounding is what lets a test here see it.
109
+ */
110
+ const cosD = Math.max(
111
+ Math.cos(Math.fround(0.5 * Math.abs(Math.asin(sinO) - Math.asin(sinI)))),
112
+ 1e-3,
113
+ );
114
+ let lp2 = 0;
115
+ let vp2 = 0;
116
+ let lpvp = 0;
117
+ for (let c = 0; c < 3; c++) {
118
+ const lp = (toLight[c] as number) - sinI * (tangent[c] as number);
119
+ const vp = (toEye[c] as number) - sinO * (tangent[c] as number);
120
+ lp2 += lp * lp;
121
+ vp2 += vp * vp;
122
+ lpvp += lp * vp;
123
+ }
124
+ const cosPhi = lpvp / Math.sqrt(lp2 * vp2 + 1e-4);
125
+ const cosHalfPhi = Math.sqrt(clamp(0.5 + 0.5 * cosPhi, 0, 1));
126
+ const beta = Math.max(roughness * roughness, HAIR_MIN_BETA);
127
+ const grow = sourceRadius / Math.max(2 * dist, 1e-3);
128
+ const x = sinI + sinO;
129
+
130
+ const r =
131
+ hairLongitudinal(beta + grow, x + 2 * shift) *
132
+ 0.25 *
133
+ cosHalfPhi *
134
+ hairFresnel(Math.sqrt(clamp(0.5 + 0.5 * dot(toLight, toEye), 0, 1)));
135
+
136
+ const a = 1 / (1.19 / cosD + 0.36 * cosD);
137
+ const h = cosHalfPhi * (1 + a * (0.6 - 0.8 * cosPhi));
138
+ const fTT = hairFresnel(cosD * Math.sqrt(clamp(1 - h * h, 0, 1)));
139
+ const tt =
140
+ hairLongitudinal(0.5 * beta + grow, x - shift) *
141
+ Math.exp(-3.65 * cosPhi - 3.98) *
142
+ (1 - fTT) *
143
+ (1 - fTT) *
144
+ backlit;
145
+ const ttPower = (0.5 * Math.sqrt(clamp(1 - h * h * a * a, 0, 1))) / cosD;
146
+
147
+ const fTRT = hairFresnel(0.5 * cosD);
148
+ const trt =
149
+ hairLongitudinal(2 * beta + grow, x - 3 * shift) *
150
+ Math.exp(17 * cosPhi - 16.78) *
151
+ (1 - fTRT) *
152
+ (1 - fTRT) *
153
+ fTRT;
154
+ const trtPower = 0.8 / cosD;
155
+
156
+ let across0 = (toEye[0] as number) - (tangent[0] as number) * sinO;
157
+ let across1 = (toEye[1] as number) - (tangent[1] as number) * sinO;
158
+ let across2 = (toEye[2] as number) - (tangent[2] as number) * sinO;
159
+ const acrossLength = Math.hypot(across0, across1, across2);
160
+ if (acrossLength > 1e-4) {
161
+ across0 /= acrossLength;
162
+ across1 /= acrossLength;
163
+ across2 /= acrossLength;
164
+ }
165
+ const wrapped = clamp(
166
+ (across0 * (toLight[0] as number) +
167
+ across1 * (toLight[1] as number) +
168
+ across2 * (toLight[2] as number) +
169
+ 1) /
170
+ 4,
171
+ 0,
172
+ 1,
173
+ );
174
+ const shape = wrapped + (1 - Math.abs(sinI) - wrapped) * KAJIYA_SHARE;
175
+
176
+ out[HAIR_OUT.r] = Math.PI * r;
177
+ for (let c = 0; c < 3; c++) {
178
+ /*
179
+ * Above zero, so the device's `pow` never forms the logarithm of zero: an infinity in the
180
+ * middle of an expression is something a shader compiler may assume never happens. Nothing in
181
+ * double precision can show the difference, so no test here does.
182
+ */
183
+ const colour = Math.max(fibre[c], 1e-4);
184
+ out[HAIR_OUT.tt + c] = Math.PI * tt * colour ** ttPower;
185
+ out[HAIR_OUT.trt + c] = Math.PI * trt * colour ** trtPower;
186
+ out[HAIR_OUT.scatter + c] = Math.sqrt(fibre[c]) * scatter * shape;
187
+ }
188
+ }