poppygl 0.0.30 → 0.0.32

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.
package/README.md CHANGED
@@ -2,11 +2,20 @@
2
2
 
3
3
  Render GLTF files to PNG images in completely native JavaScript without WebGL/OpenGL.
4
4
 
5
- <img width="996" height="732" alt="image" src="https://github.com/user-attachments/assets/5cda4566-2637-440e-8956-dff87aedbc26" />
5
+ ![NEMA17: regular PoppyGL, realistic PoppyGL, and Blender Cycles reference](docs/images/nema17-comparison.png)
6
+
7
+ Left to right: regular PoppyGL, PoppyGL with `realistic: true`, and a Blender
8
+ Cycles reference. All three views use the same model, camera, and materials.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install poppygl
14
+ ```
6
15
 
7
16
  ## Quick start
8
17
 
9
- Give poppygl a `.gltf` or `.glb` URL and it will fetch every referenced buffer/texture, rasterize the scene, and hand back a PNG buffer.
18
+ Give poppygl a `.gltf` or `.glb` URL and it will fetch every referenced buffer/texture, rasterize the scene, and return the PNG as a `Uint8Array`.
10
19
 
11
20
  ```ts
12
21
  import { renderGLTFToPNGFromURL } from "poppygl"
@@ -28,7 +37,7 @@ await writeFile("DamagedHelmet.png", png)
28
37
 
29
38
  ## Render from an in-memory GLB buffer
30
39
 
31
- Already have the `.glb` bytes (for example, uploaded by a user or read from disk)? Send the buffer straight to `renderGLTFToPNGFromGLB` and receive the PNG output.
40
+ Already have the `.glb` bytes (for example, uploaded by a user or read from disk)? Send the buffer straight to `renderGLTFToPNGFromGLB` and receive the PNG as a `Uint8Array`.
32
41
 
33
42
  ```ts
34
43
  import { readFile } from "node:fs/promises"
@@ -40,18 +49,75 @@ const png = await renderGLTFToPNGFromGLB(glb, {
40
49
  height: 768,
41
50
  })
42
51
 
43
- // write or return the PNG buffer
52
+ await writeFile("DamagedHelmet.png", png)
44
53
  ```
45
54
 
46
55
  > This helper expects every referenced buffer/image to be embedded in the GLB (the usual single-file package). If the asset links out to external resources, load it with `renderGLTFToPNGFromURL` instead so poppygl can fetch the extras.
47
56
 
48
57
  ## Render options
49
58
 
59
+ ### Realistic studio rendering
60
+
61
+ Pass `realistic: true` for studio lighting, metal reflections, and soft shadows.
62
+ The default is `false`, which uses the faster diffuse renderer.
63
+
64
+ ```ts
65
+ import { readFile, writeFile } from "node:fs/promises"
66
+ import { renderGLTFToPNGFromGLB } from "poppygl"
67
+
68
+ const glb = await readFile("motor.glb")
69
+ const png = await renderGLTFToPNGFromGLB(glb, {
70
+ realistic: true,
71
+ width: 900,
72
+ height: 900,
73
+ supersampling: 2,
74
+ })
75
+
76
+ await writeFile("motor.png", png)
77
+ ```
78
+
79
+ The same flag works with `renderGLTFToPNGFromURL`, `renderDrawCalls`, and
80
+ `renderSceneFromGLTF`. Realistic mode uses a fixed studio lighting preset;
81
+ `ambient` and `lightDir` apply to regular rendering. Camera, background, and
82
+ output settings work in both modes. A floor must be included in your model
83
+ if you want it to receive shadows.
84
+
85
+ Realistic mode supports base-color textures and scalar glTF metallic/roughness
86
+ values. Normal maps, metallic/roughness textures, and custom HDR environments
87
+ are not yet supported. Transparent and alpha-cutout surfaces do not cast shadows.
88
+
89
+ Use realistic mode for saved images. At 600×600 with 2× supersampling, example
90
+ models took 21–39 seconds to render, compared with 0.6–1.6 seconds in regular
91
+ mode on the same machine. Reduce resolution or supersampling for faster output;
92
+ render times vary with model complexity and hardware.
93
+
94
+ ### Camera and background
95
+
96
+ The camera automatically frames the model unless you specify `camPos` and
97
+ `lookAt`. Set `up` to match your model's coordinate system:
98
+
99
+ ```ts
100
+ const png = await renderGLTFToPNGFromGLB(glb, {
101
+ realistic: true,
102
+ width: 800,
103
+ height: 600,
104
+ camPos: [80, 60, 80],
105
+ lookAt: [0, 0, 0],
106
+ up: "y+",
107
+ fov: 45,
108
+ backgroundColor: "#e5e7eb",
109
+ grid: false,
110
+ })
111
+ ```
112
+
113
+ ### Options
114
+
50
115
  `renderGLTFToPNGFromURL` accepts the same render options as the lower-level APIs:
51
116
 
52
- - `width`/`height` (default `512`): output resolution in pixels.
117
+ - `realistic`: enable studio lighting, reflections, and soft shadows (default `false`).
118
+ - `width`/`height`: output resolution in pixels (defaults `800`/`600`).
53
119
  - `supersampling`: render at `width * supersampling` / `height * supersampling`, then downsample (default `1`).
54
- - `fov`: vertical field of view in degrees (defaults to `35`).
120
+ - `fov`: vertical field of view in degrees (default `60`).
55
121
  - `camPos` and `lookAt`: override the auto-framed camera position and target.
56
122
  - `up`: choose the world-up axis for the camera with `"y+" | "y-" | "x+" | "x-" | "z+" | "z-"`.
57
123
  - `cameraRotation`: apply Euler degrees `{ x, y, z }` to define camera orientation directly. When set, it takes precedence over `lookAt`.
