PageSourceSearch

https://vibelux.ai/workers/raytrace.js

js vibelux.ai collected 2026-10-05 13:17:31 UTC 19,258 bytes, 548 lines download raw bytes

1/**
2 * VibeLux path-tracer worker.
3 *
4 * Forward Monte Carlo path tracer. Emits samples from each fixture using
5 * IES-weighted importance sampling (or Lambertian fall-back), lets them
6 * bounce off room walls and cart side panels, and deposits their weight
7 * onto tier-plane grids when they land inside a cart footprint.
8 *
9 * Scene units: meters. PPF in umol/s. Reflectances in 0..1.
10 *
11 * Message contract is defined in src/lib/raytrace/types.ts.
12 */
13
14'use strict';
15
16/* =========================================================================
17 *  RNG  — tiny 32-bit mulberry for reproducibility. Good enough for MC.
18 * ========================================================================= */
19function makeRng(seed) {
20  let s = (seed >>> 0) || 1;
21  return function rng() {
22    s |= 0;
23    s = (s + 0x6D2B79F5) | 0;
24    let t = Math.imul(s ^ (s >>> 15), 1 | s);
25    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
26    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27  };
28}
29
30/* =========================================================================
31 *  Vector helpers (inlined, scalar-fast).
32 * ========================================================================= */
33function vnorm(v) {
34  const m = Math.hypot(v[0], v[1], v[2]) || 1;
35  return [v[0] / m, v[1] / m, v[2] / m];
36}
37function vscale(v, s) { return [v[0] * s, v[1] * s, v[2] * s]; }
38function vadd(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; }
39function vdot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
40function vcross(a, b) {
41  return [
42    a[1] * b[2] - a[2] * b[1],
43    a[2] * b[0] - a[0] * b[2],
44    a[0] * b[1] - a[1] * b[0],
45  ];
46}
47
48/**
49 * Build a tangent-space basis given a unit normal.
50 */
51function orthonormalBasis(n) {
52  // Pick an arbitrary non-parallel axis then Gram-Schmidt.
53  const a = Math.abs(n[0]) > 0.9 ? [0, 1, 0] : [1, 0, 0];
54  const t = vnorm(vcross(a, n));
55  const b = vcross(n, t);
56  return [t, b, n];
57}
58
59/**
60 * Cosine-weighted hemisphere sample around normal n.
61 */
62function sampleCosineHemisphere(rng, n) {
63  const u1 = rng();
64  const u2 = rng();
65  const r = Math.sqrt(u1);
66  const phi = 2 * Math.PI * u2;
67  const x = r * Math.cos(phi);
68  const y = r * Math.sin(phi);
69  const z = Math.sqrt(Math.max(0, 1 - u1));
70  const [t, b, nn] = orthonormalBasis(n);
71  return [
72    t[0] * x + b[0] * y + nn[0] * z,
73    t[1] * x + b[1] * y + nn[1] * z,
74    t[2] * x + b[2] * y + nn[2] * z,
75  ];
76}
77
78/* =========================================================================
79 *  IES / Lambertian emission sampling.
80 *
81 *  The IES grid is (gammaSteps x phiSteps). gamma is the polar angle from
82 *  the fixture's down-direction (0 = on-axis), phi the azimuth around it.
83 *  We build a 1D CDF over grid cells weighted by I * sin(gamma) to get
84 *  uniform-in-solid-angle sampling proportional to intensity.
85 * ========================================================================= */
86function prepIesCdf(ies) {
87  if (!ies) return null;
88  const G = ies.gammaSteps;
89  const P = ies.phiSteps;
90  const vals = ies.values;
91  const cdf = new Float64Array(G * P);
92  let acc = 0;
93  for (let gi = 0; gi < G; gi++) {
94    // Solid-angle weight per cell; mid-point sample of gamma.
95    const gamma = (gi + 0.5) * Math.PI / G;
96    const sinG = Math.sin(gamma);
97    for (let pi = 0; pi < P; pi++) {
98      const idx = gi * P + pi;
99      acc += Math.max(0, vals[idx]) * sinG;
100      cdf[idx] = acc;
101    }
102  }
103  // Degenerate table: fall back to isotropic-cos sampling.
104  if (acc <= 0) return null;
105  return { cdf, total: acc, G, P };
106}
107
108/**
109 * Binary-search a CDF for the bin containing r*total.
110 */
111function cdfPick(cdf, total, r) {
112  const target = r * total;
113  let lo = 0, hi = cdf.length - 1;
114  while (lo < hi) {
115    const mid = (lo + hi) >> 1;
116    if (cdf[mid] < target) lo = mid + 1;
117    else hi = mid;
118  }
119  return lo;
120}
121
122/**
123 * Sample an emission direction in the fixture's LOCAL frame
124 * (z' = aim direction). Returns a unit vector.
125 */
126function sampleEmissionLocal(rng, iesPrepped) {
127  if (!iesPrepped) {
128    // Lambertian emitter on the hemisphere centered on +z' (local).
129    return sampleCosineHemisphere(rng, [0, 0, 1]);
130  }
131  const { cdf, total, G, P }
131 = iesPrepped;
132  const bin = cdfPick(cdf, total, rng());
133  const gi = (bin / P) | 0;
134  const pi = bin - gi * P;
135  // Jitter within the cell.
136  const gamma = (gi + rng()) * Math.PI / G;
137  const phi = (pi + rng()) * 2 * Math.PI / P;
138  const sg = Math.sin(gamma);
139  // In local frame, aim direction is +z'. gamma = 0 -> along +z'.
140  // But real fixtures aim DOWN, so we will rotate after.
141  return [sg * Math.cos(phi), sg * Math.sin(phi), Math.cos(gamma)];
142}
143
144/**
145 * Rotate a local-frame direction into world frame using fixture's aim dir.
146 * The aim direction is the "forward" (gamma=0) axis.
147 */
148function rotateIntoAim(localDir, aim) {
149  // Build basis where +z world-local = aim
150  const [t, b, n] = orthonormalBasis(vnorm(aim));
151  return [
152    t[0] * localDir[0] + b[0] * localDir[1] + n[0] * localDir[2],
153    t[1] * localDir[0] + b[1] * localDir[1] + n[1] * localDir[2],
154    t[2] * localDir[0] + b[2] * localDir[1] + n[2] * localDir[2],
155  ];
156}
157
158/* =========================================================================
159 *  Scene surfaces.
160 *
161 *  Two surface kinds: bounded AA rectangles (for room walls, cart sides,
162 *  cart tiers). Each surface records:
163 *    - axis:   which world axis is its normal (0=x, 1=y, 2=z)
164 *    - sign:   +1 or -1 (normal direction along that axis)
165 *    - plane:  constant on that axis
166 *    - umin,umax,vmin,vmax: bounds in the other two axes
167 *    - kind:   'room' | 'cartWall' | 'tier'
168 *    - rho:    reflectance 0..1
169 *    - tau:    transmittance 0..1 (tiers only; 0 otherwise)
170 *    - ref:    backref object for tiers (cart index, tier index, grid)
171 * ========================================================================= */
172function buildScene(req) {
173  const surfaces = [];
174  const tiers = []; // flat list of tier refs (cartIdx, tierIdx, grid, bounds)
175
176  // Room: 6 inner planes (normals face INTO the room).
177  const R = req.room;
178  const rho = R.reflectances;
179  // Floor: z=0, normal +z
180  surfaces.push(makeRect(2, +1, 0, 0, R.width, 0, R.length, 'room', rho.floor, 0, null));
181  // Ceiling: z=H, normal -z
182  surfaces.push(makeRect(2, -1, R.height, 0, R.width, 0, R.length, 'room', rho.ceiling, 0, null));
183  // West wall: x=0, normal +x
184  surfaces.push(makeRect(0, +1, 0, 0, R.length, 0, R.height, 'room', rho.walls, 0, null));
185  // East wall: x=W, normal -x
186  surfaces.push(makeRect(0, -1, R.width, 0, R.length, 0, R.height, 'room', rho.walls, 0, null));
187  // South wall: y=0, normal +y
188  surfaces.push(makeRect(1, +1, 0, 0, R.width, 0, R.height, 'room', rho.walls, 0, null));
189  // North wall: y=L, normal -y
190  surfaces.push(makeRect(1, -1, R.length, 0, R.width, 0, R.height, 'room', rho.walls, 0, null));
191
192  // Carts.
193  req.carts.forEach((cart, ci) => {
194    const x0 = cart.x - cart.width / 2;
195    const x1 = cart.x + cart.width / 2;
196    const y0 = cart.y - cart.length / 2;
197    const y1 = cart.y + cart.length / 2;
198    // Tiers — horizontal planes, doubled (top face +z, bottom face -z) so
199    // rays from above and below both see a surface.
200    cart.tiers.forEach((tier, ti) => {
201      const grid = new Float32Array(tier.gridRows * tier.gridCols);
202      const cellW = cart.width / tier.gridCols;
203      const cellL = cart.length / tier.gridRows;
204      const tierRef = {
205        cartIdx: ci,
206        tierIdx: ti,
207        cartId: cart.id,
208        tierId: tier.id,
209        grid,
210        gridRows: tier.gridRows,
211        gridCols: tier.gridCols,
212        cellWidth: cellW,
213        cellLength: cellL,
214        cellArea: cellW * cellL,
215        bounds: { x0, x1, y0, y1 },
216        targetPPFD: tier.targetPPFD,
217        trays: Array.isArray(tier.trays) ? tier.trays : [],
218      };
219      tiers.push(tierRef);
220      // Top face (normal +z)
221      surfaces.push(makeRect(2, +1, tier.z, x0, x1, y0, y1, 'tier', tier.reflectance, tier.transmittance, tierRef));
222      // Bottom face (normal -z) — for interreflected light coming from below
223      surfaces.push(makeRect(2, -1, tier.z, x0, x1, y0, y1, 'tier', tier.reflectance, tier.transmittance, tierRef));
224    });
225  });
226
227  // Calc-surface meter planes (transparent, deposit-then-continue).
228  const calcs = [];
229  const calcSurfaceList = Array.isArray(req.calcSurfaces) ? req.calcSurfaces : [];
230  calcSurfaceList.forEach((cs, csi) => {
231    const grid = new Float32Array(cs.gridRows * cs.gridCols);
232    const cellW = (cs.xMax - cs.xMin) / cs.gridCols;
233    const cellL = (cs.yMax - cs.yMin) / cs.gridRows;
234    const csRef = {
235      calcIdx: csi,
236      id: cs.id,
237      grid,
238      gridRows: cs.gridRows,
239      gridCols: cs.gridCols,
240      cellWidth: cellW,
241      cellLength: cellL,
242      cellArea: cellW * cellL,
243      bounds: { x0: cs.xMin, x1: cs.xMax, y0: cs.yMin, y1: cs.yMax },
244      targetPPFD: cs.targetPPFD,
245    };
246    calcs.push(csRef);
247    // Two-sided meter plane so it records flux from both above and below.
248    surfaces.push(makeRect(2, +1, cs.z, cs.xMin, cs.xMax, cs.yMin, cs.yMax, 'meter', 0, 1, csRef));
249    surfaces.push(makeRect(2, -1, cs.z, cs.xMin, cs.xMax, cs.yMin, cs.yMax, 'meter', 0, 1, csRef));
250  });
251
252  // Separate meter surfaces — they don't participate in nearest-hit
253  // intersection. They deposit along the ray path instead.
254  const meterSurfaces = surfaces.filter(function(sx) { return sx.
254kind === 'meter'; });
255  const solidSurfaces = surfaces.filter(function(sx) { return sx.kind !== 'meter'; });
256  return { surfaces: solidSurfaces, meters: meterSurfaces, tiers: tiers, calcs: calcs, room: R };
257}
258
259function makeRect(axis, sign, plane, umin, umax, vmin, vmax, kind, rho, tau, ref) {
260  return { axis, sign, plane, umin, umax, vmin, vmax, kind, rho, tau, ref };
261}
262
263/* =========================================================================
264 *  Ray-surface intersection.
265 *
266 *  For each AA rectangle: plane test on `axis`, then bounds test on the
267 *  other two axes. Keeps only the nearest hit in front of the ray.
268 * ========================================================================= */
269function intersectScene(origin, dir, surfaces, tMin) {
270  let bestT = Infinity;
271  let bestSurf = null;
272  for (let i = 0; i < surfaces.length; i++) {
273    const s = surfaces[i];
274    const od = dir[s.axis];
275    if (od === 0) continue;
276    // Only hit surfaces whose normal faces the incoming ray.
277    // normal = sign along axis; ray hits front face when -sign * od > 0.
278    if (-s.sign * od <= 0) continue;
279    const t = (s.plane - origin[s.axis]) / od;
280    if (t <= tMin || t >= bestT) continue;
281    // Bounds check on the other two axes.
282    const ax0 = (s.axis + 1) % 3;
283    const ax1 = (s.axis + 2) % 3;
284    const u = origin[ax0] + t * dir[ax0];
285    const v = origin[ax1] + t * dir[ax1];
286    if (u < s.umin || u > s.umax || v < s.vmin || v > s.vmax) continue;
287    bestT = t;
288    bestSurf = s;
289  }
290  return bestSurf ? { t: bestT, surf: bestSurf } : null;
291}
292
293/* =========================================================================
294 *  Trace one ray from a fixture. Deposits onto tier grids along the way.
295 * ========================================================================= */
296function traceRay(scene, rng, origin, dir, weightPPF, maxBounces) {
297  let o = origin;
298  let d = vnorm(dir);
299  let w = weightPPF;
300  const EPS = 1e-5;
301  const surfaces = scene.surfaces;
302
303  for (let bounce = 0; bounce <= maxBounces; bounce++) {
304    if (w <= 0) return;
305    const hit = intersectScene(o, d, surfaces, EPS);
306    const hitT = hit ? hit.t : Infinity;
307
308    // Meter-plane crossings: deposit along the segment (EPS, hitT].
309    // Transparent — does not consume weight or alter direction.
310    const meters = scene.meters;
311    if (meters && meters.length > 0) {
312      for (let mi = 0; mi < meters.length; mi++) {
313        const ms = meters[mi];
314        const od = d[ms.axis];
315        if (od === 0) continue;
316        if (-ms.sign * od <= 0) continue;
317        const tm = (ms.plane - o[ms.axis]) / od;
318        if (tm <= EPS || tm > hitT) continue;
319        const ax0 = (ms.axis + 1) % 3;
320        const ax1 = (ms.axis + 2) % 3;
321        const u = o[ax0] + tm * d[ax0];
322        const v = o[ax1] + tm * d[ax1];
323        if (u < ms.umin || u > ms.umax || v < ms.vmin || v > ms.vmax) continue;
324        const ref = ms.ref;
325        const b0 = ref.bounds;
326        const cx0 = u - b0.x0;
327        const cy0 = v - b0.y0;
328        const col0 = Math.min(ref.gridCols - 1, Math.max(0, Math.floor(cx0 / ref.cellWidth)));
329        const row0 = Math.min(ref.gridRows - 1, Math.max(0, Math.floor(cy0 / ref.cellLength)));
330        ref.grid[row0 * ref.gridCols + col0] += w / ref.cellArea;
331      }
332    }
333
334    if (!hit) return;
335    const s = hit.surf;
336    const t = hit.t;
337    const p = [o[0] + t * d[0], o[1] + t * d[1], o[2] + t * d[2]];
338    const n = [0, 0, 0];
339    n[s.axis] = s.sign;
340
341    if (s.kind === 'tier') {
342      // Deposit incident flux onto the grid cell.
343      const ref = s.ref;
344      const b = ref.bounds;
345      const cx = p[0] - b.x0;
346      const cy = p[1] - b.y0;
347      const col = Math.min(ref.gridCols - 1, Math.max(0, Math.floor(cx / ref.cellWidth)));
348      const row = Math.min(ref.gridRows - 1, Math.max(0, Math.floor(cy / ref.cellLength)));
349      // PPFD contribution: photon flux (umol/s) per cell area (m^2).
350      ref.grid[row * ref.gridCols + col] += w / ref.cellArea;
351
352      // Branch on tau (pass-through), rho (reflect), absorb.
353      const tau = s.tau;
354      const rho = s.rho;
355      const r = rng();
356      if (r < tau) {
357        // Pass through with same direction, reduced weight.
358        o = vadd(p, vscale(d, EPS));
359        w *= 1; // tau already implied by branch probability
360        continue;
361      } else if (r < tau + rho) {
362        // Lambertian reflect, same-side hemisphere.
363        o = vadd(p, vscale(n, EPS));
364        d = sampleCosineHemisphere(rng, n);
365        // weight unchanged — branch probability already accounts for rho.
366        continue;
367      } else {
368        return; // absorbed
369      }
370    } else {
371      // Room wall or cart sidewall — lambertian reflect or absorb.
372      if (rng() < s.rho) {
373        o = vadd(p, vscale(n, EPS));
374        d = sampleCosineHemisphere(rng, n);
375        // weight unchanged
376        continue;
377      }
378      return;
379    }
380  }
381}
382
383/* =========================================================================
384 *  Main trace loop.
385 * ========================================================================= */
386function runTrace(req) {
387  const rng = makeRng(req.seed || 1);
388  const scene = buildScene(req);
389  const fixIes = req.fixtures.map((fx) => prepIesCdf(fx.ies));
390  const N = req.samplesPerFixture;
391  const F = req.fixtures.length;
392  const totalSamples = N * F;
393
394  const tStart = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
395  let traced = 0;
396  let lastReported = 0;
397  const progressStep = Math.max(1, Math.floor(totalSamples / 50));
398
399  for (let fi = 0; fi < F; fi++) {
400    const fx = req.fixtures[fi];
401    const iesP = fixIes[fi];
402    const aim = vnorm(fx.dir);
403    const originPos = [fx.x, fx.y, fx.z];
404    const weightPPF = fx.ppf / N;
405
406    for (let i = 0; i < N; i++) {
407      const localDir = sampleEmissionLocal(rng, iesP);
408      const worldDir = rotateIntoAim(localDir, aim);
409      traceRay(scene, rng, originPos, worldDir, weightPPF, req.maxBounces);
410      traced++;
411      if (traced - lastReported >= progressStep) {
412        lastReported = traced;
413        self.postMessage({
414          type: 'progress',
415          runId: req.runId,
416          progress: traced / totalSamples,
417          samplesTraced: traced,
418        });
419      }
420    }
421  }
422
423  const elapsedMs = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - tStart;
424
425  // Aggregate per-tier + per-tray stats. Transfer grid buffers for speed.
426  const tierResults = [];
427  const transfer = [];
428  for (const t of scene.tiers) {
429    const g = t.grid;
430    let sum = 0, mn = Infinity, mx = 0;
431    for (let k = 0; k < g.length; k++) {
432      const v = g[k];
433      if (v < mn) mn = v;
434      if (v > mx) mx = v;
435      sum += v;
436    }
437    const avg = sum / g.length;
438    const unif = avg > 0 ? (mn / avg) : 0;
439
440    // Per-tray aggregation: for each tray, find the row/col cell range
441    // whose centres fall inside the tray bounds and compute stats.
442    const trayResults = [];
443    for (const tr of (t.trays || [])) {
444      let tSum = 0, tMin = Infinity, tMax = 0, tCells = 0;
445      // Cell indices spanning the tray (inclusive).
446      const cColMin = Math.max(0, Math.floor(tr.uMin / t.cellWidth));
447      const cColMax = Math.min(t.gridCols - 1, Math.floor((tr.uMax - 1e-9) / t.cellWidth));
448      const cRowMin = Math.max(0, Math.floor(tr.vMin / t.cellLength));
449      const cRowMax = Math.min(t.gridRows - 1, Math.floor((tr.vMax - 1e-9) / t.cellLength));
450      for (let r = cRowMin; r <= cRowMax; r++) {
451        for (let c = cColMin; c <= cColMax; c++) {
452          const v = g[r * t.gridCols + c];
453          if (v < tMin) tMin = v;
454          if (v > tMax) tMax = v;
455          tSum += v;
456          tCells++;
457        }
458      }
459      const tAvg = tCells > 0 ? tSum / tCells : 0;
460      const tUnif = tAvg > 0 ? (tMin / tAvg) : 0;
461      trayResults.push({
462        id: tr.id,
463        uMin: tr.uMin, uMax: tr.uMax, vMin: tr.vMin, vMax: tr.vMax,
464        cellCount: tCells,
465        min: tMin === Infinity ? 0 : tMin,
466        avg: tAvg,
467        max: tMax,
468        uniformity: tUnif,
469      });
470    }
471
472    tierResults.push({
473      cartId: t.cartId,
474      tierId: t.tierId,
475      grid: g,
476      gridRows: t.gridRows,
477      gridCols: t.gridCols,
478      cellWidth: t.cellWidth,
479      cellLength: t.cellLength,
480      min: mn === Infinity ? 0 : mn,
481      avg,
482      max: mx,
483      uniformity: unif,
484      targetPPFD: t.targetPPFD,
485      trays: trayResults,
486    });
487    transfer.push(g.buffer);
488  }
489
490  // Calc-surface aggregates.
491  const calcResults = [];
492  const calcs = scene.calcs || [];
493  for (const c of calcs) {
494    const g = c.grid;
495    let sum = 0, mn = Infinity, mx = 0;
496    for (let k = 0; k < g.length; k++) {
497      const v = g[k];
498      if (v < mn) mn = v;
499      if (v > mx) mx = v;
500      sum += v;
501    }
502    const avg = sum / g.length;
503    const unif = avg > 0 ? (mn / avg) : 0;
504    calcResults.push({
505      id: c.id,
506      grid: g,
507      gridRows: c.gridRows,
508      gridCols: c.gridCols,
509      cellWidth: c.cellWidth,
510      cellLength: c.cellLength,
511      min: mn === Infinity ? 0 : mn,
512      avg,
513      max: mx,
514      uniformity: unif,
515      targetPPFD: c.targetPPFD,
516    });
517    transfer.push(g.buffer);
518  }
519
520  self.postMessage(
521    {
522      type: 'result',
523      runId: req.runId,
524      tiers: tierResults,
525      calcSurfaces: calcResults,
526      elapsedMs,
527      samplesTotal: totalSamples,
528    },
529    transfer
530  );
531}
532
533/* =========================================================================
534 *  Message dispatch.
535 * ========================================================================= */
536self.addEventListener('message', function (e) {
537  const msg = e.data;
538  if (!msg || msg.type !== 'trace') return;
539  try {
540    runTrace(msg);
541  } catch (err) {
542    self.postMessage({
543      type: 'error',
544      runId: msg.runId || '',
545      message: (err && err.message) || String(err),
546    });
547  }
548});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.