poppygl 0.0.30 → 0.0.31

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,17 @@ 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
+ constructor(drawCalls: DrawCall[], camera: Camera);
181
+ private bounceRadiance;
182
+ shade(position: V3, n: V3, base: V3, material: Material, sampleIndex?: number): V3;
183
+ }
184
+
170
185
  interface LightSettings {
171
186
  dir: readonly [number, number, number];
172
187
  ambient: number;
@@ -184,14 +199,16 @@ declare class SoftwareRenderer {
184
199
  readonly height: number;
185
200
  readonly bitmap: BitmapLike;
186
201
  readonly depth: Float32Array;
187
- constructor(width: number, height: number, imageFactory?: ImageFactory);
202
+ private readonly denoiser;
203
+ constructor(width: number, height: number, imageFactory?: ImageFactory, denoise?: boolean);
188
204
  get buffer(): Uint8Array<ArrayBufferLike> | Uint8ClampedArray<ArrayBufferLike>;
189
205
  clear(colorRGBA?: [number, number, number, number]): void;
190
- setPixel(x: number, y: number, r: number, g: number, b: number, a: number): void;
206
+ setPixel(x: number, y: number, r: number, g: number, b: number, a: number, keepGuide?: boolean): void;
207
+ denoise(gammaOut?: boolean): void;
191
208
  drawLines(mesh: DrawCall, camera: Camera, gammaOut?: boolean, options?: LineRenderOptions): void;
192
209
  sampleTextureNearest(img: BitmapLike | null, u: number, v: number): MutableRGBA;
193
210
  perspInterp(attrs: number[][], invWs: number[], lambdas: number[]): number[];
194
- drawMesh(mesh: DrawCall, camera: Camera, light: LightSettings, material: Material, cullBackFaces?: boolean, gammaOut?: boolean): void;
211
+ drawMesh(mesh: DrawCall, camera: Camera, light: LightSettings, material: Material, cullBackFaces?: boolean, gammaOut?: boolean, studio?: StudioLighting): void;
195
212
  }
196
213
 
197
214
  /**
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,493 @@ 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
+ root;
1470
+ bias;
1471
+ constructor(drawCalls) {
1472
+ const triangles = [];
1473
+ for (const dc of drawCalls) {
1474
+ if ((dc.mode ?? 4) !== 4 || dc.material.alphaMode === "BLEND") continue;
1475
+ if (dc.material.alphaMode === "MASK") continue;
1476
+ const count = dc.indices?.length ?? dc.positions.length / 3;
1477
+ const vertex = (index) => {
1478
+ const i = (dc.indices?.[index] ?? index) * 3;
1479
+ const p = vec33.transformMat4(
1480
+ vec33.create(),
1481
+ [dc.positions[i], dc.positions[i + 1], dc.positions[i + 2]],
1482
+ dc.model
1483
+ );
1484
+ return [p[0], p[1], p[2]];
1485
+ };
1486
+ for (let i = 0; i < count; i += 3) {
1487
+ const p = vertex(i), q = vertex(i + 1), r = vertex(i + 2);
1488
+ const min = [0, 1, 2].map(
1489
+ (a) => Math.min(p[a], q[a], r[a])
1490
+ );
1491
+ const max = [0, 1, 2].map(
1492
+ (a) => Math.max(p[a], q[a], r[a])
1493
+ );
1494
+ triangles.push({
1495
+ p,
1496
+ e1: q.map((v, a) => v - p[a]),
1497
+ e2: r.map((v, a) => v - p[a]),
1498
+ min,
1499
+ max,
1500
+ center: min.map((v, a) => (v + max[a]) / 2),
1501
+ material: dc.material
1502
+ });
1503
+ }
1504
+ }
1505
+ this.root = triangles.length ? this.build(triangles) : null;
1506
+ const extent = this.root ? Math.hypot(...this.root.max.map((v, a) => v - this.root.min[a])) : 1;
1507
+ this.bias = Math.max(1e-7, extent * 1e-5);
1508
+ }
1509
+ build(triangles) {
1510
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
1511
+ for (const t of triangles)
1512
+ for (let a = 0; a < 3; a++) {
1513
+ min[a] = Math.min(min[a], t.min[a]);
1514
+ max[a] = Math.max(max[a], t.max[a]);
1515
+ }
1516
+ if (triangles.length <= 8) return { min, max, triangles };
1517
+ const area = (lo, hi) => {
1518
+ const x = hi[0] - lo[0], y = hi[1] - lo[1], z = hi[2] - lo[2];
1519
+ return 2 * (x * y + y * z + z * x);
1520
+ };
1521
+ let bestCost = Infinity, middle = triangles.length >> 1, best = triangles;
1522
+ for (let axis = 0; axis < 3; axis++) {
1523
+ const sorted = triangles.slice().sort((a, b) => a.center[axis] - b.center[axis]);
1524
+ const suffix = new Float64Array(sorted.length);
1525
+ let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
1526
+ for (let i = sorted.length - 1; i >= 0; i--) {
1527
+ for (let a = 0; a < 3; a++) {
1528
+ lo[a] = Math.min(lo[a], sorted[i].min[a]);
1529
+ hi[a] = Math.max(hi[a], sorted[i].max[a]);
1530
+ }
1531
+ suffix[i] = area(lo, hi);
1532
+ }
1533
+ lo = [Infinity, Infinity, Infinity];
1534
+ hi = [-Infinity, -Infinity, -Infinity];
1535
+ for (let i = 1; i < sorted.length; i++) {
1536
+ const triangle = sorted[i - 1];
1537
+ for (let a = 0; a < 3; a++) {
1538
+ lo[a] = Math.min(lo[a], triangle.min[a]);
1539
+ hi[a] = Math.max(hi[a], triangle.max[a]);
1540
+ }
1541
+ if (triangle.center[axis] === sorted[i].center[axis]) continue;
1542
+ const cost = area(lo, hi) * i + suffix[i] * (sorted.length - i);
1543
+ if (cost < bestCost) {
1544
+ bestCost = cost;
1545
+ middle = i;
1546
+ best = sorted;
1547
+ }
1548
+ }
1549
+ }
1550
+ return {
1551
+ min,
1552
+ max,
1553
+ left: this.build(best.slice(0, middle)),
1554
+ right: this.build(best.slice(middle))
1555
+ };
1556
+ }
1557
+ occluded(origin, direction, maxDistance = Infinity) {
1558
+ return this.root ? this.intersect(this.root, origin, direction, maxDistance) : false;
1559
+ }
1560
+ /** Closest opaque triangle for one-bounce reflections and diffuse fill. */
1561
+ trace(origin, direction, maxDistance = Infinity) {
1562
+ const state = {
1563
+ hit: null,
1564
+ limit: maxDistance
1565
+ };
1566
+ if (this.root)
1567
+ this.intersect(this.root, origin, direction, maxDistance, state);
1568
+ return state.hit;
1569
+ }
1570
+ intersect(node, o, d, limit, state) {
1571
+ if (state) limit = state.limit;
1572
+ let near = 0, far = limit;
1573
+ for (let a = 0; a < 3; a++) {
1574
+ if (Math.abs(d[a]) < 1e-12) {
1575
+ if (o[a] < node.min[a] || o[a] > node.max[a]) return false;
1576
+ continue;
1577
+ }
1578
+ const t0 = (node.min[a] - o[a]) / d[a], t1 = (node.max[a] - o[a]) / d[a];
1579
+ near = Math.max(near, Math.min(t0, t1));
1580
+ far = Math.min(far, Math.max(t0, t1));
1581
+ if (far < near) return false;
1582
+ }
1583
+ if (node.triangles) {
1584
+ for (const { p, e1, e2, material } of node.triangles) {
1585
+ const px = d[1] * e2[2] - d[2] * e2[1], py = d[2] * e2[0] - d[0] * e2[2], pz = d[0] * e2[1] - d[1] * e2[0];
1586
+ const det = e1[0] * px + e1[1] * py + e1[2] * pz;
1587
+ if (Math.abs(det) < 1e-12) continue;
1588
+ const inv = 1 / det, tx = o[0] - p[0], ty = o[1] - p[1], tz = o[2] - p[2];
1589
+ const u = (tx * px + ty * py + tz * pz) * inv;
1590
+ if (u < 0 || u > 1) continue;
1591
+ const qx = ty * e1[2] - tz * e1[1], qy = tz * e1[0] - tx * e1[2], qz = tx * e1[1] - ty * e1[0];
1592
+ const v = (d[0] * qx + d[1] * qy + d[2] * qz) * inv;
1593
+ if (v < 0 || u + v > 1) continue;
1594
+ const distance = (e2[0] * qx + e2[1] * qy + e2[2] * qz) * inv;
1595
+ if (distance > this.bias * 0.25 && distance < limit) {
1596
+ if (!state) return true;
1597
+ const normal = [
1598
+ e1[1] * e2[2] - e1[2] * e2[1],
1599
+ e1[2] * e2[0] - e1[0] * e2[2],
1600
+ e1[0] * e2[1] - e1[1] * e2[0]
1601
+ ];
1602
+ const length = Math.hypot(...normal) || 1;
1603
+ for (let a = 0; a < 3; a++) normal[a] /= length;
1604
+ if (normal[0] * d[0] + normal[1] * d[1] + normal[2] * d[2] > 0)
1605
+ for (let a = 0; a < 3; a++) normal[a] *= -1;
1606
+ state.hit = {
1607
+ position: [
1608
+ o[0] + d[0] * distance,
1609
+ o[1] + d[1] * distance,
1610
+ o[2] + d[2] * distance
1611
+ ],
1612
+ normal,
1613
+ material,
1614
+ distance
1615
+ };
1616
+ state.limit = limit = distance;
1617
+ }
1618
+ }
1619
+ return false;
1620
+ }
1621
+ if (state) {
1622
+ this.intersect(node.left, o, d, limit, state);
1623
+ this.intersect(node.right, o, d, state.limit, state);
1624
+ return state.hit !== null;
1625
+ }
1626
+ return this.intersect(node.left, o, d, limit) || this.intersect(node.right, o, d, limit);
1627
+ }
1628
+ };
1629
+
1630
+ // lib/render/StudioLighting.ts
1631
+ var dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1632
+ var normalize = (v) => {
1633
+ const l = Math.hypot(...v) || 1;
1634
+ return [v[0] / l, v[1] / l, v[2] / l];
1635
+ };
1636
+ var W = 96;
1637
+ var H = 48;
1638
+ var SAMPLES = 128;
1639
+ var ROUGHNESSES = [0.04, 0.1, 0.18, 0.28, 0.4, 0.55, 0.7, 0.85, 1];
1640
+ var lobes = studio_environment_default.lobes.map((l) => ({
1641
+ ...l,
1642
+ direction: normalize(l.direction),
1643
+ color: l.color
1644
+ }));
1645
+ function radicalInverse(value) {
1646
+ let result = 0, factor = 0.5;
1647
+ while (value) {
1648
+ result += (value & 1) * factor;
1649
+ value >>>= 1;
1650
+ factor *= 0.5;
1651
+ }
1652
+ return result;
1653
+ }
1654
+ function basis(n) {
1655
+ const t = normalize(Math.abs(n[1]) < 0.999 ? [n[2], 0, -n[0]] : [1, 0, 0]);
1656
+ return [
1657
+ t,
1658
+ [
1659
+ n[1] * t[2] - n[2] * t[1],
1660
+ n[2] * t[0] - n[0] * t[2],
1661
+ n[0] * t[1] - n[1] * t[0]
1662
+ ]
1663
+ ];
1664
+ }
1665
+ function orient(x, y, z, n, t, b) {
1666
+ return [
1667
+ t[0] * x + b[0] * y + n[0] * z,
1668
+ t[1] * x + b[1] * y + n[1] * z,
1669
+ t[2] * x + b[2] * y + n[2] * z
1670
+ ];
1671
+ }
1672
+ function sampleTable(table, n) {
1673
+ const x = (Math.atan2(n[2], n[0]) / (2 * Math.PI) + 0.5) * W - 0.5;
1674
+ const y = Math.max(
1675
+ 0,
1676
+ Math.min(
1677
+ H - 1,
1678
+ Math.acos(Math.max(-1, Math.min(1, n[1]))) / Math.PI * H - 0.5
1679
+ )
1680
+ );
1681
+ const ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy;
1682
+ const a = (ix % W + W) % W, b = (a + 1) % W, c = Math.min(H - 1, iy + 1);
1683
+ return (table[iy * W + a] * (1 - fx) + table[iy * W + b] * fx) * (1 - fy) + (table[c * W + a] * (1 - fx) + table[c * W + b] * fx) * fy;
1684
+ }
1685
+ var cachedTables;
1686
+ function getTables() {
1687
+ if (cachedTables) return cachedTables;
1688
+ const diffuse = lobes.map(() => new Float32Array(W * H));
1689
+ const specular = ROUGHNESSES.map(
1690
+ () => lobes.map(() => new Float32Array(W * H))
1691
+ );
1692
+ const diffuseSamples = Array.from({ length: SAMPLES }, (_, i) => {
1693
+ const phi = 2 * Math.PI * radicalInverse(i), r = Math.sqrt((i + 0.5) / SAMPLES);
1694
+ return [r * Math.cos(phi), r * Math.sin(phi), Math.sqrt(1 - r * r)];
1695
+ });
1696
+ const halfSamples = ROUGHNESSES.map(
1697
+ (rough) => Array.from({ length: SAMPLES }, (_, i) => {
1698
+ const phi = 2 * Math.PI * radicalInverse(i), u = (i + 0.5) / SAMPLES, a = rough * rough;
1699
+ const cos = Math.sqrt((1 - u) / (1 + (a * a - 1) * u)), sin = Math.sqrt(1 - cos * cos);
1700
+ return [sin * Math.cos(phi), sin * Math.sin(phi), cos];
1701
+ })
1702
+ );
1703
+ for (let y = 0; y < H; y++)
1704
+ for (let x = 0; x < W; x++) {
1705
+ const theta = (y + 0.5) / H * Math.PI, phi = ((x + 0.5) / W - 0.5) * 2 * Math.PI;
1706
+ const n = [
1707
+ Math.sin(theta) * Math.cos(phi),
1708
+ Math.cos(theta),
1709
+ Math.sin(theta) * Math.sin(phi)
1710
+ ];
1711
+ const [t, b] = basis(n), index = y * W + x;
1712
+ for (const s of diffuseSamples) {
1713
+ const l = orient(...s, n, t, b);
1714
+ for (let j = 0; j < lobes.length; j++)
1715
+ diffuse[j][index] += Math.exp(lobes[j].sharpness * (dot(l, lobes[j].direction) - 1)) / SAMPLES;
1716
+ }
1717
+ for (let r = 0; r < ROUGHNESSES.length; r++) {
1718
+ let weight = 0;
1719
+ for (const s of halfSamples[r]) {
1720
+ const h = orient(...s, n, t, b), nh = dot(n, h);
1721
+ const l = [
1722
+ 2 * nh * h[0] - n[0],
1723
+ 2 * nh * h[1] - n[1],
1724
+ 2 * nh * h[2] - n[2]
1725
+ ];
1726
+ const nl = Math.max(0, dot(n, l));
1727
+ weight += nl;
1728
+ for (let j = 0; j < lobes.length; j++)
1729
+ specular[r][j][index] += Math.exp(
1730
+ lobes[j].sharpness * (dot(l, lobes[j].direction) - 1)
1731
+ ) * nl;
1732
+ }
1733
+ for (let j = 0; j < lobes.length; j++)
1734
+ specular[r][j][index] /= Math.max(weight, 1e-8);
1735
+ }
1736
+ }
1737
+ const brdf = new Float32Array(64 * 64 * 2);
1738
+ for (let y = 0; y < 64; y++)
1739
+ for (let x = 0; x < 64; x++) {
1740
+ const rough = (y + 0.5) / 64, nv = (x + 0.5) / 64, a = rough * rough, a2 = a * a;
1741
+ const v = [Math.sqrt(1 - nv * nv), 0, nv];
1742
+ let A = 0, B = 0;
1743
+ for (let i = 0; i < SAMPLES; i++) {
1744
+ const u = (i + 0.5) / SAMPLES, phi = 2 * Math.PI * radicalInverse(i);
1745
+ const nh = Math.sqrt((1 - u) / (1 + (a2 - 1) * u)), sin = Math.sqrt(1 - nh * nh);
1746
+ const h = [sin * Math.cos(phi), sin * Math.sin(phi), nh], vh = Math.max(0, dot(v, h));
1747
+ const nl = 2 * vh * nh - nv;
1748
+ if (nl <= 0) continue;
1749
+ const gv = 2 * nv / (nv + Math.sqrt(a2 + (1 - a2) * nv * nv));
1750
+ const gl = 2 * nl / (nl + Math.sqrt(a2 + (1 - a2) * nl * nl));
1751
+ const vis = gv * gl * vh / Math.max(nh * nv, 1e-8), f = (1 - vh) ** 5;
1752
+ A += (1 - f) * vis;
1753
+ B += f * vis;
1754
+ }
1755
+ const index = (y * 64 + x) * 2;
1756
+ brdf[index] = A / SAMPLES;
1757
+ brdf[index + 1] = B / SAMPLES;
1758
+ }
1759
+ cachedTables = { diffuse, specular, brdf };
1760
+ return cachedTables;
1761
+ }
1762
+ var StudioLighting = class {
1763
+ tables = getTables();
1764
+ occlusion;
1765
+ eye;
1766
+ shadowDirections;
1767
+ constructor(drawCalls, camera) {
1768
+ this.occlusion = new RayOcclusion(drawCalls);
1769
+ const world = mat46.invert(mat46.create(), camera.view);
1770
+ if (!world)
1771
+ throw new Error("Realistic rendering requires an invertible camera view.");
1772
+ this.eye = [world[12], world[13], world[14]];
1773
+ this.shadowDirections = lobes.map((l) => {
1774
+ const [t, b] = basis(l.direction);
1775
+ return Array.from({ length: 8 }, (_, i) => {
1776
+ 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);
1777
+ return orient(
1778
+ sin * Math.cos(phi),
1779
+ sin * Math.sin(phi),
1780
+ cos,
1781
+ l.direction,
1782
+ t,
1783
+ b
1784
+ );
1785
+ });
1786
+ });
1787
+ }
1788
+ bounceRadiance(hit, incoming) {
1789
+ const metal = Math.max(0, Math.min(1, hit.material.metallicFactor ?? 1));
1790
+ const irradiance = [...studio_environment_default.ambient];
1791
+ for (let j = 0; j < lobes.length; j++) {
1792
+ const value = sampleTable(this.tables.diffuse[j], hit.normal);
1793
+ for (let k = 0; k < 3; k++) irradiance[k] += value * lobes[j].color[k];
1794
+ }
1795
+ const base = hit.material.baseColorFactor;
1796
+ const rough = Math.max(0.04, Math.min(1, hit.material.roughnessFactor ?? 1));
1797
+ const nv = Math.max(1e-3, -dot(hit.normal, incoming));
1798
+ const reflected = [
1799
+ 2 * nv * hit.normal[0] + incoming[0],
1800
+ 2 * nv * hit.normal[1] + incoming[1],
1801
+ 2 * nv * hit.normal[2] + incoming[2]
1802
+ ];
1803
+ let level = 0;
1804
+ while (level < ROUGHNESSES.length - 2 && ROUGHNESSES[level + 1] < rough)
1805
+ level++;
1806
+ const blend = (rough - ROUGHNESSES[level]) / (ROUGHNESSES[level + 1] - ROUGHNESSES[level]);
1807
+ const index = (Math.min(63, Math.floor(rough * 64)) * 64 + Math.min(63, Math.floor(nv * 64))) * 2;
1808
+ const A = this.tables.brdf[index], B = this.tables.brdf[index + 1];
1809
+ const radiance = irradiance.map(
1810
+ (c, k) => c * base[k] * (1 - metal) * 0.96
1811
+ );
1812
+ const pref = [...studio_environment_default.ambient];
1813
+ for (let j = 0; j < lobes.length; j++) {
1814
+ const value = sampleTable(this.tables.specular[level][j], reflected) * (1 - blend) + sampleTable(this.tables.specular[level + 1][j], reflected) * blend;
1815
+ for (let k = 0; k < 3; k++) pref[k] += value * lobes[j].color[k];
1816
+ }
1817
+ for (let k = 0; k < 3; k++)
1818
+ radiance[k] += pref[k] * ((0.04 * (1 - metal) + base[k] * metal) * A + B);
1819
+ return radiance;
1820
+ }
1821
+ shade(position, n, base, material, sampleIndex = 0) {
1822
+ const v = normalize([
1823
+ this.eye[0] - position[0],
1824
+ this.eye[1] - position[1],
1825
+ this.eye[2] - position[2]
1826
+ ]);
1827
+ const nv = Math.max(1e-3, dot(n, v));
1828
+ const reflected = [
1829
+ 2 * nv * n[0] - v[0],
1830
+ 2 * nv * n[1] - v[1],
1831
+ 2 * nv * n[2] - v[2]
1832
+ ];
1833
+ const origin = [
1834
+ position[0] + n[0] * this.occlusion.bias,
1835
+ position[1] + n[1] * this.occlusion.bias,
1836
+ position[2] + n[2] * this.occlusion.bias
1837
+ ];
1838
+ const metal = Math.max(0, Math.min(1, material.metallicFactor ?? 1));
1839
+ const rough = Math.max(0.04, Math.min(1, material.roughnessFactor ?? 1));
1840
+ let level = 0;
1841
+ while (level < ROUGHNESSES.length - 2 && ROUGHNESSES[level + 1] < rough)
1842
+ level++;
1843
+ const blend = (rough - ROUGHNESSES[level]) / (ROUGHNESSES[level + 1] - ROUGHNESSES[level]);
1844
+ const bx = Math.min(63, Math.floor(nv * 64)), by = Math.min(63, Math.floor(rough * 64)), bi = (by * 64 + bx) * 2;
1845
+ const A = this.tables.brdf[bi], B = this.tables.brdf[bi + 1];
1846
+ const f0 = base.map((c) => 0.04 * (1 - metal) + c * metal);
1847
+ const color = base.map(
1848
+ (c, j) => studio_environment_default.ambient[j] * c * (1 - metal) * 0.96
1849
+ );
1850
+ const specColor = f0.map(
1851
+ (c, j) => studio_environment_default.ambient[j] * (c * A + B)
1852
+ );
1853
+ let bits = sampleIndex + 1;
1854
+ bits = Math.imul(bits ^ bits >>> 16, 2146121005);
1855
+ bits = Math.imul(bits ^ bits >>> 15, 2221713035);
1856
+ const hash = ((bits ^ bits >>> 16) >>> 0) / 4294967296;
1857
+ const phase = hash * 2 * Math.PI, cosPhase = Math.cos(phase), sinPhase = Math.sin(phase);
1858
+ for (let j = 0; j < lobes.length; j++) {
1859
+ const dirs = this.shadowDirections[j];
1860
+ let visible = 0, total = 0;
1861
+ const axis = lobes[j].direction;
1862
+ for (const direction of dirs) {
1863
+ const parallel = dot(direction, axis);
1864
+ const cross = [
1865
+ axis[1] * direction[2] - axis[2] * direction[1],
1866
+ axis[2] * direction[0] - axis[0] * direction[2],
1867
+ axis[0] * direction[1] - axis[1] * direction[0]
1868
+ ];
1869
+ const l = direction.map(
1870
+ (value, k) => axis[k] * parallel + (value - axis[k] * parallel) * cosPhase + cross[k] * sinPhase
1871
+ );
1872
+ const weight = Math.max(0, dot(n, l));
1873
+ total += weight;
1874
+ if (weight > 0 && !this.occlusion.occluded(origin, l)) visible += weight;
1875
+ }
1876
+ const visibility = total > 0 ? visible / total : 1;
1877
+ const diffuse = sampleTable(this.tables.diffuse[j], n) * visibility;
1878
+ const specular = (sampleTable(this.tables.specular[level][j], reflected) * (1 - blend) + sampleTable(this.tables.specular[level + 1][j], reflected) * blend) * visibility;
1879
+ for (let k = 0; k < 3; k++) {
1880
+ color[k] += lobes[j].color[k] * base[k] * (1 - metal) * 0.96 * diffuse;
1881
+ specColor[k] += lobes[j].color[k] * (f0[k] * A + B) * specular;
1882
+ }
1883
+ }
1884
+ const [t, b] = basis(n);
1885
+ let blocked = 0;
1886
+ for (let i = 0; i < 4; i++) {
1887
+ const phi = i * Math.PI / 2 + phase, l = orient(
1888
+ 0.7 * Math.cos(phi),
1889
+ 0.7 * Math.sin(phi),
1890
+ Math.sqrt(0.51),
1891
+ n,
1892
+ t,
1893
+ b
1894
+ );
1895
+ const hit = this.occlusion.trace(origin, l);
1896
+ if (hit) {
1897
+ blocked++;
1898
+ const bounce2 = this.bounceRadiance(hit, l);
1899
+ for (let k = 0; k < 3; k++)
1900
+ color[k] += bounce2[k] * base[k] * (1 - metal) * 0.96 / 4;
1901
+ }
1902
+ }
1903
+ const ao = 1 - blocked / 4;
1904
+ for (let k = 0; k < 3; k++)
1905
+ color[k] -= studio_environment_default.ambient[k] * (1 - ao) * base[k] * (1 - metal) * 0.96;
1906
+ const [rt, rb] = basis(reflected), bounce = [0, 0, 0];
1907
+ let hitWeight = 0, totalWeight = 0;
1908
+ for (let i = 0; i < 8; i++) {
1909
+ const u = (i + 0.5) / 8, phi = 2 * Math.PI * radicalInverse(i) + phase, a = rough * rough;
1910
+ const nh = Math.sqrt((1 - u) / (1 + (a * a - 1) * u)), sin = Math.sqrt(1 - nh * nh);
1911
+ const h = orient(
1912
+ sin * Math.cos(phi),
1913
+ sin * Math.sin(phi),
1914
+ nh,
1915
+ reflected,
1916
+ rt,
1917
+ rb
1918
+ );
1919
+ const l = [
1920
+ 2 * nh * h[0] - reflected[0],
1921
+ 2 * nh * h[1] - reflected[1],
1922
+ 2 * nh * h[2] - reflected[2]
1923
+ ];
1924
+ const weight = Math.max(0, dot(reflected, l));
1925
+ totalWeight += weight;
1926
+ if (weight === 0 || dot(n, l) <= 0) continue;
1927
+ const hit = this.occlusion.trace(origin, l);
1928
+ if (hit) {
1929
+ hitWeight += weight;
1930
+ const radiance = this.bounceRadiance(hit, l);
1931
+ for (let k = 0; k < 3; k++) bounce[k] += radiance[k] * weight;
1932
+ }
1933
+ }
1934
+ for (let k = 0; k < 3; k++)
1935
+ color[k] += specColor[k] * (1 - hitWeight / Math.max(totalWeight, 1e-8)) + bounce[k] / Math.max(totalWeight, 1e-8) * (f0[k] * A + B);
1936
+ return color;
1937
+ }
1938
+ };
1939
+
1334
1940
  // lib/render/renderDrawCalls.ts