@@ -59,10 +125,12 @@ const png = await renderGLTFToPNGFromGLB(glb, {
59
125
  - `debugFontSize`: optional pixel size for `debugPoints` labels; when omitted the renderer derives a size from the image dimensions.
60
126
  - `debugPointColor`: optional RGB tuple for debug point markers.
61
127
  - `debugLabelColor`: optional RGB tuple for debug point labels.
62
- - `lightDir`: normalized directional light vector (defaults to a top-right key light).
63
- - `ambient`: ambient lighting contribution (0–1, defaults to `0.2`).
64
- - `gamma`: gamma correction applied to the output (defaults to `2.2`).
65
- - `cull`: back-face culling mode (`"back"`, `"front"`, or `"none"`).
128
+ - `lightDir`: directional light vector for regular mode (default `[-0.4, -0.9, -0.2]`).
129
+ - `ambient`: ambient lighting contribution from 0 to 1 (default `0.15`; regular mode only).
130
+ - `gamma`: enable gamma correction (default `true`).
131
+ - `cull`: enable back-face culling (default `true`).
132
+ - `backgroundColor`: a hex string such as `"#e5e7eb"`, or an RGB tuple with values from 0 to 1.
133
+ - `grid`: show a ground grid (default `false`), or provide grid options.
66
134
  - `fetchImpl`: optional override for resource loading (must match the `fetch` signature).
67
135
 
68
136
  You can inspect the defaults via `getDefaultRenderOptions()` or reuse the internal merge logic with `resolveRenderOptions()`.
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ declare const createUint8Bitmap: ImageFactory;
13
13
  interface Material {
14
14
  baseColorFactor: [number, number, number, number];
15
15
  baseColorTexture: BitmapLike | null;
16
+ metallicFactor?: number;
17
+ roughnessFactor?: number;
16
18
  alphaMode?: "OPAQUE" | "MASK" | "BLEND";
17
19
  alphaCutoff?: number;
18
20
  }
@@ -75,6 +77,8 @@ interface CameraRotation {
75
77
  */
76
78
  declare function hexToRgb(hex: string): [number, number, number] | null;
77
79
  interface RenderOptions {
80
+ /** Opt into metallic/roughness studio lighting and geometry-based soft shadows. */
81
+ realistic: boolean;
78
82
  width: number;
79
83
  height: number;
80
84
  supersampling: number;
@@ -167,6 +171,18 @@ declare function isPNG(filenameOrUri: string): boolean;
167
171
  declare function isJPG(filenameOrUri: string): boolean;
168
172
  declare function decodeImageFromBuffer(buf: Uint8Array, mimeType?: string | null): Promise<BitmapLike>;
169
173
 
174
+ type V3 = [number, number, number];
175
+ declare class StudioLighting {
176
+ private tables;
177
+ private occlusion;
178
+ private eye;
179
+ private shadowDirections;
180
+ private diffuseCache;
181
+ constructor(drawCalls: DrawCall[], camera: Camera);
182
+ private bounceRadiance;
183
+ shade(position: V3, n: V3, base: V3, material: Material, sampleIndex?: number): V3;
184
+ }
185
+
170
186
  interface LightSettings {
171
187
  dir: readonly [number, number, number];
172
188
  ambient: number;
@@ -184,14 +200,16 @@ declare class SoftwareRenderer {
184
200
  readonly height: number;
185
201
  readonly bitmap: BitmapLike;
186
202
  readonly depth: Float32Array;
187
- constructor(width: number, height: number, imageFactory?: ImageFactory);
203
+ private readonly denoiser;
204
+ constructor(width: number, height: number, imageFactory?: ImageFactory, denoise?: boolean);
188
205
  get buffer(): Uint8Array<ArrayBufferLike> | Uint8ClampedArray<ArrayBufferLike>;
189
206
  clear(colorRGBA?: [number, number, number, number]): void;
190
- setPixel(x: number, y: number, r: number, g: number, b: number, a: number): void;
207
+ setPixel(x: number, y: number, r: number, g: number, b: number, a: number, keepGuide?: boolean): void;
208
+ denoise(gammaOut?: boolean): void;
191
209
  drawLines(mesh: DrawCall, camera: Camera, gammaOut?: boolean, options?: LineRenderOptions): void;
192
210
  sampleTextureNearest(img: BitmapLike | null, u: number, v: number): MutableRGBA;
193
211
  perspInterp(attrs: number[][], invWs: number[], lambdas: number[]): number[];
194
- drawMesh(mesh: DrawCall, camera: Camera, light: LightSettings, material: Material, cullBackFaces?: boolean, gammaOut?: boolean): void;
212
+ drawMesh(mesh: DrawCall, camera: Camera, light: LightSettings, material: Material, cullBackFaces?: boolean, gammaOut?: boolean, studio?: StudioLighting): void;
195
213
  }
196
214
 
197
215
  /**
package/dist/index.js CHANGED
@@ -140,13 +140,16 @@ function getAABBCornerDistances(aabb, eye) {
140
140
  const xs = [aabb.min[0], aabb.max[0]];
141
141
  const ys = [aabb.min[1], aabb.max[1]];
142
142
  const zs = [aabb.min[2], aabb.max[2]];
143
- let nearest = Infinity;
143
+ const nearest = Math.hypot(
144
+ ...[0, 1, 2].map(
145
+ (axis) => Math.max(aabb.min[axis] - eye[axis], 0, eye[axis] - aabb.max[axis])
146
+ )
147
+ );
144
148
  let farthest = 0;
145
149
  for (const x of xs) {
146
150
  for (const y of ys) {
147
151
  for (const z of zs) {
148
152
  const distance = vec3.distance(eye, vec3.fromValues(x, y, z));
149
- nearest = Math.min(nearest, distance);
150
153
  farthest = Math.max(farthest, distance);
151
154
  }
152
155
  }
@@ -287,8 +290,8 @@ function hasCrease(normals, minimumDot) {
287
290
  for (let j = i + 1; j < normals.length; j++) {
288
291
  const a = normals[i];
289
292
  const b = normals[j];
290
- const dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
291
- if (dot < minimumDot) return true;
293
+ const dot2 = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
294
+ if (dot2 < minimumDot) return true;
292
295
  }
293
296
  }
294
297
  return false;
@@ -469,6 +472,7 @@ function hexToRgb(hex) {
469
472
  return [r, g, b];
470
473
  }
471
474
  var DEFAULT_RENDER_OPTIONS = {
475
+ realistic: false,
472
476
  width: 800,
473
477
  height: 600,
474
478
  supersampling: 1,
@@ -583,22 +587,97 @@ function clipMeshToNearPlane(mesh, mvp) {
583
587
  };
584
588
  }
585
589
 
590
+ // lib/render/GeometryDenoiser.ts
591
+ var GeometryDenoiser = class {
592
+ constructor(width, height) {
593
+ this.width = width;
594
+ this.height = height;
595
+ const count = width * height;
596
+ this.ids = new Int32Array(count);
597
+ this.normals = new Float32Array(count * 3);
598
+ this.albedo = new Float32Array(count * 3);
599
+ this.radiance = new Float32Array(count * 3);
600
+ this.distance = new Float32Array(count);
601
+ }
602
+ ids;
603
+ normals;
604
+ albedo;
605
+ radiance;
606
+ distance;
607
+ materials = /* @__PURE__ */ new WeakMap();
608
+ nextId = 1;
609
+ materialId(material) {
610
+ let id = this.materials.get(material);
611
+ if (!id) {
612
+ id = this.nextId++;
613
+ this.materials.set(material, id);
614
+ }
615
+ return id;
616
+ }
617
+ record(index, id, n, albedo, radiance, distance) {
618
+ this.ids[index] = id;
619
+ this.normals.set(n, index * 3);
620
+ this.albedo.set(albedo, index * 3);
621
+ this.radiance.set(radiance, index * 3);
622
+ this.distance[index] = distance;
623
+ }
624
+ apply(output, gamma) {
625
+ const kernel = [1, 4, 6, 4, 1];
626
+ for (let y = 0; y < this.height; y++)
627
+ for (let x = 0; x < this.width; x++) {
628
+ const index = y * this.width + x, id = this.ids[index];
629
+ if (id === 0) continue;
630
+ const k = index * 3, n = this.normals, a = this.albedo;
631
+ let r = 0, g = 0, b = 0, total = 0;
632
+ for (let dy = -2; dy <= 2; dy++)
633
+ for (let dx = -2; dx <= 2; dx++) {
634
+ const px = x + dx, py = y + dy;
635
+ if (px < 0 || py < 0 || px >= this.width || py >= this.height)
636
+ continue;
637
+ const neighbor = py * this.width + px, p = neighbor * 3;
638
+ if (this.ids[neighbor] !== id) continue;
639
+ if (Math.abs(this.distance[index] - this.distance[neighbor]) > this.distance[index] * 0.02)
640
+ continue;
641
+ if (n[k] * n[p] + n[k + 1] * n[p + 1] + n[k + 2] * n[p + 2] < 0.98)
642
+ continue;
643
+ if (Math.abs(a[k] - a[p]) + Math.abs(a[k + 1] - a[p + 1]) + Math.abs(a[k + 2] - a[p + 2]) > 0.03)
644
+ continue;
645
+ const weight = kernel[dy + 2] * kernel[dx + 2];
646
+ total += weight;
647
+ r += this.radiance[p] * weight;
648
+ g += this.radiance[p + 1] * weight;
649
+ b += this.radiance[p + 2] * weight;
650
+ }
651
+ if (!total) continue;
652
+ const encode2 = (value) => Math.floor(
653
+ 255 * (gamma ? srgbEncodeLinear01(Math.max(0, Math.min(1, value / total))) : Math.max(0, Math.min(1, value / total)))
654
+ );
655
+ output[index * 4] = encode2(r);
656
+ output[index * 4 + 1] = encode2(g);
657
+ output[index * 4 + 2] = encode2(b);
658
+ }
659
+ }
660
+ };
661
+
586
662
  // lib/render/SoftwareRenderer.ts
587
663
  var SoftwareRenderer = class {
588
664
  width;
589
665
  height;
590
666
  bitmap;
591
667
  depth;
592
- constructor(width, height, imageFactory = createUint8Bitmap) {
668
+ denoiser;
669
+ constructor(width, height, imageFactory = createUint8Bitmap, denoise = false) {
593
670
  this.width = width;
594
671
  this.height = height;
595
672
  this.bitmap = imageFactory(width, height);
596
673
  this.depth = new Float32Array(width * height);
674
+ this.denoiser = denoise ? new GeometryDenoiser(width, height) : null;
597
675
  }
598
676
  get buffer() {
599
677
  return this.bitmap.data;
600
678
  }
601
679
  clear(colorRGBA = [0, 0, 0, 255]) {
680
+ this.denoiser?.ids.fill(0);
602
681
  const [r, g, b, a] = colorRGBA;
603
682
  for (let i = 0; i < this.width * this.height; i++) {
604
683
  const j = i * 4;
@@ -609,14 +688,18 @@ var SoftwareRenderer = class {
609
688
  this.depth[i] = Infinity;
610
689
  }
611
690
  }
612
- setPixel(x, y, r, g, b, a) {
691
+ setPixel(x, y, r, g, b, a, keepGuide = false) {
613
692
  if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
614
693
  const idx = (y * this.width + x) * 4;
694
+ if (this.denoiser && !keepGuide) this.denoiser.ids[y * this.width + x] = 0;
615
695
  this.buffer[idx + 0] = r;
616
696
  this.buffer[idx + 1] = g;
617
697
  this.buffer[idx + 2] = b;
618
698
  this.buffer[idx + 3] = a;
619
699
  }
700
+ denoise(gammaOut = true) {
701
+ this.denoiser?.apply(this.buffer, gammaOut);
702
+ }
620
703
  drawLines(mesh, camera, gammaOut = true, options = {}) {
621
704
  const { positions, indices, model, material } = mesh;
622
705
  if (!indices) return;
@@ -752,8 +835,9 @@ var SoftwareRenderer = class {
752
835
  }
753
836
  return out;
754
837
  }
755
- drawMesh(mesh, camera, light, material, cullBackFaces = true, gammaOut = true) {
838
+ drawMesh(mesh, camera, light, material, cullBackFaces = true, gammaOut = true, studio) {
756
839
  let { positions, normals, uvs, indices, model, colors } = mesh;
840
+ const materialId = this.denoiser?.materialId(material) ?? 0;
757
841
  const view = camera.view;
758
842
  const proj = camera.proj;
759
843
  const mvp = mat43.create();
@@ -784,6 +868,7 @@ var SoftwareRenderer = class {
784
868
  const vInvW = new Float32Array(vertexCount);
785
869
  const vNDCz = new Float32Array(vertexCount);
786
870
  const vWorldN = new Array(vertexCount);
871
+ const vWorldP = studio ? new Array(vertexCount) : null;
787
872
  const vColor = new Array(vertexCount);
788
873
  for (let i = 0; i < vertexCount; i++) {
789
874
  const p = vec42.fromValues(
@@ -794,6 +879,10 @@ var SoftwareRenderer = class {
794
879
  );
795
880
  const c = vec42.create();
796
881
  vec42.transformMat4(c, p, mvp);
882
+ if (vWorldP) {
883
+ const world = vec42.transformMat4(vec42.create(), p, model);
884
+ vWorldP[i] = [world[0], world[1], world[2]];
885
+ }
797
886
  const invW = 1 / c[3];
798
887
  const ndcX = c[0] * invW;
799
888
  const ndcY = c[1] * invW;
@@ -854,6 +943,7 @@ var SoftwareRenderer = class {
854
943
  vWorldN[i1],
855
944
  vWorldN[i2]
856
945
  ];
946
+ const worldPositions = vWorldP ? [vWorldP[i0], vWorldP[i1], vWorldP[i2]] : null;
857
947
  const uv = uvs ? [
858
948
  [uvs[i0 * 2 + 0], uvs[i0 * 2 + 1]],
859
949
  [uvs[i1 * 2 + 0], uvs[i1 * 2 + 1]],
@@ -934,7 +1024,22 @@ var SoftwareRenderer = class {
934
1024
  let r = baseColor[0] * lit;
935
1025
  let g = baseColor[1] * lit;
936
1026
  let b = baseColor[2] * lit;
1027
+ if (studio && worldPositions) {
1028
+ const position = this.perspInterp(worldPositions, invW, [
1029
+ l0,
1030
+ l1,
1031
+ l2
1032
+ ]);
1033
+ [r, g, b] = studio.shade(
1034
+ position,
1035
+ nrm,
1036
+ [baseColor[0], baseColor[1], baseColor[2]],
1037
+ material,
1038
+ di
1039
+ );
1040
+ }
937
1041
  let a = baseColor[3];
1042
+ const linearR = r, linearG = g, linearB = b;
938
1043
  const alphaMode = material.alphaMode ?? "OPAQUE";
939
1044
  const alphaCutoff = material.alphaCutoff ?? 0.5;
940
1045
  if (alphaMode === "MASK" && a < alphaCutoff) continue;
@@ -976,8 +1081,22 @@ var SoftwareRenderer = class {
976
1081
  clamp(outR, 0, 1) * 255 | 0,
977
1082
  clamp(outG, 0, 1) * 255 | 0,
978
1083
  clamp(outB, 0, 1) * 255 | 0,
979
- clamp(outA, 0, 1) * 255 | 0
1084
+ clamp(outA, 0, 1) * 255 | 0,
1085
+ true
980
1086
  );
1087
+ if (this.denoiser) {
1088
+ if (alphaMode !== "BLEND") {
1089
+ const distance = 1 / (l0 * invW[0] + l1 * invW[1] + l2 * invW[2]);
1090
+ this.denoiser.record(
1091
+ di,
1092
+ materialId,
1093
+ nrm,
1094
+ [baseColor[0], baseColor[1], baseColor[2]],
1095
+ [linearR, linearG, linearB],
1096
+ distance
1097
+ );
1098
+ } else this.denoiser.ids[di] = 0;
1099
+ }
981
1100
  }
982
1101
  }
983
1102
  }
@@ -1331,6 +1450,627 @@ function resolveRenderOptions(options = {}) {
1331
1450
  };
1332
1451
  }
1333
1452
 
1453
+ // lib/render/StudioLighting.ts
1454
+ import { mat4 as mat46 } from "gl-matrix";
1455
+
1456
+ // lib/render/studio-environment.json
1457
+ var studio_environment_default = {
1458
+ ambient: [0.055, 0.065, 0.085],
1459
+ lobes: [
1460
+ { direction: [-0.45, 0.8, 0.4], sharpness: 18, color: [5, 4.8, 4.4] },
1461
+ { direction: [0.8, 0.3, 0.3], sharpness: 30, color: [2.6, 3.1, 4] },
1462
+ { direction: [-0.2, 0.3, -0.95], sharpness: 50, color: [4, 4.3, 5] }
1463
+ ]
1464
+ };
1465
+
1466
+ // lib/render/RayOcclusion.ts
1467
+ import { vec3 as vec33 } from "gl-matrix";
1468
+ var RayOcclusion = class {
1469
+ bounds = new Float64Array(0);
1470
+ escapes = new Uint32Array(0);
1471
+ starts = new Uint32Array(0);
1472
+ counts = new Uint8Array(0);
1473
+ triangleData = new Float64Array(0);
1474
+ triangles = [];
1475
+ bias;
1476
+ inverse = [0, 0, 0];
1477
+ parallelAxes = 0;
1478
+ closest = {
1479
+ hit: null,
1480
+ limit: Infinity
1481
+ };
1482
+ constructor(drawCalls) {
1483
+ const triangles = [];
1484
+ for (const dc of drawCalls) {
1485
+ if ((dc.mode ?? 4) !== 4 || dc.material.alphaMode === "BLEND") continue;
1486
+ if (dc.material.alphaMode === "MASK") continue;
1487
+ const count = dc.indices?.length ?? dc.positions.length / 3;
1488
+ const vertex = (index) => {
1489
+ const i = (dc.indices?.[index] ?? index) * 3;
1490
+ const p = vec33.transformMat4(
1491
+ vec33.create(),
1492
+ [dc.positions[i], dc.positions[i + 1], dc.positions[i + 2]],
1493
+ dc.model
1494
+ );
1495
+ return [p[0], p[1], p[2]];
1496
+ };
1497
+ for (let i = 0; i < count; i += 3) {
1498
+ const p = vertex(i), q = vertex(i + 1), r = vertex(i + 2);
1499
+ const min = [0, 1, 2].map(
1500
+ (a) => Math.min(p[a], q[a], r[a])
1501
+ );
1502
+ const max = [0, 1, 2].map(
1503
+ (a) => Math.max(p[a], q[a], r[a])
1504
+ );
1505
+ const e1 = q.map((v, a) => v - p[a]), e2 = r.map((v, a) => v - p[a]);
1506
+ const normal = [
1507
+ e1[1] * e2[2] - e1[2] * e2[1],
1508
+ e1[2] * e2[0] - e1[0] * e2[2],
1509
+ e1[0] * e2[1] - e1[1] * e2[0]
1510
+ ];
1511
+ const length = Math.hypot(...normal) || 1;
1512
+ for (let a = 0; a < 3; a++) normal[a] /= length;
1513
+ triangles.push({
1514
+ p,
1515
+ e1,
1516
+ e2,
1517
+ normal,
1518
+ min,
1519
+ max,
1520
+ center: min.map((v, a) => (v + max[a]) / 2),
1521
+ material: dc.material
1522
+ });
1523
+ }
1524
+ }
1525
+ const root = triangles.length ? this.build(triangles) : null;
1526
+ if (root) this.flatten(root, triangles.length);
1527
+ const extent = root ? Math.hypot(...root.max.map((v, a) => v - root.min[a])) : 1;
1528
+ this.bias = Math.max(1e-7, extent * 1e-5);
1529
+ }
1530
+ /** Preorder bounds with subtree escape offsets retain the existing SAH
1531
+ * primitive order, while avoiding recursive calls and pointer-heavy nodes
1532
+ * during millions of ray queries. Float64 retains the original precision.
1533
+ */
1534
+ flatten(root, triangleCount) {
1535
+ const nodes = [], escapes = [];
1536
+ const visit = (node) => {
1537
+ const index = nodes.length;
1538
+ nodes.push(node);
1539
+ escapes.push(0);
1540
+ if (!node.triangles) {
1541
+ visit(node.left);
1542
+ visit(node.right);
1543
+ }
1544
+ escapes[index] = nodes.length;
1545
+ };
1546
+ visit(root);
1547
+ this.bounds = new Float64Array(nodes.length * 6);
1548
+ this.escapes = Uint32Array.from(escapes);
1549
+ this.starts = new Uint32Array(nodes.length);
1550
+ this.counts = new Uint8Array(nodes.length);
1551
+ this.triangleData = new Float64Array(triangleCount * 9);
1552
+ for (let i = 0; i < nodes.length; i++) {
1553
+ const node = nodes[i];
1554
+ this.bounds.set(node.min, i * 6);
1555
+ this.bounds.set(node.max, i * 6 + 3);
1556
+ if (!node.triangles) continue;
1557
+ this.starts[i] = this.triangles.length;
1558
+ this.counts[i] = node.triangles.length;
1559
+ for (const triangle of node.triangles) {
1560
+ const offset = this.triangles.length * 9;
1561
+ this.triangleData.set(triangle.p, offset);
1562
+ this.triangleData.set(triangle.e1, offset + 3);
1563
+ this.triangleData.set(triangle.e2, offset + 6);
1564
+ this.triangles.push({
1565
+ normal: triangle.normal,
1566
+ material: triangle.material
1567
+ });
1568
+ }
1569
+ }
1570
+ }
1571
+ build(triangles) {
1572
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
1573
+ for (const t of triangles)
1574
+ for (let a = 0; a < 3; a++) {
1575
+ min[a] = Math.min(min[a], t.min[a]);
1576
+ max[a] = Math.max(max[a], t.max[a]);
1577
+ }
1578
+ if (triangles.length <= 8) return { min, max, triangles };
1579
+ const area = (lo, hi) => {
1580
+ const x = hi[0] - lo[0], y = hi[1] - lo[1], z = hi[2] - lo[2];
1581
+ return 2 * (x * y + y * z + z * x);
1582
+ };
1583
+ let bestCost = Infinity, middle = triangles.length >> 1, best = triangles;
1584
+ for (let axis = 0; axis < 3; axis++) {
1585
+ const sorted = triangles.slice().sort((a, b) => a.center[axis] - b.center[axis]);
1586
+ const suffix = new Float64Array(sorted.length);
1587
+ let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
1588
+ for (let i = sorted.length - 1; i >= 0; i--) {
1589
+ for (let a = 0; a < 3; a++) {
1590
+ lo[a] = Math.min(lo[a], sorted[i].min[a]);
1591
+ hi[a] = Math.max(hi[a], sorted[i].max[a]);
1592
+ }
1593
+ suffix[i] = area(lo, hi);
1594
+ }
1595
+ lo = [Infinity, Infinity, Infinity];
1596
+ hi = [-Infinity, -Infinity, -Infinity];
1597
+ for (let i = 1; i < sorted.length; i++) {
1598
+ const triangle = sorted[i - 1];
1599
+ for (let a = 0; a < 3; a++) {
1600
+ lo[a] = Math.min(lo[a], triangle.min[a]);
1601
+ hi[a] = Math.max(hi[a], triangle.max[a]);
1602
+ }
1603
+ if (triangle.center[axis] === sorted[i].center[axis]) continue;
1604
+ const cost = area(lo, hi) * i + suffix[i] * (sorted.length - i);
1605
+ if (cost < bestCost) {
1606
+ bestCost = cost;
1607
+ middle = i;
1608
+ best = sorted;
1609
+ }
1610
+ }
1611
+ }
1612
+ return {
1613
+ min,
1614
+ max,
1615
+ left: this.build(best.slice(0, middle)),
1616
+ right: this.build(best.slice(middle))
1617
+ };
1618
+ }
1619
+ prepareRay(direction) {
1620
+ this.parallelAxes = 0;
1621
+ for (let a = 0; a < 3; a++) {
1622
+ this.inverse[a] = 1 / direction[a];
1623
+ if (Math.abs(direction[a]) < 1e-12) this.parallelAxes |= 1 << a;
1624
+ }
1625
+ }
1626
+ occluded(origin, direction, maxDistance = Infinity) {
1627
+ this.prepareRay(direction);
1628
+ return this.intersect(origin, direction, maxDistance);
1629
+ }
1630
+ /** Closest opaque triangle for one-bounce reflections and diffuse fill. */
1631
+ trace(origin, direction, maxDistance = Infinity) {
1632
+ this.prepareRay(direction);
1633
+ const state = this.closest;
1634
+ state.hit = null;
1635
+ state.limit = maxDistance;
1636
+ this.intersect(origin, direction, maxDistance, state);
1637
+ if (!state.hit) return null;
1638
+ const { normal, material } = state.hit;
1639
+ const sign = dotNormal(normal, direction) > 0 ? -1 : 1;
1640
+ return {
1641
+ position: [
1642
+ origin[0] + direction[0] * state.limit,
1643
+ origin[1] + direction[1] * state.limit,
1644
+ origin[2] + direction[2] * state.limit
1645
+ ],
1646
+ normal: [normal[0] * sign, normal[1] * sign, normal[2] * sign],
1647
+ material,
1648
+ distance: state.limit
1649
+ };
1650
+ }
1651
+ intersect(o, d, maxDistance, state) {
1652
+ const ox = o[0], oy = o[1], oz = o[2], dx = d[0], dy = d[1], dz = d[2], ix = this.inverse[0], iy = this.inverse[1], iz = this.inverse[2], parallel = this.parallelAxes, bounds = this.bounds, data = this.triangleData;
1653
+ let node = 0;
1654
+ while (node < this.counts.length) {
1655
+ const offset = node * 6;
1656
+ let limit = state ? state.limit : maxDistance, near = 0, far = limit;
1657
+ if (parallel & 2) {
1658
+ if (oy < bounds[offset + 1] || oy > bounds[offset + 4]) {
1659
+ node = this.escapes[node];
1660
+ continue;
1661
+ }
1662
+ } else {
1663
+ let t0 = (bounds[offset + 1] - oy) * iy, t1 = (bounds[offset + 4] - oy) * iy;
1664
+ if (t0 > t1) {
1665
+ const swap = t0;
1666
+ t0 = t1;
1667
+ t1 = swap;
1668
+ }
1669
+ if (t0 > near) near = t0;
1670
+ if (t1 < far) far = t1;
1671
+ if (far < near) {
1672
+ node = this.escapes[node];
1673
+ continue;
1674
+ }
1675
+ }
1676
+ if (parallel & 1) {
1677
+ if (ox < bounds[offset + 0] || ox > bounds[offset + 3]) {
1678
+ node = this.escapes[node];
1679
+ continue;
1680
+ }
1681
+ } else {
1682
+ let t0 = (bounds[offset + 0] - ox) * ix, t1 = (bounds[offset + 3] - ox) * ix;
1683
+ if (t0 > t1) {
1684
+ const swap = t0;
1685
+ t0 = t1;
1686
+ t1 = swap;
1687
+ }
1688
+ if (t0 > near) near = t0;
1689
+ if (t1 < far) far = t1;
1690
+ if (far < near) {
1691
+ node = this.escapes[node];
1692
+ continue;
1693
+ }
1694
+ }
1695
+ if (parallel & 4) {
1696
+ if (oz < bounds[offset + 2] || oz > bounds[offset + 5]) {
1697
+ node = this.escapes[node];
1698
+ continue;
1699
+ }
1700
+ } else {
1701
+ let t0 = (bounds[offset + 2] - oz) * iz, t1 = (bounds[offset + 5] - oz) * iz;
1702
+ if (t0 > t1) {
1703
+ const swap = t0;
1704
+ t0 = t1;
1705
+ t1 = swap;
1706
+ }
1707
+ if (t0 > near) near = t0;
1708
+ if (t1 < far) far = t1;
1709
+ if (far < near) {
1710
+ node = this.escapes[node];
1711
+ continue;
1712
+ }
1713
+ }
1714
+ const start = this.starts[node], end = start + this.counts[node];
1715
+ for (let i = start; i < end; i++) {
1716
+ const offset2 = i * 9, p0 = data[offset2], p1 = data[offset2 + 1], p2 = data[offset2 + 2], e10 = data[offset2 + 3], e11 = data[offset2 + 4], e12 = data[offset2 + 5], e20 = data[offset2 + 6], e21 = data[offset2 + 7], e22 = data[offset2 + 8];
1717
+ const px = dy * e22 - dz * e21, py = dz * e20 - dx * e22, pz = dx * e21 - dy * e20;
1718
+ const det = e10 * px + e11 * py + e12 * pz;
1719
+ if (Math.abs(det) < 1e-12) continue;
1720
+ const inv = 1 / det, tx = ox - p0, ty = oy - p1, tz = oz - p2;
1721
+ const u = (tx * px + ty * py + tz * pz) * inv;
1722
+ if (u < 0 || u > 1) continue;
1723
+ const qx = ty * e12 - tz * e11, qy = tz * e10 - tx * e12, qz = tx * e11 - ty * e10;
1724
+ const v = (dx * qx + dy * qy + dz * qz) * inv;
1725
+ if (v < 0 || u + v > 1) continue;
1726
+ const distance = (e20 * qx + e21 * qy + e22 * qz) * inv;
1727
+ if (distance > this.bias * 0.25 && distance < limit) {
1728
+ if (!state) return true;
1729
+ state.hit = this.triangles[i];
1730
+ state.limit = limit = distance;
1731
+ }
1732
+ }
1733
+ node++;
1734
+ }
1735
+ return state?.hit != null;
1736
+ }
1737
+ };
1738
+ function dotNormal(normal, direction) {
1739
+ return normal[0] * direction[0] + normal[1] * direction[1] + normal[2] * direction[2];
1740
+ }
1741
+
1742
+ // lib/render/StudioLighting.ts
1743
+ var dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1744
+ var normalize = (v) => {
1745
+ const l = Math.hypot(...v) || 1;
1746
+ return [v[0] / l, v[1] / l, v[2] / l];
1747
+ };
1748
+ var W = 96;
1749
+ var H = 48;
1750
+ var SAMPLES = 128;
1751
+ var ROUGHNESSES = [0.04, 0.1, 0.18, 0.28, 0.4, 0.55, 0.7, 0.85, 1];
1752
+ var lobes = studio_environment_default.lobes.map((l) => ({
1753
+ ...l,
1754
+ direction: normalize(l.direction),
1755
+ color: l.color
1756
+ }));
1757
+ function radicalInverse(value) {
1758
+ let result = 0, factor = 0.5;
1759
+ while (value) {
1760
+ result += (value & 1) * factor;
1761
+ value >>>= 1;
1762
+ factor *= 0.5;
1763
+ }
1764
+ return result;
1765
+ }
1766
+ function basis(n) {
1767
+ const t = normalize(Math.abs(n[1]) < 0.999 ? [n[2], 0, -n[0]] : [1, 0, 0]);
1768
+ return [
1769
+ t,
1770
+ [
1771
+ n[1] * t[2] - n[2] * t[1],
1772
+ n[2] * t[0] - n[0] * t[2],
1773
+ n[0] * t[1] - n[1] * t[0]
1774
+ ]
1775
+ ];
1776
+ }
1777
+ function orient(x, y, z, n, t, b) {
1778
+ return [
1779
+ t[0] * x + b[0] * y + n[0] * z,
1780
+ t[1] * x + b[1] * y + n[1] * z,
1781
+ t[2] * x + b[2] * y + n[2] * z
1782
+ ];
1783
+ }
1784
+ function tableLookup(n) {
1785
+ const x = (Math.atan2(n[2], n[0]) / (2 * Math.PI) + 0.5) * W - 0.5;
1786
+ const y = Math.max(
1787
+ 0,
1788
+ Math.min(
1789
+ H - 1,
1790
+ Math.acos(Math.max(-1, Math.min(1, n[1]))) / Math.PI * H - 0.5
1791
+ )
1792
+ );
1793
+ const ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy;
1794
+ const a = (ix % W + W) % W, b = (a + 1) % W, c = Math.min(H - 1, iy + 1);
1795
+ return [iy * W + a, iy * W + b, c * W + a, c * W + b, fx, fy];
1796
+ }
1797
+ function sampleTable(table, lookup) {
1798
+ const [i00, i10, i01, i11, fx, fy] = lookup;
1799
+ return (table[i00] * (1 - fx) + table[i10] * fx) * (1 - fy) + (table[i01] * (1 - fx) + table[i11] * fx) * fy;
1800
+ }
1801
+ var cachedTables;
1802
+ function getTables() {
1803
+ if (cachedTables) return cachedTables;
1804
+ const diffuse = lobes.map(() => new Float32Array(W * H));
1805
+ const specular = ROUGHNESSES.map(
1806
+ () => lobes.map(() => new Float32Array(W * H))
1807
+ );
1808
+ const diffuseSamples = Array.from({ length: SAMPLES }, (_, i) => {
1809
+ const phi = 2 * Math.PI * radicalInverse(i), r = Math.sqrt((i + 0.5) / SAMPLES);
1810
+ return [r * Math.cos(phi), r * Math.sin(phi), Math.sqrt(1 - r * r)];
1811
+ });
1812
+ const halfSamples = ROUGHNESSES.map(
1813
+ (rough) => Array.from({ length: SAMPLES }, (_, i) => {
1814
+ const phi = 2 * Math.PI * radicalInverse(i), u = (i + 0.5) / SAMPLES, a = rough * rough;
1815
+ const cos = Math.sqrt((1 - u) / (1 + (a * a - 1) * u)), sin = Math.sqrt(1 - cos * cos);
1816
+ return [sin * Math.cos(phi), sin * Math.sin(phi), cos];
1817
+ })
1818
+ );
1819
+ for (let y = 0; y < H; y++)
1820
+ for (let x = 0; x < W; x++) {
1821
+ const theta = (y + 0.5) / H * Math.PI, phi = ((x + 0.5) / W - 0.5) * 2 * Math.PI;
1822
+ const n = [
1823
+ Math.sin(theta) * Math.cos(phi),
1824
+ Math.cos(theta),
1825
+ Math.sin(theta) * Math.sin(phi)
1826
+ ];
1827
+ const [t, b] = basis(n), index = y * W + x;
1828
+ for (const s of diffuseSamples) {
1829
+ const l = orient(...s, n, t, b);
1830
+ for (let j = 0; j < lobes.length; j++)
1831
+ diffuse[j][index] += Math.exp(lobes[j].sharpness * (dot(l, lobes[j].direction) - 1)) / SAMPLES;
1832
+ }
1833
+ for (let r = 0; r < ROUGHNESSES.length; r++) {
1834
+ let weight = 0;
1835
+ for (const s of halfSamples[r]) {
1836
+ const h = orient(...s, n, t, b), nh = dot(n, h);
1837
+ const l = [
1838
+ 2 * nh * h[0] - n[0],
1839
+ 2 * nh * h[1] - n[1],
1840
+ 2 * nh * h[2] - n[2]
1841
+ ];
1842
+ const nl = Math.max(0, dot(n, l));
1843
+ weight += nl;
1844
+ for (let j = 0; j < lobes.length; j++)
1845
+ specular[r][j][index] += Math.exp(
1846
+ lobes[j].sharpness * (dot(l, lobes[j].direction) - 1)
1847
+ ) * nl;
1848
+ }
1849
+ for (let j = 0; j < lobes.length; j++)
1850
+ specular[r][j][index] /= Math.max(weight, 1e-8);
1851
+ }
1852
+ }
1853
+ const brdf = new Float32Array(64 * 64 * 2);
1854
+ for (let y = 0; y < 64; y++)
1855
+ for (let x = 0; x < 64; x++) {
1856
+ const rough = (y + 0.5) / 64, nv = (x + 0.5) / 64, a = rough * rough, a2 = a * a;
1857
+ const v = [Math.sqrt(1 - nv * nv), 0, nv];
1858
+ let A = 0, B = 0;
1859
+ for (let i = 0; i < SAMPLES; i++) {
1860
+ const u = (i + 0.5) / SAMPLES, phi = 2 * Math.PI * radicalInverse(i);
1861
+ const nh = Math.sqrt((1 - u) / (1 + (a2 - 1) * u)), sin = Math.sqrt(1 - nh * nh);
1862
+ const h = [sin * Math.cos(phi), sin * Math.sin(phi), nh], vh = Math.max(0, dot(v, h));
1863
+ const nl = 2 * vh * nh - nv;
1864
+ if (nl <= 0) continue;
1865
+ const gv = 2 * nv / (nv + Math.sqrt(a2 + (1 - a2) * nv * nv));
1866
+ const gl = 2 * nl / (nl + Math.sqrt(a2 + (1 - a2) * nl * nl));
1867
+ const vis = gv * gl * vh / Math.max(nh * nv, 1e-8), f = (1 - vh) ** 5;
1868
+ A += (1 - f) * vis;
1869
+ B += f * vis;
1870
+ }
1871
+ const index = (y * 64 + x) * 2;
1872
+ brdf[index] = A / SAMPLES;
1873
+ brdf[index + 1] = B / SAMPLES;
1874
+ }
1875
+ cachedTables = { diffuse, specular, brdf };
1876
+ return cachedTables;
1877
+ }
1878
+ var StudioLighting = class {
1879
+ tables = getTables();
1880
+ occlusion;
1881
+ eye;
1882
+ shadowDirections;
1883
+ diffuseCache = /* @__PURE__ */ new WeakMap();
1884
+ constructor(drawCalls, camera) {
1885
+ this.occlusion = new RayOcclusion(drawCalls);
1886
+ const world = mat46.invert(mat46.create(), camera.view);
1887
+ if (!world)
1888
+ throw new Error("Realistic rendering requires an invertible camera view.");
1889
+ this.eye = [world[12], world[13], world[14]];
1890
+ this.shadowDirections = lobes.map((l) => {
1891
+ const [t, b] = basis(l.direction);
1892
+ return Array.from({ length: 8 }, (_, i) => {
1893
+ const u = (i + 0.5) / 8, cos = 1 + Math.log(1 - u) / l.sharpness, sin = Math.sqrt(Math.max(0, 1 - cos * cos)), phi = 2 * Math.PI * radicalInverse(i);
1894
+ const direction = orient(
1895
+ sin * Math.cos(phi),
1896
+ sin * Math.sin(phi),
1897
+ cos,
1898
+ l.direction,
1899
+ t,
1900
+ b
1901
+ );
1902
+ const parallel = dot(direction, l.direction);
1903
+ return {
1904
+ parallel: l.direction.map((v) => v * parallel),
1905
+ perpendicular: direction.map(
1906
+ (v, k) => v - l.direction[k] * parallel
1907
+ ),
1908
+ cross: [
1909
+ l.direction[1] * direction[2] - l.direction[2] * direction[1],
1910
+ l.direction[2] * direction[0] - l.direction[0] * direction[2],
1911
+ l.direction[0] * direction[1] - l.direction[1] * direction[0]
1912
+ ]
1913
+ };
1914
+ });
1915
+ });
1916
+ }
1917
+ bounceRadiance(hit, incoming) {
1918
+ const metal = Math.max(0, Math.min(1, hit.material.metallicFactor ?? 1));
1919
+ let cached = this.diffuseCache.get(hit.material);
1920
+ if (!cached || cached.normal.some((v, k) => !Object.is(v, hit.normal[k]))) {
1921
+ const irradiance2 = [...studio_environment_default.ambient];
1922
+ const lookup = tableLookup(hit.normal);
1923
+ for (let j = 0; j < lobes.length; j++) {
1924
+ const value = sampleTable(this.tables.diffuse[j], lookup);
1925
+ for (let k = 0; k < 3; k++)
1926
+ irradiance2[k] += value * lobes[j].color[k];
1927
+ }
1928
+ cached = { normal: [...hit.normal], irradiance: irradiance2 };
1929
+ this.diffuseCache.set(hit.material, cached);
1930
+ }
1931
+ const irradiance = cached.irradiance;
1932
+ const base = hit.material.baseColorFactor;
1933
+ const rough = Math.max(0.04, Math.min(1, hit.material.roughnessFactor ?? 1));
1934
+ const nv = Math.max(1e-3, -dot(hit.normal, incoming));
1935
+ const reflected = [
1936
+ 2 * nv * hit.normal[0] + incoming[0],
1937
+ 2 * nv * hit.normal[1] + incoming[1],
1938
+ 2 * nv * hit.normal[2] + incoming[2]
1939
+ ];
1940
+ let level = 0;
1941
+ while (level < ROUGHNESSES.length - 2 && ROUGHNESSES[level + 1] < rough)
1942
+ level++;
1943
+ const blend = (rough - ROUGHNESSES[level]) / (ROUGHNESSES[level + 1] - ROUGHNESSES[level]);
1944
+ const index = (Math.min(63, Math.floor(rough * 64)) * 64 + Math.min(63, Math.floor(nv * 64))) * 2;
1945
+ const A = this.tables.brdf[index], B = this.tables.brdf[index + 1];
1946
+ const radiance = irradiance.map(
1947
+ (c, k) => c * base[k] * (1 - metal) * 0.96
1948
+ );
1949
+ const pref = [...studio_environment_default.ambient];
1950
+ const reflectedLookup = tableLookup(reflected);
1951
+ for (let j = 0; j < lobes.length; j++) {
1952
+ const value = sampleTable(this.tables.specular[level][j], reflectedLookup) * (1 - blend) + sampleTable(this.tables.specular[level + 1][j], reflectedLookup) * blend;
1953
+ for (let k = 0; k < 3; k++) pref[k] += value * lobes[j].color[k];
1954
+ }
1955
+ for (let k = 0; k < 3; k++)
1956
+ radiance[k] += pref[k] * ((0.04 * (1 - metal) + base[k] * metal) * A + B);
1957
+ return radiance;
1958
+ }
1959
+ shade(position, n, base, material, sampleIndex = 0) {
1960
+ const v = normalize([
1961
+ this.eye[0] - position[0],
1962
+ this.eye[1] - position[1],
1963
+ this.eye[2] - position[2]
1964
+ ]);
1965
+ const nv = Math.max(1e-3, dot(n, v));
1966
+ const reflected = [
1967
+ 2 * nv * n[0] - v[0],
1968
+ 2 * nv * n[1] - v[1],
1969
+ 2 * nv * n[2] - v[2]
1970
+ ];
1971
+ const origin = [
1972
+ position[0] + n[0] * this.occlusion.bias,
1973
+ position[1] + n[1] * this.occlusion.bias,
1974
+ position[2] + n[2] * this.occlusion.bias
1975
+ ];
1976
+ const metal = Math.max(0, Math.min(1, material.metallicFactor ?? 1));
1977
+ const rough = Math.max(0.04, Math.min(1, material.roughnessFactor ?? 1));
1978
+ let level = 0;
1979
+ while (level < ROUGHNESSES.length - 2 && ROUGHNESSES[level + 1] < rough)
1980
+ level++;
1981
+ const blend = (rough - ROUGHNESSES[level]) / (ROUGHNESSES[level + 1] - ROUGHNESSES[level]);
1982
+ const bx = Math.min(63, Math.floor(nv * 64)), by = Math.min(63, Math.floor(rough * 64)), bi = (by * 64 + bx) * 2;
1983
+ const A = this.tables.brdf[bi], B = this.tables.brdf[bi + 1];
1984
+ const f0 = base.map((c) => 0.04 * (1 - metal) + c * metal);
1985
+ const color = base.map(
1986
+ (c, j) => studio_environment_default.ambient[j] * c * (1 - metal) * 0.96
1987
+ );
1988
+ const specColor = f0.map(
1989
+ (c, j) => studio_environment_default.ambient[j] * (c * A + B)
1990
+ );
1991
+ let bits = sampleIndex + 1;
1992
+ bits = Math.imul(bits ^ bits >>> 16, 2146121005);
1993
+ bits = Math.imul(bits ^ bits >>> 15, 2221713035);
1994
+ const hash = ((bits ^ bits >>> 16) >>> 0) / 4294967296;
1995
+ const phase = hash * 2 * Math.PI, cosPhase = Math.cos(phase), sinPhase = Math.sin(phase);
1996
+ const normalLookup = tableLookup(n), reflectedLookup = tableLookup(reflected);
1997
+ for (let j = 0; j < lobes.length; j++) {
1998
+ const dirs = this.shadowDirections[j];
1999
+ let visible = 0, total = 0;
2000
+ for (const { parallel, perpendicular, cross } of dirs) {
2001
+ const l = [
2002
+ parallel[0] + perpendicular[0] * cosPhase + cross[0] * sinPhase,
2003
+ parallel[1] + perpendicular[1] * cosPhase + cross[1] * sinPhase,
2004
+ parallel[2] + perpendicular[2] * cosPhase + cross[2] * sinPhase
2005
+ ];
2006
+ const weight = Math.max(0, dot(n, l));
2007
+ total += weight;
2008
+ if (weight > 0 && !this.occlusion.occluded(origin, l)) visible += weight;
2009
+ }
2010
+ const visibility = total > 0 ? visible / total : 1;
2011
+ const diffuse = sampleTable(this.tables.diffuse[j], normalLookup) * visibility;
2012
+ const specular = (sampleTable(this.tables.specular[level][j], reflectedLookup) * (1 - blend) + sampleTable(this.tables.specular[level + 1][j], reflectedLookup) * blend) * visibility;
2013
+ for (let k = 0; k < 3; k++) {
2014
+ color[k] += lobes[j].color[k] * base[k] * (1 - metal) * 0.96 * diffuse;
2015
+ specColor[k] += lobes[j].color[k] * (f0[k] * A + B) * specular;
2016
+ }
2017
+ }
2018
+ const [t, b] = basis(n);
2019
+ let blocked = 0;
2020
+ for (let i = 0; metal < 1 && i < 4; i++) {
2021
+ const phi = i * Math.PI / 2 + phase, l = orient(
2022
+ 0.7 * Math.cos(phi),
2023
+ 0.7 * Math.sin(phi),
2024
+ Math.sqrt(0.51),
2025
+ n,
2026
+ t,
2027
+ b
2028
+ );
2029
+ const hit = this.occlusion.trace(origin, l);
2030
+ if (hit) {
2031
+ blocked++;
2032
+ const bounce2 = this.bounceRadiance(hit, l);
2033
+ for (let k = 0; k < 3; k++)
2034
+ color[k] += bounce2[k] * base[k] * (1 - metal) * 0.96 / 4;
2035
+ }
2036
+ }
2037
+ const ao = 1 - blocked / 4;
2038
+ for (let k = 0; k < 3; k++)
2039
+ color[k] -= studio_environment_default.ambient[k] * (1 - ao) * base[k] * (1 - metal) * 0.96;
2040
+ const [rt, rb] = basis(reflected), bounce = [0, 0, 0];
2041
+ let hitWeight = 0, totalWeight = 0;
2042
+ for (let i = 0; i < 8; i++) {
2043
+ const u = (i + 0.5) / 8, phi = 2 * Math.PI * radicalInverse(i) + phase, a = rough * rough;
2044
+ const nh = Math.sqrt((1 - u) / (1 + (a * a - 1) * u)), sin = Math.sqrt(1 - nh * nh);
2045
+ const h = orient(
2046
+ sin * Math.cos(phi),
2047
+ sin * Math.sin(phi),
2048
+ nh,
2049
+ reflected,
2050
+ rt,
2051
+ rb
2052
+ );
2053
+ const l = [
2054
+ 2 * nh * h[0] - reflected[0],
2055
+ 2 * nh * h[1] - reflected[1],
2056
+ 2 * nh * h[2] - reflected[2]
2057
+ ];
2058
+ const weight = Math.max(0, dot(reflected, l));
2059
+ totalWeight += weight;
2060
+ if (weight === 0 || dot(n, l) <= 0) continue;
2061
+ const hit = this.occlusion.trace(origin, l);
2062
+ if (hit) {
2063
+ hitWeight += weight;
2064
+ const radiance = this.bounceRadiance(hit, l);
2065
+ for (let k = 0; k < 3; k++) bounce[k] += radiance[k] * weight;
2066
+ }
2067
+ }
2068
+ for (let k = 0; k < 3; k++)
2069
+ color[k] += specColor[k] * (1 - hitWeight / Math.max(totalWeight, 1e-8)) + bounce[k] / Math.max(totalWeight, 1e-8) * (f0[k] * A + B);
2070
+ return color;
2071
+ }
2072
+ };
2073
+
1334
2074
  // lib/render/renderDrawCalls.ts
1335
2075
  function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint8Bitmap) {
1336
2076
  const options = resolveRenderOptions(optionsInput);
@@ -1346,7 +2086,12 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1346
2086
  options.up,
1347
2087
  options.cameraRotation ?? null
1348
2088
  );
1349
- const renderer = new SoftwareRenderer(renderWidth, renderHeight, imageFactory);
2089
+ const renderer = new SoftwareRenderer(
2090
+ renderWidth,
2091
+ renderHeight,
2092
+ imageFactory,
2093
+ options.realistic
2094
+ );
1350
2095
  let rgb = null;
1351
2096
  if (options.backgroundColor) {
1352
2097
  if (typeof options.backgroundColor === "string") {
@@ -1407,6 +2152,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1407
2152
  const blendDraws = allDrawCalls.filter(
1408
2153
  (dc) => (dc.material.alphaMode ?? "OPAQUE") === "BLEND"
1409
2154
  );
2155
+ const studio = options.realistic ? new StudioLighting(allDrawCalls, camera) : void 0;
1410
2156
  const renderGroup = (dcs) => {
1411
2157
  for (const dc of dcs) {
1412
2158
  if (dc.mode === 1) {
@@ -1418,7 +2164,8 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1418
2164
  { dir: options.lightDir, ambient: options.ambient },
1419
2165
  dc.material,
1420
2166
  options.cull,
1421
- options.gamma
2167
+ options.gamma,
2168
+ studio
1422
2169
  );
1423
2170
  }
1424
2171
  }
@@ -1442,6 +2189,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1442
2189
  });
1443
2190
  }
1444
2191
  renderGroup(blendDraws);
2192
+ renderer.denoise(options.gamma);
1445
2193
  for (const dc of drawCalls) {
1446
2194
  if (!dc.showHiddenEdges) continue;
1447
2195
  const edgeDrawCall = createEdgeDrawCall(dc);
@@ -1480,7 +2228,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1480
2228
  }
1481
2229
 
1482
2230
  // lib/gltf/createInfiniteGrid.ts
1483
- import { mat4 as mat46 } from "gl-matrix";
2231
+ import { mat4 as mat47 } from "gl-matrix";
1484
2232
  function createInfiniteGrid(options = {}) {
1485
2233
  const cellSize = options.cellSize ?? 1;
1486
2234
  const sectionSize = options.sectionSize ?? 10;
@@ -1504,9 +2252,9 @@ function createInfiniteGrid(options = {}) {
1504
2252
  size
1505
2253
  ]);
1506
2254
  const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
1507
- const model = mat46.create();
2255
+ const model = mat47.create();
1508
2256
  const offset = options.offset ?? {};
1509
- mat46.fromTranslation(model, [offset.x ?? 0, offset.y ?? 0, offset.z ?? 0]);
2257
+ mat47.fromTranslation(model, [offset.x ?? 0, offset.y ?? 0, offset.z ?? 0]);
1510
2258
  return {
1511
2259
  positions,
1512
2260
  indices,
@@ -1531,7 +2279,7 @@ function createInfiniteGrid(options = {}) {
1531
2279
  }
1532
2280
 
1533
2281
  // lib/gltf/createSceneFromGLTF.ts
1534
- import { mat4 as mat47 } from "gl-matrix";
2282
+ import { mat4 as mat48 } from "gl-matrix";
1535
2283
  var COMPONENT_INFO = {
1536
2284
  5120: {
1537
2285
  name: "BYTE",
@@ -1738,17 +2486,17 @@ function readIndices(ctx, accessorIndex) {
1738
2486
  }
1739
2487
  function nodeLocalMatrix(node) {
1740
2488
  if (node.matrix) {
1741
- const m2 = mat47.create();
2489
+ const m2 = mat48.create();
1742
2490
  for (let i = 0; i < 16; i++) m2[i] = node.matrix[i];
1743
2491
  return m2;
1744
2492
  }
1745
2493
  const translation = node.translation || [0, 0, 0];
1746
2494
  const rotation = node.rotation || [0, 0, 0, 1];
1747
2495
  const scale = node.scale || [1, 1, 1];
1748
- const m = mat47.create();
1749
- const tmp = mat47.create();
1750
- mat47.fromRotationTranslationScale(tmp, rotation, translation, scale);
1751
- mat47.copy(m, tmp);
2496
+ const m = mat48.create();
2497
+ const tmp = mat48.create();
2498
+ mat48.fromRotationTranslationScale(tmp, rotation, translation, scale);
2499
+ mat48.copy(m, tmp);
1752
2500
  return m;
1753
2501
  }
1754
2502
  function getMaterial(gltf, textures, images, materialIndex) {
@@ -1769,6 +2517,8 @@ function getMaterial(gltf, textures, images, materialIndex) {
1769
2517
  return {
1770
2518
  baseColorFactor: factor,
1771
2519
  baseColorTexture: texImg,
2520
+ metallicFactor: pbr.metallicFactor ?? 1,
2521
+ roughnessFactor: pbr.roughnessFactor ?? 1,
1772
2522
  alphaMode,
1773
2523
  alphaCutoff
1774
2524
  };
@@ -1786,8 +2536,8 @@ function createSceneFromGLTF(gltf, resources) {
1786
2536
  const node = nodes[nodeIndex];
1787
2537
  if (!node) return;
1788
2538
  const local = nodeLocalMatrix(node);
1789
- const world = mat47.create();
1790
- mat47.multiply(world, parentMatrix, local);
2539
+ const world = mat48.create();
2540
+ mat48.multiply(world, parentMatrix, local);
1791
2541
  if (Number.isInteger(node.mesh)) {
1792
2542
  const mesh = meshes[node.mesh];
1793
2543
  if (mesh) {
@@ -1826,7 +2576,7 @@ function createSceneFromGLTF(gltf, resources) {
1826
2576
  traverse(childIndex, world);
1827
2577
  }
1828
2578
  }
1829
- const identity = mat47.create();
2579
+ const identity = mat48.create();
1830
2580
  for (const rootIndex of scene.nodes || []) {
1831
2581
  traverse(rootIndex, identity);
1832
2582
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poppygl",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.30",
4
+ "version": "0.0.32",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tscircuit/poppygl.git"
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "start": "cosmos",
14
14
  "build:site": "cosmos-export",
15
- "build": "tsup-node ./lib/index.ts --format esm --dts",
15
+ "build": "tsup-node ./lib/index.ts --format esm --dts --external @jscad/modeling",
16
16
  "format": "biome format --write .",
17
17
  "format:check": "biome format .",
18
18
  "test": "bun test",
@@ -21,10 +21,12 @@
21
21
  "type": "module",
22
22
  "devDependencies": {
23
23
  "@biomejs/biome": "^2.2.4",
24
+ "@jscad/modeling": "2.13.0",
24
25
  "@playwright/test": "^1.60.0",
25
26
  "@tscircuit/alphabet": "0.0.23",
26
27
  "@types/bun": "^1.2.23",
27
28
  "@types/readable-stream": "^4.0.21",
29
+ "jscad-electronics": "0.0.187",
28
30
  "looks-same": "^10.0.1",
29
31
  "react": "^19.1.1",
30
32
  "react-cosmos": "^7.0.0",