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 +78 -10
- package/dist/index.d.ts +20 -3
- package/dist/index.js +638 -22
- package/package.json +4 -2
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
|
-
|
|
5
|
+

|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
- `
|
|
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 (
|
|
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`:
|
|
63
|
-
- `ambient`: ambient lighting contribution
|
|
64
|
-
- `gamma`: gamma correction
|
|
65
|
-
- `cull`: back-face culling
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
2121
|
+
const model = mat47.create();
|
|
1508
2122
|
const offset = options.offset ?? {};
|
|
1509
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
1749
|
-
const tmp =
|
|
1750
|
-
|
|
1751
|
-
|
|
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 =
|
|
1790
|
-
|
|
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 =
|
|
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.
|
|
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",
|