1335
1941
  function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint8Bitmap) {
1336
1942
  const options = resolveRenderOptions(optionsInput);
@@ -1346,7 +1952,12 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1346
1952
  options.up,
1347
1953
  options.cameraRotation ?? null
1348
1954
  );
1349
- const renderer = new SoftwareRenderer(renderWidth, renderHeight, imageFactory);
1955
+ const renderer = new SoftwareRenderer(
1956
+ renderWidth,
1957
+ renderHeight,
1958
+ imageFactory,
1959
+ options.realistic
1960
+ );
1350
1961
  let rgb = null;
1351
1962
  if (options.backgroundColor) {
1352
1963
  if (typeof options.backgroundColor === "string") {
@@ -1407,6 +2018,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1407
2018
  const blendDraws = allDrawCalls.filter(
1408
2019
  (dc) => (dc.material.alphaMode ?? "OPAQUE") === "BLEND"
1409
2020
  );
2021
+ const studio = options.realistic ? new StudioLighting(allDrawCalls, camera) : void 0;
1410
2022
  const renderGroup = (dcs) => {
1411
2023
  for (const dc of dcs) {
1412
2024
  if (dc.mode === 1) {
@@ -1418,7 +2030,8 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1418
2030
  { dir: options.lightDir, ambient: options.ambient },
1419
2031
  dc.material,
1420
2032
  options.cull,
1421
- options.gamma
2033
+ options.gamma,
2034
+ studio
1422
2035
  );
1423
2036
  }
1424
2037
  }
@@ -1442,6 +2055,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1442
2055
  });
