@driftengine/capture 4.0.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 (199) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +29 -0
  3. package/README.md +68 -0
  4. package/dist/browserFrames.d.ts +31 -0
  5. package/dist/browserFrames.js +105 -0
  6. package/dist/collide.d.ts +59 -0
  7. package/dist/collide.js +92 -0
  8. package/dist/decimate.d.ts +42 -0
  9. package/dist/decimate.js +439 -0
  10. package/dist/delight.d.ts +42 -0
  11. package/dist/delight.js +351 -0
  12. package/dist/delightScene.d.ts +37 -0
  13. package/dist/delightScene.js +183 -0
  14. package/dist/depth.d.ts +44 -0
  15. package/dist/depth.js +53 -0
  16. package/dist/features.d.ts +33 -0
  17. package/dist/features.js +260 -0
  18. package/dist/frames.d.ts +47 -0
  19. package/dist/frames.js +87 -0
  20. package/dist/fusion.d.ts +77 -0
  21. package/dist/fusion.js +152 -0
  22. package/dist/gaussians/adam.d.ts +57 -0
  23. package/dist/gaussians/adam.js +88 -0
  24. package/dist/gaussians/depth.d.ts +8 -0
  25. package/dist/gaussians/depth.js +73 -0
  26. package/dist/gaussians/gradients.d.ts +36 -0
  27. package/dist/gaussians/gradients.js +228 -0
  28. package/dist/gaussians/harmonics.d.ts +46 -0
  29. package/dist/gaussians/harmonics.js +99 -0
  30. package/dist/gaussians/optimise.d.ts +50 -0
  31. package/dist/gaussians/optimise.js +125 -0
  32. package/dist/gaussians/parameters.d.ts +35 -0
  33. package/dist/gaussians/parameters.js +165 -0
  34. package/dist/gaussians/project.d.ts +72 -0
  35. package/dist/gaussians/project.js +137 -0
  36. package/dist/gaussians/projectGradient.d.ts +15 -0
  37. package/dist/gaussians/projectGradient.js +213 -0
  38. package/dist/gaussians/rasterise.d.ts +13 -0
  39. package/dist/gaussians/rasterise.js +65 -0
  40. package/dist/gaussians/refine.d.ts +36 -0
  41. package/dist/gaussians/refine.js +136 -0
  42. package/dist/gaussians/similarity.d.ts +25 -0
  43. package/dist/gaussians/similarity.js +80 -0
  44. package/dist/index.d.ts +101 -0
  45. package/dist/index.js +96 -0
  46. package/dist/marching.d.ts +38 -0
  47. package/dist/marching.js +258 -0
  48. package/dist/masks.d.ts +59 -0
  49. package/dist/masks.js +165 -0
  50. package/dist/math/dense.d.ts +68 -0
  51. package/dist/math/dense.js +294 -0
  52. package/dist/math/schur.d.ts +23 -0
  53. package/dist/math/schur.js +140 -0
  54. package/dist/models/batchNorm.d.ts +17 -0
  55. package/dist/models/batchNorm.js +24 -0
  56. package/dist/models/clipTokenizer.d.ts +40 -0
  57. package/dist/models/clipTokenizer.js +122 -0
  58. package/dist/models/clipTower.d.ts +27 -0
  59. package/dist/models/clipTower.js +45 -0
  60. package/dist/models/depthAnything2.d.ts +38 -0
  61. package/dist/models/depthAnything2.js +163 -0
  62. package/dist/models/depthAnything3.d.ts +26 -0
  63. package/dist/models/depthAnything3.js +171 -0
  64. package/dist/models/dualDpt.d.ts +19 -0
  65. package/dist/models/dualDpt.js +138 -0
  66. package/dist/models/hiera.d.ts +49 -0
  67. package/dist/models/hiera.js +120 -0
  68. package/dist/models/hieraAttention.d.ts +16 -0
  69. package/dist/models/hieraAttention.js +68 -0
  70. package/dist/models/miniature.d.ts +36 -0
  71. package/dist/models/miniature.js +216 -0
  72. package/dist/models/mobileSam.d.ts +41 -0
  73. package/dist/models/mobileSam.js +88 -0
  74. package/dist/models/owlv2.d.ts +49 -0
  75. package/dist/models/owlv2.js +131 -0
  76. package/dist/models/owlv2Detect.d.ts +25 -0
  77. package/dist/models/owlv2Detect.js +80 -0
  78. package/dist/models/owlv2Miniature.d.ts +27 -0
  79. package/dist/models/owlv2Miniature.js +87 -0
  80. package/dist/models/rope.d.ts +23 -0
  81. package/dist/models/rope.js +84 -0
  82. package/dist/models/sam21.d.ts +45 -0
  83. package/dist/models/sam21.js +126 -0
  84. package/dist/models/sam21Miniature.d.ts +37 -0
  85. package/dist/models/sam21Miniature.js +194 -0
  86. package/dist/models/sam21Video.d.ts +30 -0
  87. package/dist/models/sam21Video.js +101 -0
  88. package/dist/models/sam2Choice.d.ts +19 -0
  89. package/dist/models/sam2Choice.js +55 -0
  90. package/dist/models/sam2Decoder.d.ts +36 -0
  91. package/dist/models/sam2Decoder.js +108 -0
  92. package/dist/models/sam2Memories.d.ts +54 -0
  93. package/dist/models/sam2Memories.js +93 -0
  94. package/dist/models/sam2Memory.d.ts +30 -0
  95. package/dist/models/sam2Memory.js +136 -0
  96. package/dist/models/sam2Positions.d.ts +24 -0
  97. package/dist/models/sam2Positions.js +94 -0
  98. package/dist/models/sam2Tracker.d.ts +69 -0
  99. package/dist/models/sam2Tracker.js +172 -0
  100. package/dist/models/samDecoder.d.ts +45 -0
  101. package/dist/models/samDecoder.js +96 -0
  102. package/dist/models/samMiniature.d.ts +43 -0
  103. package/dist/models/samMiniature.js +161 -0
  104. package/dist/models/samPrompt.d.ts +57 -0
  105. package/dist/models/samPrompt.js +160 -0
  106. package/dist/models/seeded.d.ts +16 -0
  107. package/dist/models/seeded.js +48 -0
  108. package/dist/models/tinyVit.d.ts +38 -0
  109. package/dist/models/tinyVit.js +82 -0
  110. package/dist/models/twoWay.d.ts +46 -0
  111. package/dist/models/twoWay.js +67 -0
  112. package/dist/models/vit.d.ts +45 -0
  113. package/dist/models/vit.js +157 -0
  114. package/dist/models/windowAttention.d.ts +25 -0
  115. package/dist/models/windowAttention.js +104 -0
  116. package/dist/poses.d.ts +31 -0
  117. package/dist/poses.js +750 -0
  118. package/dist/prepare.d.ts +30 -0
  119. package/dist/prepare.js +238 -0
  120. package/dist/propose.d.ts +36 -0
  121. package/dist/propose.js +63 -0
  122. package/dist/resample.d.ts +39 -0
  123. package/dist/resample.js +165 -0
  124. package/dist/run.d.ts +3 -0
  125. package/dist/run.js +1 -0
  126. package/dist/scene.d.ts +104 -0
  127. package/dist/scene.js +109 -0
  128. package/dist/segment.d.ts +40 -0
  129. package/dist/segment.js +184 -0
  130. package/dist/testScene.d.ts +55 -0
  131. package/dist/testScene.js +214 -0
  132. package/dist/twoView.d.ts +22 -0
  133. package/dist/twoView.js +435 -0
  134. package/package.json +63 -0
  135. package/src/browserFrames.ts +145 -0
  136. package/src/collide.ts +128 -0
  137. package/src/decimate.ts +510 -0
  138. package/src/delight.ts +427 -0
  139. package/src/delightScene.ts +249 -0
  140. package/src/depth.ts +100 -0
  141. package/src/features.ts +302 -0
  142. package/src/frames.ts +111 -0
  143. package/src/fusion.ts +229 -0
  144. package/src/gaussians/adam.ts +131 -0
  145. package/src/gaussians/depth.ts +87 -0
  146. package/src/gaussians/gradients.ts +285 -0
  147. package/src/gaussians/harmonics.ts +122 -0
  148. package/src/gaussians/optimise.ts +191 -0
  149. package/src/gaussians/parameters.ts +186 -0
  150. package/src/gaussians/project.ts +207 -0
  151. package/src/gaussians/projectGradient.ts +234 -0
  152. package/src/gaussians/rasterise.ts +81 -0
  153. package/src/gaussians/refine.ts +176 -0
  154. package/src/gaussians/similarity.ts +87 -0
  155. package/src/index.ts +225 -0
  156. package/src/marching.ts +358 -0
  157. package/src/masks.ts +201 -0
  158. package/src/math/dense.ts +317 -0
  159. package/src/math/schur.ts +167 -0
  160. package/src/models/batchNorm.ts +57 -0
  161. package/src/models/clipTokenizer.ts +129 -0
  162. package/src/models/clipTower.ts +87 -0
  163. package/src/models/depthAnything2.ts +276 -0
  164. package/src/models/depthAnything3.ts +229 -0
  165. package/src/models/dualDpt.ts +233 -0
  166. package/src/models/hiera.ts +231 -0
  167. package/src/models/hieraAttention.ts +121 -0
  168. package/src/models/miniature.ts +241 -0
  169. package/src/models/mobileSam.ts +135 -0
  170. package/src/models/owlv2.ts +190 -0
  171. package/src/models/owlv2Detect.ts +116 -0
  172. package/src/models/owlv2Miniature.ts +108 -0
  173. package/src/models/rope.ts +111 -0
  174. package/src/models/sam21.ts +182 -0
  175. package/src/models/sam21Miniature.ts +225 -0
  176. package/src/models/sam21Video.ts +154 -0
  177. package/src/models/sam2Choice.ts +62 -0
  178. package/src/models/sam2Decoder.ts +218 -0
  179. package/src/models/sam2Memories.ts +165 -0
  180. package/src/models/sam2Memory.ts +247 -0
  181. package/src/models/sam2Positions.ts +106 -0
  182. package/src/models/sam2Tracker.ts +300 -0
  183. package/src/models/samDecoder.ts +225 -0
  184. package/src/models/samMiniature.ts +190 -0
  185. package/src/models/samPrompt.ts +227 -0
  186. package/src/models/seeded.ts +50 -0
  187. package/src/models/tinyVit.ts +178 -0
  188. package/src/models/twoWay.ts +122 -0
  189. package/src/models/vit.ts +265 -0
  190. package/src/models/windowAttention.ts +155 -0
  191. package/src/poses.ts +894 -0
  192. package/src/prepare.ts +306 -0
  193. package/src/propose.ts +96 -0
  194. package/src/resample.ts +197 -0
  195. package/src/run.ts +7 -0
  196. package/src/scene.ts +141 -0
  197. package/src/segment.ts +220 -0
  198. package/src/testScene.ts +283 -0
  199. package/src/twoView.ts +536 -0
