poppygl 0.0.29 → 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,
@@ -512,22 +516,168 @@ function srgbDecodeToLinear01(srgb) {
512
516
  // lib/utils/clamp.ts
513
517
  var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
514
518
 
519
+ // lib/render/clipMeshToNearPlane.ts
520
+ function clipMeshToNearPlane(mesh, mvp) {
521
+ const count = mesh.positions.length / 3;
522
+ const distances = new Float64Array(count);
523
+ let needsClipping = false;
524
+ for (let i = 0; i < count; i++) {
525
+ const distance = (mvp[2] + mvp[3]) * mesh.positions[i * 3] + (mvp[6] + mvp[7]) * mesh.positions[i * 3 + 1] + (mvp[10] + mvp[11]) * mesh.positions[i * 3 + 2] + mvp[14] + mvp[15];
526
+ distances[i] = distance;
527
+ if (!(distance >= 0)) needsClipping = true;
528
+ }
529
+ if (!needsClipping) return mesh;
530
+ const positions = Array.from(mesh.positions);
531
+ const normals = mesh.normals ? Array.from(mesh.normals) : null;
532
+ const uvs = mesh.uvs ? Array.from(mesh.uvs) : null;
533
+ const colors = mesh.colors ? Array.from(mesh.colors) : null;
534
+ const indices = [];
535
+ const sourceIndices = mesh.indices ?? Uint32Array.from({ length: count }, (_, i) => i);
536
+ const intersections = /* @__PURE__ */ new Map();
537
+ const intersect = (a, b) => {
538
+ if (a > b) [a, b] = [b, a];
539
+ if (distances[a] === 0) return a;
540
+ if (distances[b] === 0) return b;
541
+ const key = `${a}:${b}`;
542
+ const cached = intersections.get(key);
543
+ if (cached !== void 0) return cached;
544
+ const t = distances[a] / (distances[a] - distances[b]);
545
+ const index = positions.length / 3;
546
+ const interpolate = (values, size) => {
547
+ if (!values) return;
548
+ for (let k = 0; k < size; k++) {
549
+ const start = values[a * size + k];
550
+ values.push(start + t * (values[b * size + k] - start));
551
+ }
552
+ };
553
+ interpolate(positions, 3);
554
+ interpolate(normals, 3);
555
+ interpolate(uvs, 2);
556
+ interpolate(colors, 3);
557
+ intersections.set(key, index);
558
+ return index;
559
+ };
560
+ for (let i = 0; i < sourceIndices.length; i += 3) {
561
+ const triangle = [
562
+ sourceIndices[i],
563
+ sourceIndices[i + 1],
564
+ sourceIndices[i + 2]
565
+ ];
566
+ if (triangle.some((vertex) => !Number.isFinite(distances[vertex]))) continue;
567
+ const polygon = [];
568
+ for (let j = 0; j < 3; j++) {
569
+ const a = triangle[j];
570
+ const b = triangle[(j + 1) % 3];
571
+ const aInside = distances[a] >= 0;
572
+ const bInside = distances[b] >= 0;
573
+ if (aInside) polygon.push(a);
574
+ if (aInside !== bInside) polygon.push(intersect(a, b));
575
+ }
576
+ for (let j = 1; j + 1 < polygon.length; j++) {
577
+ indices.push(polygon[0], polygon[j], polygon[j + 1]);
578
+ }
579
+ }
580
+ return {
581
+ ...mesh,
582
+ positions: new Float32Array(positions),
583
+ normals: normals ? new Float32Array(normals) : null,
584
+ uvs: uvs ? new Float32Array(uvs) : null,
585
+ colors: colors ? new Float32Array(colors) : null,
586
+ indices: new Uint32Array(indices)
587
+ };
588
+ }
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
+
515
662
  // lib/render/SoftwareRenderer.ts
516
663
  var SoftwareRenderer = class {
517
664
  width;
518
665
  height;
519
666
  bitmap;
520
667
  depth;
521
- constructor(width, height, imageFactory = createUint8Bitmap) {
668
+ denoiser;
669
+ constructor(width, height, imageFactory = createUint8Bitmap, denoise = false) {
522
670
  this.width = width;
523
671
  this.height = height;
524
672
  this.bitmap = imageFactory(width, height);
525
673
  this.depth = new Float32Array(width * height);
674
+ this.denoiser = denoise ? new GeometryDenoiser(width, height) : null;
526
675
  }
527
676
  get buffer() {
528
677
  return this.bitmap.data;
529
678
  }
530
679
  clear(colorRGBA = [0, 0, 0, 255]) {
680
+ this.denoiser?.ids.fill(0);
531
681
  const [r, g, b, a] = colorRGBA;
532
682
  for (let i = 0; i < this.width * this.height; i++) {
533
683
  const j = i * 4;
@@ -538,14 +688,18 @@ var SoftwareRenderer = class {
538
688
  this.depth[i] = Infinity;
539
689
  }
540
690
  }
541
- setPixel(x, y, r, g, b, a) {
691
+ setPixel(x, y, r, g, b, a, keepGuide = false) {
542
692
  if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
543
693
  const idx = (y * this.width + x) * 4;
694
+ if (this.denoiser && !keepGuide) this.denoiser.ids[y * this.width + x] = 0;
544
695
  this.buffer[idx + 0] = r;
545
696
  this.buffer[idx + 1] = g;
546
697
  this.buffer[idx + 2] = b;
547
698
  this.buffer[idx + 3] = a;
548
699
  }
700
+ denoise(gammaOut = true) {
701
+ this.denoiser?.apply(this.buffer, gammaOut);
702
+ }
549
703
  drawLines(mesh, camera, gammaOut = true, options = {}) {
550
704
  const { positions, indices, model, material } = mesh;
551
705
  if (!indices) return;
@@ -681,16 +835,17 @@ var SoftwareRenderer = class {
681
835
  }
682
836
  return out;
683
837
  }
684
- drawMesh(mesh, camera, light, material, cullBackFaces = true, gammaOut = true) {
685
- const { positions, normals, uvs, indices, model, colors } = mesh;
838
+ drawMesh(mesh, camera, light, material, cullBackFaces = true, gammaOut = true, studio) {
839
+ let { positions, normals, uvs, indices, model, colors } = mesh;
840
+ const materialId = this.denoiser?.materialId(material) ?? 0;
686
841
  const view = camera.view;
687
842
  const proj = camera.proj;
688
843
  const mvp = mat43.create();
689
844
  mat43.multiply(mvp, proj, mat43.multiply(mat43.create(), view, model));
690
845
  const normalMat = mat3.create();
691
846
  mat3.normalFromMat4(normalMat, model);
692
- const vertexCount = positions.length / 3 | 0;
693
- const idx = indices ?? (() => {
847
+ let vertexCount = positions.length / 3 | 0;
848
+ let idx = indices ?? (() => {
694
849
  const a = new Uint32Array(vertexCount);
695
850
  for (let i = 0; i < vertexCount; i++) a[i] = i;
696
851
  return a;
@@ -699,10 +854,21 @@ var SoftwareRenderer = class {
699
854
  if (!useNormals) {
700
855
  useNormals = computeSmoothNormals(positions, idx);
701
856
  }
857
+ const clipped = clipMeshToNearPlane(
858
+ { ...mesh, normals: useNormals, indices: idx },
859
+ mvp
860
+ );
861
+ positions = clipped.positions;
862
+ useNormals = clipped.normals;
863
+ uvs = clipped.uvs;
864
+ colors = clipped.colors;
865
+ idx = clipped.indices;
866
+ vertexCount = positions.length / 3;
702
867
  const vScreen = new Array(vertexCount);
703
868
  const vInvW = new Float32Array(vertexCount);
704
869
  const vNDCz = new Float32Array(vertexCount);
705
870
  const vWorldN = new Array(vertexCount);
871
+ const vWorldP = studio ? new Array(vertexCount) : null;
706
872
  const vColor = new Array(vertexCount);
707
873
  for (let i = 0; i < vertexCount; i++) {
708
874
  const p = vec42.fromValues(
@@ -713,6 +879,10 @@ var SoftwareRenderer = class {
713
879
  );
714
880
  const c = vec42.create();
715
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
+ }
716
886
  const invW = 1 / c[3];
717
887
  const ndcX = c[0] * invW;
718
888
  const ndcY = c[1] * invW;
@@ -773,6 +943,7 @@ var SoftwareRenderer = class {
773
943
  vWorldN[i1],
774
944
  vWorldN[i2]
775
945
  ];
946
+ const worldPositions = vWorldP ? [vWorldP[i0], vWorldP[i1], vWorldP[i2]] : null;
776
947
  const uv = uvs ? [
777
948
  [uvs[i0 * 2 + 0], uvs[i0 * 2 + 1]],
778
949
  [uvs[i1 * 2 + 0], uvs[i1 * 2 + 1]],
@@ -853,7 +1024,22 @@ var SoftwareRenderer = class {
853
1024
  let r = baseColor[0] * lit;
854
1025
  let g = baseColor[1] * lit;
855
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
+ }
856
1041
  let a = baseColor[3];
1042
+ const linearR = r, linearG = g, linearB = b;
857
1043
  const alphaMode = material.alphaMode ?? "OPAQUE";
858
1044
  const alphaCutoff = material.alphaCutoff ?? 0.5;
859
1045
  if (alphaMode === "MASK" && a < alphaCutoff) continue;
@@ -895,8 +1081,22 @@ var SoftwareRenderer = class {
895
1081
  clamp(outR, 0, 1) * 255 | 0,
896
1082
  clamp(outG, 0, 1) * 255 | 0,
897
1083
  clamp(outB, 0, 1) * 255 | 0,
898
- clamp(outA, 0, 1) * 255 | 0
1084
+ clamp(outA, 0, 1) * 255 | 0,
1085
+ true
899
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
+ }
900
1100
  }
901
1101
  }
902
1102
  }
@@ -1250,6 +1450,493 @@ function resolveRenderOptions(options = {}) {
1250
1450
  };
1251
1451
  }
1252
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
+
1253
1940
  // lib/render/renderDrawCalls.ts
1254
1941
  function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint8Bitmap) {
1255
1942
  const options = resolveRenderOptions(optionsInput);
@@ -1265,7 +1952,12 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1265
1952
  options.up,
1266
1953
  options.cameraRotation ?? null
1267
1954
  );
1268
- const renderer = new SoftwareRenderer(renderWidth, renderHeight, imageFactory);
1955
+ const renderer = new SoftwareRenderer(
1956
+ renderWidth,
1957
+ renderHeight,
1958
+ imageFactory,
1959
+ options.realistic
1960
+ );
1269
1961
  let rgb = null;
1270
1962
  if (options.backgroundColor) {
1271
1963
  if (typeof options.backgroundColor === "string") {
@@ -1326,6 +2018,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1326
2018
  const blendDraws = allDrawCalls.filter(
1327
2019
  (dc) => (dc.material.alphaMode ?? "OPAQUE") === "BLEND"
1328
2020
  );
2021
+ const studio = options.realistic ? new StudioLighting(allDrawCalls, camera) : void 0;
1329
2022
  const renderGroup = (dcs) => {
1330
2023
  for (const dc of dcs) {
1331
2024
  if (dc.mode === 1) {
@@ -1337,7 +2030,8 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1337
2030
  { dir: options.lightDir, ambient: options.ambient },
1338
2031
  dc.material,
1339
2032
  options.cull,
1340
- options.gamma
2033
+ options.gamma,
2034
+ studio
1341
2035
  );
1342
2036
  }
1343
2037
  }
@@ -1361,6 +2055,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1361
2055
  });
1362
2056
  }
1363
2057
  renderGroup(blendDraws);
2058
+ renderer.denoise(options.gamma);
1364
2059
  for (const dc of drawCalls) {
1365
2060
  if (!dc.showHiddenEdges) continue;
1366
2061
  const edgeDrawCall = createEdgeDrawCall(dc);
@@ -1399,7 +2094,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
1399
2094
  }
1400
2095
 
1401
2096
  // lib/gltf/createInfiniteGrid.ts
1402
- import { mat4 as mat46 } from "gl-matrix";
2097
+ import { mat4 as mat47 } from "gl-matrix";
1403
2098
  function createInfiniteGrid(options = {}) {
1404
2099
  const cellSize = options.cellSize ?? 1;
1405
2100
  const sectionSize = options.sectionSize ?? 10;
@@ -1423,9 +2118,9 @@ function createInfiniteGrid(options = {}) {
1423
2118
  size
1424
2119
  ]);
1425
2120
  const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
1426
- const model = mat46.create();
2121
+ const model = mat47.create();
1427
2122
  const offset = options.offset ?? {};
1428
- 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]);
1429
2124
  return {
1430
2125
  positions,
1431
2126
  indices,
@@ -1450,7 +2145,7 @@ function createInfiniteGrid(options = {}) {
1450
2145
  }
1451
2146
 
1452
2147
  // lib/gltf/createSceneFromGLTF.ts
1453
- import { mat4 as mat47 } from "gl-matrix";
2148
+ import { mat4 as mat48 } from "gl-matrix";
1454
2149
  var COMPONENT_INFO = {
1455
2150
  5120: {
1456
2151
  name: "BYTE",
@@ -1657,17 +2352,17 @@ function readIndices(ctx, accessorIndex) {
1657
2352
  }
1658
2353
  function nodeLocalMatrix(node) {
1659
2354
  if (node.matrix) {
1660
- const m2 = mat47.create();
2355
+ const m2 = mat48.create();
1661
2356
  for (let i = 0; i < 16; i++) m2[i] = node.matrix[i];
1662
2357
  return m2;
1663
2358
  }
1664
2359
  const translation = node.translation || [0, 0, 0];
1665
2360
  const rotation = node.rotation || [0, 0, 0, 1];
1666
2361
  const scale = node.scale || [1, 1, 1];
1667
- const m = mat47.create();
1668
- const tmp = mat47.create();
1669
- mat47.fromRotationTranslationScale(tmp, rotation, translation, scale);
1670
- 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);
1671
2366
  return m;
1672
2367
  }
1673
2368
  function getMaterial(gltf, textures, images, materialIndex) {
@@ -1688,6 +2383,8 @@ function getMaterial(gltf, textures, images, materialIndex) {
1688
2383
  return {
1689
2384
  baseColorFactor: factor,
1690
2385
  baseColorTexture: texImg,
2386
+ metallicFactor: pbr.metallicFactor ?? 1,
2387
+ roughnessFactor: pbr.roughnessFactor ?? 1,
1691
2388
  alphaMode,
1692
2389
  alphaCutoff
1693
2390
  };
@@ -1705,8 +2402,8 @@ function createSceneFromGLTF(gltf, resources) {
1705
2402
  const node = nodes[nodeIndex];
1706
2403
  if (!node) return;
1707
2404
  const local = nodeLocalMatrix(node);
1708
- const world = mat47.create();
1709
- mat47.multiply(world, parentMatrix, local);
2405
+ const world = mat48.create();
2406
+ mat48.multiply(world, parentMatrix, local);
1710
2407
  if (Number.isInteger(node.mesh)) {
1711
2408
  const mesh = meshes[node.mesh];
1712
2409
  if (mesh) {
@@ -1745,7 +2442,7 @@ function createSceneFromGLTF(gltf, resources) {
1745
2442
  traverse(childIndex, world);
1746
2443
  }
1747
2444
  }
1748
- const identity = mat47.create();
2445
+ const identity = mat48.create();
1749
2446
  for (const rootIndex of scene.nodes || []) {
1750
2447
  traverse(rootIndex, identity);
1751
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.29",
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",