1443
2056
  }
1444
2057
  renderGroup(blendDraws);
2058
+ renderer.denoise(options.gamma);
1445
2059
  for (const dc of drawCalls) {
1446
2060
  if (!dc.showHiddenEdges) continue;
1447
2061
  const edgeDrawCall = createEdgeDrawCall(dc);
@@ -1480,7 +2094,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1480
2094
  }
1481
2095
 
1482
2096
  // lib/gltf/createInfiniteGrid.ts
1483
- import { mat4 as mat46 } from "gl-matrix";
2097
+ import { mat4 as mat47 } from "gl-matrix";
1484
2098
  function createInfiniteGrid(options = {}) {
1485
2099
  const cellSize = options.cellSize ?? 1;
1486
2100
  const sectionSize = options.sectionSize ?? 10;
@@ -1504,9 +2118,9 @@ function createInfiniteGrid(options = {}) {
1504
2118
  size
1505
2119
  ]);
1506
2120
  const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
1507
- const model = mat46.create();
2121
+ const model = mat47.create();
1508
2122
  const offset = options.offset ?? {};
1509
- mat46.fromTranslation(model, [offset.x ?? 0, offset.y ?? 0, offset.z ?? 0]);
2123
+ mat47.fromTranslation(model, [offset.x ?? 0, offset.y ?? 0, offset.z ?? 0]);
1510
2124
  return {
1511
2125
  positions,
1512
2126
  indices,
@@ -1531,7 +2145,7 @@ function createInfiniteGrid(options = {}) {
1531
2145
  }
1532
2146
 
1533
2147
  // lib/gltf/createSceneFromGLTF.ts
1534
- import { mat4 as mat47 } from "gl-matrix";
2148
+ import { mat4 as mat48 } from "gl-matrix";
1535
2149
  var COMPONENT_INFO = {
1536
2150
  5120: {
1537
2151
  name: "BYTE",
@@ -1738,17 +2352,17 @@ function readIndices(ctx, accessorIndex) {
1738
2352
  }
1739
2353
  function nodeLocalMatrix(node) {
1740
2354
  if (node.matrix) {
1741
- const m2 = mat47.create();
2355
+ const m2 = mat48.create();
1742
2356
  for (let i = 0; i < 16; i++) m2[i] = node.matrix[i];
1743
2357
  return m2;
1744
2358
  }
1745
2359
  const translation = node.translation || [0, 0, 0];
1746
2360
  const rotation = node.rotation || [0, 0, 0, 1];
1747
2361
  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);
2362
+ const m = mat48.create();
2363
+ const tmp = mat48.create();
2364
+ mat48.fromRotationTranslationScale(tmp, rotation, translation, scale);
2365
+ mat48.copy(m, tmp);
1752
2366
  return m;
1753
2367
  }
1754
2368
  function getMaterial(gltf, textures, images, materialIndex) {
@@ -1769,6 +2383,8 @@ function getMaterial(gltf, textures, images, materialIndex) {
1769
2383
  return {
1770
2384
  baseColorFactor: factor,
1771
2385
  baseColorTexture: texImg,
2386
+ metallicFactor: pbr.metallicFactor ?? 1,
2387
+ roughnessFactor: pbr.roughnessFactor ?? 1,
1772
2388
  alphaMode,
1773
2389
  alphaCutoff
1774
2390
  };
@@ -1786,8 +2402,8 @@ function createSceneFromGLTF(gltf, resources) {
1786
2402
  const node = nodes[nodeIndex];
1787
2403
  if (!node) return;
1788
2404
  const local = nodeLocalMatrix(node);
1789
- const world = mat47.create();
1790
- mat47.multiply(world, parentMatrix, local);
2405
+ const world = mat48.create();
2406
+ mat48.multiply(world, parentMatrix, local);
1791
2407
  if (Number.isInteger(node.mesh)) {
1792
2408
  const mesh = meshes[node.mesh];
1793
2409
  if (mesh) {
@@ -1826,7 +2442,7 @@ function createSceneFromGLTF(gltf, resources) {
1826
2442
  traverse(childIndex, world);
1827
2443
  }
1828
2444
  }
1829
- const identity = mat47.create();
2445
+ const identity = mat48.create();
1830
2446
  for (const rootIndex of scene.nodes || []) {
1831
2447
  traverse(rootIndex, identity);
1832
2448
  }
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.31",
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",