package/src/delight.ts ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * A captured surface's material, separated from the light it was photographed under.
3
+ *
4
+ * ## What this is, said plainly
5
+ *
6
+ * **Inverse rendering is ill-posed and this does not pretend otherwise.** A photograph of a grey
7
+ * wall in shadow and a photograph of a dark wall in light are the same photograph. Nothing recovers
8
+ * the difference from pixels alone; what recovers it is an *assumption*, and the whole of the
9
+ * honesty in this file is in saying which assumptions and where they fail.
10
+ *
11
+ * **Shadow baked into albedo is the field's named failure.** A capture delit badly looks correct
12
+ * until it is relit — and then the old light is still painted on, a shadow follows an object that
13
+ * has moved, and the scene reads as flat and wrong in a way nobody can point at. Every measurement
14
+ * below is aimed at that one failure.
15
+ *
16
+ * **No learned model ships here, and the reason is licensing rather than quality.** The models that
17
+ * do this well are trained on datasets whose terms do not permit redistribution in a product, and
18
+ * a weight file a consumer cannot lawfully ship is worse than no weight file at all. So this is
19
+ * multi-view inverse rendering with a classical separation, and `tools/capture-prior/` is where a
20
+ * prior the project owns would be trained if one is wanted — on scenes DriftEngine itself renders,
21
+ * which is data nobody else has terms on.
22
+ *
23
+ * ## The two assumptions, and what each is worth
24
+ *
25
+ * **A Lambertian point looks the same from everywhere; a highlight does not.** That is what
26
+ * *multi-view* buys, and it buys exactly one thing: the view-dependent part. A point seen from six
27
+ * cameras gives six observations whose *median* rejects a highlight in any minority of them, and
28
+ * whose spread above that median is the specular energy — which is where the roughness estimate
29
+ * comes from. **What it cannot do is remove a cast shadow**, and that is worth stating because it
30
+ * is the assumption people expect it to carry: a shadowed point is shadowed from every camera, so
31
+ * every observation agrees, and agreement is not evidence of albedo.
32
+ *
33
+ * **A shadow changes intensity and not colour; a material change usually changes colour.** That is
34
+ * Retinex, and it is what separates a cast shadow from a dark patch of paint. Over the mesh's own
35
+ * edges, an edge where the intensity jumps and the chromaticity holds is called shading and the
36
+ * jump is given to the light; an edge where the chromaticity moves is called reflectance and the
37
+ * jump is given to the albedo. **Where it fails is a grey object on a grey floor** — no chromaticity
38
+ * to tell them apart — and that is what the confidence is for.
39
+ *
40
+ * ## What it achieves, measured
41
+ *
42
+ * On the synthetic scene in `delight.test.ts`: a cast shadow that makes one half of a floor **4.8
43
+ * times darker than the other in the photographs** comes back **2.2 times darker in the albedo** —
44
+ * half of it removed, in the logarithm. A matte floor's albedo lands within about a tenth of the
45
+ * truth, and the whole scene relit under a light it never saw is within a tenth of a channel of the
46
+ * renderer's own answer.
47
+ *
48
+ * **Half a shadow is a real result and not a finished one**, and the number is written here rather
49
+ * than rounded up because it is the number a learned prior would have to beat to be worth its
50
+ * weights. What is left is the part a chromaticity cannot see: where a shadow's edge falls inside a
51
+ * single material, the only thing separating shading from paint is a *prior about scenes*, and this
52
+ * has none.
53
+ *
54
+ * ## The confidence is the point, not a decoration
55
+ *
56
+ * Every surface here gets a number saying how much of this to believe, and the test that makes it
57
+ * mean something **correlates it against the actual error**. A channel that does not track the error
58
+ * is worse than none, because a consumer trusts it. Low confidence is the honest answer for glass,
59
+ * for a surface one camera saw, and for a grey object on a grey floor — and a pipeline that knows
60
+ * which surfaces those are can leave them alone rather than delighting them wrongly.
61
+ */
62
+ import { exactExp, exactLog } from '@driftengine/core';
63
+ import type { MeshData } from '@driftengine/drft';
64
+
65
+ import { cameraCentre, nearestHit } from './delightScene.ts';
66
+ import type { Frame } from './gaussians/rasterise.ts';
67
+ import type { RasterCamera } from './gaussians/project.ts';
68
+
69
+ /** One view of the surface: a linear frame and where it was taken from. */
70
+ export interface DelightView {
71
+ readonly frame: Frame;
72
+ readonly camera: RasterCamera;
73
+ }
74
+
75
+ export interface DelightOptions {
76
+ /**
77
+ * Below this many views, a vertex is answered at low confidence rather than delit.
78
+ *
79
+ * Two is the fewest that can reject anything: one observation cannot disagree with itself, so a
80
+ * highlight in it is indistinguishable from paint.
81
+ */
82
+ readonly minimumViews?: number;
83
+ /**
84
+ * How far apart two chromaticities have to be for an edge to be called a material change.
85
+ *
86
+ * **The one number that decides what a shadow is.** Too small and every shading gradient is read
87
+ * as paint, which puts the shadow back in the albedo; too large and a real material boundary is
88
+ * read as shading, which smears one surface's colour across another. Measured on the synthetic
89
+ * scenes at 0.02 to 0.08; the default sits in the middle of that.
90
+ */
91
+ readonly chromaticityStep?: number;
92
+ /** Sweeps of the shading solve. Each one spreads a decision one edge further over the mesh. */
93
+ readonly iterations?: number;
94
+ }
95
+
96
+ /** What comes back, one entry per vertex of the mesh handed in. */
97
+ export interface DelightOut {
98
+ /** Three per vertex, linear. */
99
+ readonly albedo: Float32Array;
100
+ /** One per vertex. */
101
+ readonly roughness: Float32Array;
102
+ /** Three per vertex: the mesh's own, answered here so a caller has one surface to read. */
103
+ readonly normal: Float32Array;
104
+ /** One per vertex, 0 to 1. */
105
+ readonly confidence: Float32Array;
106
+ }
107
+
108
+ const DEFAULT_MINIMUM_VIEWS = 2;
109
+ const DEFAULT_CHROMATICITY_STEP = 0.05;
110
+ const DEFAULT_ITERATIONS = 120;
111
+ /** Where in the sorted shading the scene's "fully lit" is taken to be. */
112
+ const ANCHOR_PERCENTILE = 0.9;
113
+ /** Views past which another one adds little: four sample a hemisphere well enough to reject by. */
114
+ const ENOUGH_VIEWS = 4;
115
+
116
+ export function createDelightOut(vertices: number): DelightOut {
117
+ return {
118
+ albedo: new Float32Array(vertices * 3),
119
+ roughness: new Float32Array(vertices),
120
+ normal: new Float32Array(vertices * 3),
121
+ confidence: new Float32Array(vertices),
122
+ };
123
+ }
124
+
125
+ /** `mesh`'s material, as seen in `views`. */
126
+ export function delight(
127
+ views: readonly DelightView[],
128
+ mesh: MeshData,
129
+ options: DelightOptions,
130
+ out: DelightOut,
131
+ ): void {
132
+ const vertices = mesh.positions.length / 3;
133
+ const minimumViews = options.minimumViews ?? DEFAULT_MINIMUM_VIEWS;
134
+ const step = options.chromaticityStep ?? DEFAULT_CHROMATICITY_STEP;
135
+ const iterations = options.iterations ?? DEFAULT_ITERATIONS;
136
+
137
+ out.normal.set(mesh.normals.subarray(0, vertices * 3));
138
+
139
+ /* What each view saw of each vertex, where it saw it at all. */
140
+ const observed = new Float64Array(vertices * 3);
141
+ const seen = new Int32Array(vertices);
142
+ const spread = new Float64Array(vertices);
143
+ gather(views, mesh, observed, seen, spread);
144
+
145
+ /*
146
+ * The intensity a point returned and the colour it returned it in, kept apart. Retinex works on
147
+ * the first and leaves the second alone, because a shadow is the one and a material is the other.
148
+ */
149
+ const logObserved = new Float64Array(vertices * 3);
150
+ const chroma = new Float64Array(vertices * 3);
151
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
152
+ let total = 0;
153
+ for (let c = 0; c < 3; c += 1) total += observed[vertex * 3 + c] as number;
154
+ for (let c = 0; c < 3; c += 1) {
155
+ logObserved[vertex * 3 + c] = exactLog(Math.max(observed[vertex * 3 + c] as number, 1e-4));
156
+ chroma[vertex * 3 + c] = (observed[vertex * 3 + c] as number) / Math.max(total, 1e-6);
157
+ }
158
+ }
159
+
160
+ /*
161
+ * **One solve per channel, sharing one classification.** An intensity-only separation cannot tell
162
+ * a warm key light from a neutral ambient: a surface in shadow is lit by one and out of it by the
163
+ * other, so its recovered *colour* carries the difference even when its brightness does not.
164
+ * Measured on the synthetic scene, that alone was most of what was left — the shadowed and lit
165
+ * halves of one floor came back 35% apart. The decision about which edges are shading is still
166
+ * made once, on chromaticity, because that is what a shadow does not change.
167
+ */
168
+ const channelShading: Float64Array[] = [];
169
+ const channel = new Float64Array(vertices);
170
+ for (let c = 0; c < 3; c += 1) {
171
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
172
+ channel[vertex] = logObserved[vertex * 3 + c] as number;
173
+ }
174
+ channelShading.push(solveShading(mesh, channel, chroma, step, iterations, seen, minimumViews));
175
+ }
176
+
177
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
178
+ /* What is left of each channel once its own light is taken out. */
179
+ for (let c = 0; c < 3; c += 1) {
180
+ const shading = (channelShading[c] as Float64Array)[vertex] as number;
181
+ const value = exactExp((logObserved[vertex * 3 + c] as number) - shading);
182
+ out.albedo[vertex * 3 + c] = value > 1 ? 1 : value;
183
+ }
184
+ /*
185
+ * **The spread above the median is specular energy, and a wider lobe means a rougher surface.**
186
+ * A mirror puts all of it in one view and a chalk surface in none, so the roughness falls as
187
+ * the disagreement rises. Coarse by construction: six views sample a lobe badly, and a number
188
+ * that says "rough" or "polished" is what a capture can honestly carry.
189
+ */
190
+ const excess = spread[vertex] as number;
191
+ out.roughness[vertex] = Math.min(1, Math.max(0.05, 1 - Math.min(1, excess * 4)));
192
+ out.confidence[vertex] = confidenceAt(
193
+ seen[vertex] as number,
194
+ minimumViews,
195
+ excess,
196
+ views.length,
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Every view's opinion of every vertex: the median colour, how many saw it, and their disagreement.
203
+ *
204
+ * **Visibility is a ray back to the camera against the mesh itself.** A vertex behind a wall is one
205
+ * the camera never saw, and reading the pixel it projects to reads the wall's colour as the
206
+ * surface's — which is not a subtle error, it is another object's paint.
207
+ */
208
+ function gather(
209
+ views: readonly DelightView[],
210
+ mesh: MeshData,
211
+ observed: Float64Array,
212
+ seen: Int32Array,
213
+ spread: Float64Array,
214
+ ): void {
215
+ const vertices = mesh.positions.length / 3;
216
+ const samples: number[][] = [];
217
+ for (let vertex = 0; vertex < vertices; vertex += 1) samples.push([]);
218
+
219
+ for (const view of views) {
220
+ const { width, height, intrinsics } = view.camera;
221
+ const [fx, fy, cx, cy] = intrinsics;
222
+ const m = view.camera.worldToCamera;
223
+ const eye = cameraCentre(m);
224
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
225
+ const point: [number, number, number] = [
226
+ mesh.positions[vertex * 3] as number,
227
+ mesh.positions[vertex * 3 + 1] as number,
228
+ mesh.positions[vertex * 3 + 2] as number,
229
+ ];
230
+ /* Facing away from the camera is not seen, whatever the depth says. */
231
+ let facing = 0;
232
+ for (let k = 0; k < 3; k += 1) {
233
+ facing += (mesh.normals[vertex * 3 + k] as number) * ((eye[k] as number) - point[k]);
234
+ }
235
+ if (!(facing > 0)) continue;
236
+
237
+ let viewZ = m[11] as number;
238
+ let viewX = m[3] as number;
239
+ let viewY = m[7] as number;
240
+ for (let k = 0; k < 3; k += 1) {
241
+ viewX += (m[k] as number) * point[k];
242
+ viewY += (m[4 + k] as number) * point[k];
243
+ viewZ += (m[8 + k] as number) * point[k];
244
+ }
245
+ if (!(viewZ > 0)) continue;
246
+ const px = Math.floor((fx * viewX) / viewZ + cx);
247
+ const py = Math.floor((fy * viewY) / viewZ + cy);
248
+ if (px < 0 || px >= width || py < 0 || py >= height) continue;
249
+
250
+ /* Occluded by the surface itself: the ray to the camera meets something first. */
251
+ const toEye: [number, number, number] = [
252
+ (eye[0] as number) - point[0],
253
+ (eye[1] as number) - point[1],
254
+ (eye[2] as number) - point[2],
255
+ ];
256
+ const distance = Math.sqrt(toEye[0] * toEye[0] + toEye[1] * toEye[1] + toEye[2] * toEye[2]);
257
+ const direction: [number, number, number] = [
258
+ toEye[0] / distance,
259
+ toEye[1] / distance,
260
+ toEye[2] / distance,
261
+ ];
262
+ if (nearestHit(mesh, point, direction, 1e-3, distance - 1e-3) !== null) continue;
263
+
264
+ const pixel = (py * width + px) * 4;
265
+ (samples[vertex] as number[]).push(
266
+ view.frame[pixel] as number,
267
+ view.frame[pixel + 1] as number,
268
+ view.frame[pixel + 2] as number,
269
+ );
270
+ }
271
+ }
272
+
273
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
274
+ const held = samples[vertex] as number[];
275
+ const count = held.length / 3;
276
+ seen[vertex] = count;
277
+ if (count === 0) continue;
278
+ /* The median a channel at a time: a highlight in a minority of views is rejected by it. */
279
+ for (let c = 0; c < 3; c += 1) {
280
+ const column: number[] = [];
281
+ for (let at = 0; at < count; at += 1) column.push(held[at * 3 + c] as number);
282
+ column.sort((a, b) => a - b);
283
+ observed[vertex * 3 + c] = column[Math.floor((count - 1) / 2)] as number;
284
+ }
285
+ /*
286
+ * **How far the views stand from their own median, in either direction.** A view brighter than
287
+ * the rest is a highlight and one darker is an occlusion or an inter-reflection, and both say
288
+ * the same thing about the answer: the median is standing in for observations that did not
289
+ * agree. The mean of those distances and the worst of them were both measured here and are
290
+ * interchangeable on these scenes, so it is the mean — the plainer statistic, and the one that
291
+ * does not turn on a single camera.
292
+ */
293
+ let apart = 0;
294
+ for (let at = 0; at < count; at += 1) {
295
+ for (let c = 0; c < 3; c += 1) {
296
+ apart += Math.abs((held[at * 3 + c] as number) - (observed[vertex * 3 + c] as number));
297
+ }
298
+ }
299
+ spread[vertex] = apart / (count * 3);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * The light, as a field over the mesh that explains every intensity jump a colour jump does not.
305
+ *
306
+ * A sweep at a time: each vertex takes the average of what its neighbours imply, where a shading
307
+ * edge implies the neighbour's shading plus the observed jump and a reflectance edge implies the
308
+ * neighbour's shading unchanged.
309
+ *
310
+ * **The constant is assumed rather than solved for, and which assumption matters.** Nothing in a
311
+ * photograph says whether the light was bright and the paint dark or the other way round, so one
312
+ * end has to be fixed. The textbook choice is *the brightest surface is white*, and it is wrong for
313
+ * a capture: a room of brown floor and blue wall has no white in it, and the recovery comes back
314
+ * about sixty per cent too bright — measured on the synthetic scene, which then failed its
315
+ * relighting gate by a third of a channel. What is assumed here instead is **the brightest
316
+ * illumination in the scene is full illumination**, which is a statement about the light rather
317
+ * than about the paint, and is true of any scene with a surface facing the light.
318
+ */
319
+ function solveShading(
320
+ mesh: MeshData,
321
+ logIntensity: Float64Array,
322
+ chroma: Float64Array,
323
+ step: number,
324
+ iterations: number,
325
+ seen: Int32Array,
326
+ minimumViews: number,
327
+ ): Float64Array {
328
+ const vertices = logIntensity.length;
329
+ const neighbours = adjacency(mesh, vertices);
330
+ const shading = new Float64Array(vertices);
331
+ const next = new Float64Array(vertices);
332
+
333
+ for (let sweep = 0; sweep < iterations; sweep += 1) {
334
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
335
+ const around = neighbours[vertex] as number[];
336
+ if (around.length === 0) {
337
+ next[vertex] = shading[vertex] as number;
338
+ continue;
339
+ }
340
+ let total = 0;
341
+ for (const other of around) {
342
+ let apart = 0;
343
+ for (let c = 0; c < 3; c += 1) {
344
+ apart += Math.abs((chroma[vertex * 3 + c] as number) - (chroma[other * 3 + c] as number));
345
+ }
346
+ const materialChange = apart > step;
347
+ const jump = materialChange
348
+ ? 0
349
+ : (logIntensity[vertex] as number) - (logIntensity[other] as number);
350
+ total += (shading[other] as number) + jump;
351
+ }
352
+ next[vertex] = total / around.length;
353
+ }
354
+ shading.set(next);
355
+ }
356
+
357
+ /*
358
+ * The most-lit surface anybody saw properly is taken as fully lit, which fixes the constant.
359
+ *
360
+ * **At a high percentile rather than at the maximum**, and that choice is the load-bearing part:
361
+ * one vertex is enough to set a maximum, and the one that sets it is whichever caught a highlight
362
+ * the views could not average away — the whole scene is then scaled against that vertex.
363
+ * Measured on the synthetic scene, a block's four-vertex top did exactly that and left a matte
364
+ * floor half again too bright.
365
+ *
366
+ * **The shift itself is usually small**, because the solve starts from zero everywhere and its
367
+ * iteration has no absolute term, so it settles around a shading of nought without being told to.
368
+ * That is an accident of the method rather than a decision, which is why the constant is set
369
+ * here explicitly instead of being left to it.
370
+ */
371
+ const levels: number[] = [];
372
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
373
+ if ((seen[vertex] as number) < minimumViews) continue;
374
+ levels.push(shading[vertex] as number);
375
+ }
376
+ if (levels.length > 0) {
377
+ levels.sort((a, b) => a - b);
378
+ const brightest = levels[Math.floor((levels.length - 1) * ANCHOR_PERCENTILE)] as number;
379
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
380
+ shading[vertex] = (shading[vertex] as number) - brightest;
381
+ }
382
+ }
383
+ return shading;
384
+ }
385
+
386
+ /** Which vertices share an edge with which, by position so a split seam is still one surface. */
387
+ function adjacency(mesh: MeshData, vertices: number): number[][] {
388
+ const out: number[][] = [];
389
+ for (let vertex = 0; vertex < vertices; vertex += 1) out.push([]);
390
+ const triangles = mesh.indices.length / 3;
391
+ const seen = new Set<number>();
392
+ for (let face = 0; face < triangles; face += 1) {
393
+ for (let slot = 0; slot < 3; slot += 1) {
394
+ const a = mesh.indices[face * 3 + slot] as number;
395
+ const b = mesh.indices[face * 3 + ((slot + 1) % 3)] as number;
396
+ if (a === b) continue;
397
+ const key = a < b ? a * vertices + b : b * vertices + a;
398
+ if (seen.has(key)) continue;
399
+ seen.add(key);
400
+ (out[a] as number[]).push(b);
401
+ (out[b] as number[]).push(a);
402
+ }
403
+ }
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * How much of this vertex to believe.
409
+ *
410
+ * Three things make an answer weak and they are multiplied rather than averaged, because any one of
411
+ * them alone is enough to make the answer wrong: too few views to reject anything, a surface whose
412
+ * views disagree wildly — glass, a mirror, water — and a surface nobody saw at all.
413
+ */
414
+ function confidenceAt(seen: number, minimumViews: number, excess: number, views: number): number {
415
+ if (seen === 0) return 0;
416
+ if (seen < minimumViews) return 0.1;
417
+ /*
418
+ * Every view that saw it is evidence, with diminishing returns past a handful — **counted
419
+ * absolutely, not against how many cameras there happened to be**. Dividing by the number
420
+ * supplied made two views of two look exactly as good as seven of seven, which is the opposite
421
+ * of what the number is for: a point two cameras saw is one where nothing could be rejected.
422
+ */
423
+ const coverage = Math.min(1, seen / ENOUGH_VIEWS);
424
+ /* And disagreement across views is the tell for a surface that is not diffuse at all. */
425
+ const agreement = 1 / (1 + excess * 12);
426
+ return Math.max(0, Math.min(1, coverage * agreement));
427
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * A lit scene with the answer known: what a delighting test needs and nothing else.
3
+ *
4
+ * **A fixture, not a renderer.** `testScene.ts` ray casts analytic boxes and planes for the
5
+ * geometry stages; this renders a *triangle mesh* with a light in it, because delighting is asked
6
+ * about a mesh and about shading — a cast shadow, a highlight, and an albedo that the caller knows
7
+ * because it put it there. It is deliberately the simplest thing that can pose the question: one
8
+ * directional light, Lambert plus a Blinn–Phong lobe, and a shadow by testing the ray to the light
9
+ * against the same triangles.
10
+ *
11
+ * **It lives beside the code rather than in the test file** because three tests need it and because
12
+ * the *relighting* gate needs to render the same scene under a second light — which is the one
13
+ * measurement that says whether a recovered material is a material or a picture of one.
14
+ */
15
+ import { exactExp, exactLog } from '@driftengine/core';
16
+ import type { MeshData } from '@driftengine/drft';
17
+
18
+ import type { RasterCamera } from './gaussians/project.ts';
19
+
20
+ export interface LitScene {
21
+ readonly mesh: MeshData;
22
+ /** Three per vertex, linear: what the recovery is measured against. */
23
+ readonly albedo: Float32Array;
24
+ /** One per vertex: the same. */
25
+ readonly roughness: Float32Array;
26
+ }
27
+
28
+ export interface Light {
29
+ /** Towards the light, unit length. */
30
+ readonly direction: readonly [number, number, number];
31
+ readonly colour: readonly [number, number, number];
32
+ /** What arrives from everywhere else, so a shadowed point is dark rather than black. */
33
+ readonly ambient: number;
34
+ }
35
+
36
+ /**
37
+ * `scene` seen from `camera`, into `out` as linear premultiplied RGBA.
38
+ *
39
+ * Each pixel is one ray: the nearest triangle, its interpolated normal and albedo, Lambert against
40
+ * the light, a shadow ray back to it, and a Blinn–Phong lobe whose width is the roughness. What a
41
+ * real capture would have instead of the shadow ray is a shadow in the photograph; what matters for
42
+ * the test is that the shadow is *there* and that the caller knows where.
43
+ */
44
+ export function renderLit(
45
+ scene: LitScene,
46
+ camera: RasterCamera,
47
+ light: Light,
48
+ out: Float32Array,
49
+ ): void {
50
+ const { width, height, intrinsics } = camera;
51
+ const [fx, fy, cx, cy] = intrinsics;
52
+ const m = camera.worldToCamera;
53
+ const eye = cameraCentre(m);
54
+ out.fill(0);
55
+
56
+ for (let y = 0; y < height; y += 1) {
57
+ for (let x = 0; x < width; x += 1) {
58
+ const ahead = toWorld(m, [(x + 0.5 - cx) / fx, (y + 0.5 - cy) / fy, 1]);
59
+ const direction = normalise([
60
+ (ahead[0] as number) - (eye[0] as number),
61
+ (ahead[1] as number) - (eye[1] as number),
62
+ (ahead[2] as number) - (eye[2] as number),
63
+ ]);
64
+ const hit = nearestHit(scene.mesh, eye, direction, 1e-4, Infinity);
65
+ const pixel = (y * width + x) * 4;
66
+ if (hit === null) {
67
+ out[pixel + 3] = 1;
68
+ continue;
69
+ }
70
+ const point = [0, 1, 2].map(
71
+ (k) => (eye[k] as number) + hit.distance * (direction[k] as number),
72
+ ) as [number, number, number];
73
+ const normal = interpolate(scene.mesh, scene.mesh.normals, hit, 3);
74
+ const albedo = interpolate(scene.mesh, scene.albedo, hit, 3);
75
+ const rough = interpolate(scene.mesh, scene.roughness, hit, 1)[0] as number;
76
+
77
+ const facing = dot(normal, light.direction);
78
+ /* A ray back to the light, against the same triangles: a hard shadow with a hard edge. */
79
+ const lit =
80
+ facing > 0 &&
81
+ nearestHit(scene.mesh, point, light.direction as [number, number, number], 1e-3, 40) ===
82
+ null;
83
+ const toEye = normalise([
84
+ (eye[0] as number) - point[0],
85
+ (eye[1] as number) - point[1],
86
+ (eye[2] as number) - point[2],
87
+ ]);
88
+ const half = normalise([
89
+ toEye[0] + (light.direction[0] as number),
90
+ toEye[1] + (light.direction[1] as number),
91
+ toEye[2] + (light.direction[2] as number),
92
+ ]);
93
+ /*
94
+ * **A Blinn–Phong lobe that widens *and weakens* as the surface roughens.** The exponent
95
+ * alone is not enough: with a strength that does not fall, a rough surface comes out with an
96
+ * enormous broad highlight over the whole of it, which is not a surface anybody has seen. It
97
+ * also made this fixture's floor 1.77 times too bright through a delighting that was working
98
+ * correctly — the light really was mostly specular, so the recovery was right about a scene
99
+ * nobody would build.
100
+ */
101
+ const square = rough * rough;
102
+ const shininess = 2 / Math.max(1e-4, square * square) - 2;
103
+ /*
104
+ * **Normalised, which is the difference between a rough surface and a bright one.** A lobe
105
+ * that widens without losing energy puts a broad sheen over the whole of a rough surface,
106
+ * and it is not a small effect: measured here, the dark channels of a matte blue floor came
107
+ * back nearly twice as bright through a delighting that was working correctly. The peak of a
108
+ * Blinn–Phong lobe scales as (n + 2) / 8π, and 0.04 is a dielectric's reflectance head-on.
109
+ */
110
+ const strength = (0.04 * (shininess + 2)) / (8 * Math.PI);
111
+ /*
112
+ * The power by the engine's own reproducible pair rather than `Math.pow`, which the
113
+ * determinism gate refuses — two engines may differ by a unit in the last place, and a
114
+ * fixture that renders differently on two machines is a test nobody can compare.
115
+ */
116
+ const facingHalf = Math.max(0, dot(normal, half));
117
+ const lobe = facingHalf > 0 ? exactExp(shininess * exactLog(facingHalf)) : 0;
118
+ const specular = lit ? lobe * strength : 0;
119
+
120
+ for (let c = 0; c < 3; c += 1) {
121
+ const irradiance = (lit ? facing : 0) * (light.colour[c] as number) + light.ambient;
122
+ out[pixel + c] =
123
+ (albedo[c] as number) * irradiance + specular * (light.colour[c] as number);
124
+ }
125
+ out[pixel + 3] = 1;
126
+ }
127
+ }
128
+ }
129
+
130
+ /** Where a camera stands, from its 3 × 4 world-to-camera. */
131
+ export function cameraCentre(worldToCamera: Float64Array): [number, number, number] {
132
+ const out: [number, number, number] = [0, 0, 0];
133
+ for (let r = 0; r < 3; r += 1) {
134
+ for (let c = 0; c < 3; c += 1) {
135
+ out[c] -= (worldToCamera[r * 4 + c] as number) * (worldToCamera[r * 4 + 3] as number);
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** A camera-space point in the world: the projection run backwards. */
142
+ function toWorld(
143
+ m: Float64Array,
144
+ view: readonly [number, number, number],
145
+ ): [number, number, number] {
146
+ const out: [number, number, number] = [0, 0, 0];
147
+ for (let r = 0; r < 3; r += 1) {
148
+ for (let c = 0; c < 3; c += 1) {
149
+ out[c] += (m[r * 4 + c] as number) * ((view[r] as number) - (m[r * 4 + 3] as number));
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ export interface MeshHit {
156
+ readonly triangle: number;
157
+ readonly distance: number;
158
+ /** Barycentric weights of the three corners, in order. */
159
+ readonly weights: readonly [number, number, number];
160
+ }
161
+
162
+ /** The nearest triangle a ray meets, by Möller–Trumbore over every one of them. */
163
+ export function nearestHit(
164
+ mesh: MeshData,
165
+ origin: readonly [number, number, number],
166
+ direction: readonly [number, number, number],
167
+ near: number,
168
+ far: number,
169
+ ): MeshHit | null {
170
+ let best: MeshHit | null = null;
171
+ const triangles = mesh.indices.length / 3;
172
+ for (let face = 0; face < triangles; face += 1) {
173
+ const a = mesh.indices[face * 3] as number;
174
+ const b = mesh.indices[face * 3 + 1] as number;
175
+ const c = mesh.indices[face * 3 + 2] as number;
176
+ const e1 = corner(mesh, b, a);
177
+ const e2 = corner(mesh, c, a);
178
+ const p = cross(direction, e2);
179
+ const determinant = dot(e1, p);
180
+ if (Math.abs(determinant) < 1e-12) continue;
181
+ const inverse = 1 / determinant;
182
+ const t = [0, 1, 2].map(
183
+ (k) => (origin[k] as number) - (mesh.positions[a * 3 + k] as number),
184
+ ) as [number, number, number];
185
+ const u = dot(t, p) * inverse;
186
+ if (u < 0 || u > 1) continue;
187
+ const q = cross(t, e1);
188
+ const v = dot(direction, q) * inverse;
189
+ if (v < 0 || u + v > 1) continue;
190
+ const distance = dot(e2, q) * inverse;
191
+ if (distance < near || distance > far) continue;
192
+ if (best !== null && distance >= best.distance) continue;
193
+ best = { triangle: face, distance, weights: [1 - u - v, u, v] };
194
+ }
195
+ return best;
196
+ }
197
+
198
+ /** A per-vertex attribute at a hit, by its barycentric weights. */
199
+ export function interpolate(
200
+ mesh: MeshData,
201
+ values: ArrayLike<number>,
202
+ hit: MeshHit,
203
+ stride: number,
204
+ ): number[] {
205
+ const out = new Array<number>(stride).fill(0);
206
+ for (let corner = 0; corner < 3; corner += 1) {
207
+ const vertex = mesh.indices[hit.triangle * 3 + corner] as number;
208
+ const weight = hit.weights[corner] as number;
209
+ for (let k = 0; k < stride; k += 1) {
210
+ out[k] = (out[k] as number) + weight * (values[vertex * stride + k] as number);
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ function corner(mesh: MeshData, to: number, from: number): [number, number, number] {
217
+ return [0, 1, 2].map(
218
+ (k) => (mesh.positions[to * 3 + k] as number) - (mesh.positions[from * 3 + k] as number),
219
+ ) as [number, number, number];
220
+ }
221
+
222
+ function cross(
223
+ a: readonly [number, number, number],
224
+ b: readonly [number, number, number],
225
+ ): [number, number, number] {
226
+ return [
227
+ (a[1] as number) * (b[2] as number) - (a[2] as number) * (b[1] as number),
228
+ (a[2] as number) * (b[0] as number) - (a[0] as number) * (b[2] as number),
229
+ (a[0] as number) * (b[1] as number) - (a[1] as number) * (b[0] as number),
230
+ ];
231
+ }
232
+
233
+ function dot(a: readonly number[], b: readonly number[]): number {
234
+ return (
235
+ (a[0] as number) * (b[0] as number) +
236
+ (a[1] as number) * (b[1] as number) +
237
+ (a[2] as number) * (b[2] as number)
238
+ );
239
+ }
240
+
241
+ function normalise(v: readonly number[]): [number, number, number] {
242
+ const length = Math.sqrt(
243
+ (v[0] as number) * (v[0] as number) +
244
+ (v[1] as number) * (v[1] as number) +
245
+ (v[2] as number) * (v[2] as number),
246
+ );
247
+ const scale = length > 0 ? 1 / length : 0;
248
+ return [(v[0] as number) * scale, (v[1] as number) * scale, (v[2] as number) * scale];
249
+ }