poppygl 0.0.1

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/index.js +769 -0
  4. package/package.json +28 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 tscircuit Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # poppygl
2
+
3
+ Render GLTF files to PNG images in completely native javascript without WebGL/OpenGL
4
+
5
+ ![example image](./tests/basics/__snapshots__/basics01.snap.png)
6
+
7
+ ```tsx
8
+ import { renderGLTFToPNGBuffer } from "poppygl"
9
+
10
+ const gltfPath = "./circuit.gltf" // path to your GLTF file
11
+
12
+ const pngBuffer = await renderGLTFToPNGBuffer(gltfPath, {
13
+ width: 320,
14
+ height: 240,
15
+ })
16
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,769 @@
1
+ #!/usr/bin/env node
2
+
3
+ // lib/index.ts
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { PassThrough } from "stream";
7
+ import { pathToFileURL } from "url";
8
+ import PImage from "pureimage";
9
+ import { mat3, mat4, vec3, vec4 } from "gl-matrix";
10
+ var DEFAULT_LIGHT_DIR = [-0.4, -0.9, -0.2];
11
+ var DEFAULT_RENDER_OPTIONS = {
12
+ width: 800,
13
+ height: 600,
14
+ fov: 60,
15
+ cull: true,
16
+ gamma: true,
17
+ ambient: 0.15,
18
+ lightDir: DEFAULT_LIGHT_DIR,
19
+ camPos: null,
20
+ lookAt: null
21
+ };
22
+ var clamp = (x, a, b) => Math.max(a, Math.min(b, x));
23
+ var toRad = (d) => d * Math.PI / 180;
24
+ function bufferFromDataURI(uri) {
25
+ const m = uri.match(/^data:.*?;base64,(.*)$/);
26
+ if (!m) throw new Error(`Unsupported data URI: ${uri.slice(0, 64)}...`);
27
+ return Buffer.from(m[1], "base64");
28
+ }
29
+ function isPNG(filenameOrUri) {
30
+ return /\.png(\?|$)/i.test(filenameOrUri) || /image\/png/i.test(filenameOrUri);
31
+ }
32
+ function isJPG(filenameOrUri) {
33
+ return /\.(jpg|jpeg)(\?|$)/i.test(filenameOrUri) || /image\/jpe?g/i.test(filenameOrUri);
34
+ }
35
+ function bufferToStream(buf) {
36
+ const s = new PassThrough();
37
+ s.end(buf);
38
+ return s;
39
+ }
40
+ async function encodePNGToBuffer(image) {
41
+ const passThrough = new PassThrough();
42
+ const chunks = [];
43
+ passThrough.on("data", (chunk) => {
44
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
45
+ });
46
+ const resultPromise = new Promise((resolve2, reject) => {
47
+ passThrough.on("end", () => resolve2(Buffer.concat(chunks)));
48
+ passThrough.on("error", reject);
49
+ });
50
+ await PImage.encodePNGToStream(image, passThrough);
51
+ return await resultPromise;
52
+ }
53
+ function srgbEncodeLinear01(x) {
54
+ if (x <= 31308e-7) return 12.92 * x;
55
+ return 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
56
+ }
57
+ function mulColor(a, b) {
58
+ return [a[0] * b[0], a[1] * b[1], a[2] * b[2], a[3] * b[3]];
59
+ }
60
+ async function loadGLTF(gltfPath) {
61
+ const baseDir = path.dirname(gltfPath);
62
+ const gltf = JSON.parse(fs.readFileSync(gltfPath, "utf8"));
63
+ const buffers = await Promise.all(
64
+ (gltf.buffers || []).map(async (b) => {
65
+ if (b.uri && b.uri.startsWith("data:")) {
66
+ return bufferFromDataURI(b.uri);
67
+ } else if (b.uri) {
68
+ const p = path.resolve(baseDir, decodeURIComponent(b.uri));
69
+ return fs.readFileSync(p);
70
+ } else {
71
+ throw new Error(
72
+ "Buffer without uri not supported in this lightweight loader."
73
+ );
74
+ }
75
+ })
76
+ );
77
+ function detectMimeTypeFromBuffer(buf, hint) {
78
+ if (hint) return hint;
79
+ if (buf.length >= 8 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71 && buf[4] === 13 && buf[5] === 10 && buf[6] === 26 && buf[7] === 10)
80
+ return "image/png";
81
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 216)
82
+ return "image/jpeg";
83
+ return null;
84
+ }
85
+ async function decodeImageFromBuffer(buf, mimeType) {
86
+ const type = detectMimeTypeFromBuffer(buf, mimeType);
87
+ if (type === "image/png")
88
+ return PImage.decodePNGFromStream(bufferToStream(buf));
89
+ if (type === "image/jpeg" || type === "image/jpg")
90
+ return PImage.decodeJPEGFromStream(bufferToStream(buf));
91
+ throw new Error("Unsupported embedded image mimeType: " + (mimeType || "unknown"));
92
+ }
93
+ const images = await Promise.all(
94
+ (gltf.images || []).map(async (img) => {
95
+ if (img.uri) {
96
+ if (img.uri.startsWith("data:")) {
97
+ const buf = bufferFromDataURI(img.uri);
98
+ if (isPNG(img.uri))
99
+ return PImage.decodePNGFromStream(bufferToStream(buf));
100
+ if (isJPG(img.uri))
101
+ return PImage.decodeJPEGFromStream(bufferToStream(buf));
102
+ throw new Error(
103
+ `Unsupported data-URI image type for ${img.uri.slice(0, 32)}...`
104
+ );
105
+ }
106
+ const fpath = path.resolve(baseDir, decodeURIComponent(img.uri));
107
+ if (isPNG(img.uri))
108
+ return PImage.decodePNGFromStream(fs.createReadStream(fpath));
109
+ if (isJPG(img.uri))
110
+ return PImage.decodeJPEGFromStream(fs.createReadStream(fpath));
111
+ return PImage.decodePNGFromStream(fs.createReadStream(fpath)).catch(
112
+ () => PImage.decodeJPEGFromStream(fs.createReadStream(fpath))
113
+ );
114
+ }
115
+ if (typeof img.bufferView === "number") {
116
+ const bv = gltf.bufferViews[img.bufferView];
117
+ if (!bv) throw new Error(`Invalid image bufferView index ${img.bufferView}`);
118
+ const buffer = buffers[bv.buffer];
119
+ if (!buffer) throw new Error(`Missing buffer for image bufferView ${img.bufferView}`);
120
+ const byteOffset = bv.byteOffset || 0;
121
+ const byteLength = bv.byteLength;
122
+ if (typeof byteLength !== "number")
123
+ throw new Error(`bufferView ${img.bufferView} missing byteLength for image.`);
124
+ const slice = buffer.slice(byteOffset, byteOffset + byteLength);
125
+ return decodeImageFromBuffer(slice, img.mimeType);
126
+ }
127
+ throw new Error(
128
+ "images[*] entry missing uri or bufferView; unsupported in this lightweight loader."
129
+ );
130
+ })
131
+ );
132
+ const COMPONENT_INFO = {
133
+ 5120: {
134
+ name: "BYTE",
135
+ size: 1,
136
+ array: Int8Array,
137
+ norm: (v) => Math.max(-1, v / 127)
138
+ },
139
+ 5121: {
140
+ name: "UNSIGNED_BYTE",
141
+ size: 1,
142
+ array: Uint8Array,
143
+ norm: (v) => v / 255
144
+ },
145
+ 5122: {
146
+ name: "SHORT",
147
+ size: 2,
148
+ array: Int16Array,
149
+ norm: (v) => Math.max(-1, v / 32767)
150
+ },
151
+ 5123: {
152
+ name: "UNSIGNED_SHORT",
153
+ size: 2,
154
+ array: Uint16Array,
155
+ norm: (v) => v / 65535
156
+ },
157
+ 5125: {
158
+ name: "UNSIGNED_INT",
159
+ size: 4,
160
+ array: Uint32Array,
161
+ norm: (v) => v / 4294967295
162
+ },
163
+ 5126: { name: "FLOAT", size: 4, array: Float32Array, norm: (v) => v }
164
+ };
165
+ const NUM_COMP = {
166
+ SCALAR: 1,
167
+ VEC2: 2,
168
+ VEC3: 3,
169
+ VEC4: 4,
170
+ MAT2: 4,
171
+ MAT3: 9,
172
+ MAT4: 16
173
+ };
174
+ function readAccessorAsFloat32(accIndex) {
175
+ const acc = gltf.accessors[accIndex];
176
+ if (acc.sparse)
177
+ throw new Error("Sparse accessors not supported (lightweight).");
178
+ const bv = gltf.bufferViews[acc.bufferView];
179
+ const comp = COMPONENT_INFO[acc.componentType];
180
+ const ncomp = NUM_COMP[acc.type];
181
+ const stride = bv.byteStride || comp.size * ncomp;
182
+ const count = acc.count;
183
+ const buf = buffers[bv.buffer];
184
+ const base = (bv.byteOffset || 0) + (acc.byteOffset || 0);
185
+ const src = new Uint8Array(
186
+ buf.buffer,
187
+ buf.byteOffset + base,
188
+ bv.byteLength - (acc.byteOffset || 0)
189
+ );
190
+ const out = new Float32Array(count * ncomp);
191
+ const canUseTightView = stride === comp.size * ncomp && src.byteOffset % comp.size === 0;
192
+ if (canUseTightView) {
193
+ const Typed = comp.array;
194
+ const tarr = new Typed(src.buffer, src.byteOffset, count * ncomp);
195
+ if (comp.name === "FLOAT" && !acc.normalized)
196
+ return new Float32Array(tarr.buffer, tarr.byteOffset, tarr.length);
197
+ for (let i = 0; i < tarr.length; i++)
198
+ out[i] = acc.normalized ? comp.norm(tarr[i]) : tarr[i];
199
+ return out;
200
+ }
201
+ const dv = new DataView(src.buffer, src.byteOffset, src.byteLength);
202
+ let off = 0;
203
+ for (let i = 0; i < count; i++) {
204
+ let o = i * stride;
205
+ for (let c = 0; c < ncomp; c++) {
206
+ let v;
207
+ switch (acc.componentType) {
208
+ case 5120:
209
+ v = dv.getInt8(o + c * comp.size);
210
+ break;
211
+ case 5121:
212
+ v = dv.getUint8(o + c * comp.size);
213
+ break;
214
+ case 5122:
215
+ v = dv.getInt16(o + c * comp.size, true);
216
+ break;
217
+ case 5123:
218
+ v = dv.getUint16(o + c * comp.size, true);
219
+ break;
220
+ case 5125:
221
+ v = dv.getUint32(o + c * comp.size, true);
222
+ break;
223
+ case 5126:
224
+ v = dv.getFloat32(o + c * comp.size, true);
225
+ break;
226
+ default:
227
+ throw new Error("Unknown componentType");
228
+ }
229
+ out[off++] = acc.normalized ? COMPONENT_INFO[acc.componentType].norm(v) : v;
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+ function readIndices(accIndex) {
235
+ const acc = gltf.accessors[accIndex];
236
+ if (acc.type !== "SCALAR")
237
+ throw new Error("Indices accessor must be SCALAR");
238
+ const bv = gltf.bufferViews[acc.bufferView];
239
+ const comp = COMPONENT_INFO[acc.componentType];
240
+ if (acc.componentType !== 5121 && acc.componentType !== 5123 && acc.componentType !== 5125) {
241
+ throw new Error("Index componentType must be UNSIGNED_BYTE/SHORT/INT");
242
+ }
243
+ const stride = bv.byteStride || comp.size;
244
+ const count = acc.count;
245
+ const buf = buffers[bv.buffer];
246
+ const base = (bv.byteOffset || 0) + (acc.byteOffset || 0);
247
+ const src = new Uint8Array(
248
+ buf.buffer,
249
+ buf.byteOffset + base,
250
+ bv.byteLength - (acc.byteOffset || 0)
251
+ );
252
+ const canUseTightView = stride === comp.size && src.byteOffset % comp.size === 0;
253
+ if (canUseTightView) {
254
+ const Typed = comp.array;
255
+ const tarr = new Typed(src.buffer, src.byteOffset, count);
256
+ return acc.componentType === 5125 ? new Uint32Array(tarr.buffer, tarr.byteOffset, tarr.length) : new Uint32Array(tarr);
257
+ }
258
+ const dv = new DataView(src.buffer, src.byteOffset, src.byteLength);
259
+ const out = new Uint32Array(count);
260
+ for (let i = 0; i < count; i++) {
261
+ const o = i * stride;
262
+ let v;
263
+ switch (acc.componentType) {
264
+ case 5121:
265
+ v = dv.getUint8(o);
266
+ break;
267
+ case 5123:
268
+ v = dv.getUint16(o, true);
269
+ break;
270
+ case 5125:
271
+ v = dv.getUint32(o, true);
272
+ break;
273
+ }
274
+ out[i] = v >>> 0;
275
+ }
276
+ return out;
277
+ }
278
+ const textures = gltf.textures || [];
279
+ function getMaterial(matIndex) {
280
+ const m = (gltf.materials || [])[matIndex] || {};
281
+ const pbr = m.pbrMetallicRoughness || {};
282
+ const factor = pbr.baseColorFactor || [1, 1, 1, 1];
283
+ let texImg = null;
284
+ if (pbr.baseColorTexture && Number.isInteger(pbr.baseColorTexture.index)) {
285
+ const t = textures[pbr.baseColorTexture.index];
286
+ if (t && Number.isInteger(t.source)) texImg = images[t.source] || null;
287
+ }
288
+ return { baseColorFactor: factor, baseColorTexture: texImg };
289
+ }
290
+ const nodes = gltf.nodes || [];
291
+ const meshes = gltf.meshes || [];
292
+ const scenes = gltf.scenes || [];
293
+ const sceneIndex = Number.isInteger(gltf.scene) ? gltf.scene : 0;
294
+ const scene = scenes[sceneIndex] || { nodes: [] };
295
+ const drawCalls = [];
296
+ function nodeLocalMatrix(n) {
297
+ if (n.matrix) {
298
+ const m2 = mat4.create();
299
+ for (let i = 0; i < 16; i++) m2[i] = n.matrix[i];
300
+ return m2;
301
+ }
302
+ const t = n.translation || [0, 0, 0];
303
+ const r = n.rotation || [0, 0, 0, 1];
304
+ const s = n.scale || [1, 1, 1];
305
+ const m = mat4.create();
306
+ const mq = mat4.create();
307
+ mat4.fromRotationTranslationScale(mq, r, t, s);
308
+ mat4.copy(m, mq);
309
+ return m;
310
+ }
311
+ function traverse(nodeIndex, parentMatrix) {
312
+ const n = nodes[nodeIndex];
313
+ const local = nodeLocalMatrix(n);
314
+ const world = mat4.create();
315
+ mat4.multiply(world, parentMatrix, local);
316
+ if (Number.isInteger(n.mesh)) {
317
+ const mesh = meshes[n.mesh];
318
+ for (const prim of mesh.primitives) {
319
+ if (prim.mode != null && prim.mode !== 4) continue;
320
+ const posAcc = prim.attributes.POSITION;
321
+ if (posAcc == null) continue;
322
+ const norAcc = prim.attributes.NORMAL;
323
+ const uvAcc = prim.attributes.TEXCOORD_0;
324
+ const positions = readAccessorAsFloat32(posAcc);
325
+ const normals = norAcc != null ? readAccessorAsFloat32(norAcc) : null;
326
+ const uvs = uvAcc != null ? readAccessorAsFloat32(uvAcc) : null;
327
+ const indices = prim.indices != null ? readIndices(prim.indices) : null;
328
+ const material = getMaterial(prim.material);
329
+ drawCalls.push({
330
+ positions,
331
+ normals,
332
+ uvs,
333
+ indices,
334
+ model: world,
335
+ material
336
+ });
337
+ }
338
+ }
339
+ for (const c of n.children || []) traverse(c, world);
340
+ }
341
+ const I = mat4.create();
342
+ for (const root of scene.nodes || []) traverse(root, I);
343
+ return { gltf, drawCalls };
344
+ }
345
+ var SoftwareRenderer = class {
346
+ constructor(w, h, options = {}) {
347
+ this.w = w;
348
+ this.h = h;
349
+ this.img = PImage.make(w, h);
350
+ this.buf = this.img.data;
351
+ this.depth = new Float32Array(w * h);
352
+ this.options = options;
353
+ this.clear([0, 0, 0, 255]);
354
+ }
355
+ clear(colorRGBA = [0, 0, 0, 255]) {
356
+ const [r, g, b, a] = colorRGBA;
357
+ for (let i = 0; i < this.w * this.h; i++) {
358
+ const j = i * 4;
359
+ this.buf[j + 0] = r;
360
+ this.buf[j + 1] = g;
361
+ this.buf[j + 2] = b;
362
+ this.buf[j + 3] = a;
363
+ this.depth[i] = Infinity;
364
+ }
365
+ }
366
+ setPixel(x, y, r, g, b, a) {
367
+ if (x < 0 || y < 0 || x >= this.w || y >= this.h) return;
368
+ const idx = (y * this.w + x) * 4;
369
+ this.buf[idx + 0] = r;
370
+ this.buf[idx + 1] = g;
371
+ this.buf[idx + 2] = b;
372
+ this.buf[idx + 3] = a;
373
+ }
374
+ // Sample nearest from pureimage bitmap (u,v in [0,1])
375
+ sampleTextureNearest(img, u, v) {
376
+ if (!img) return [1, 1, 1, 1];
377
+ const x = clamp(Math.floor(u * (img.width - 1)), 0, img.width - 1);
378
+ const y = clamp(Math.floor(v * (img.height - 1)), 0, img.height - 1);
379
+ const idx = (y * img.width + x) * 4;
380
+ const d = img.data;
381
+ return [
382
+ d[idx + 0] / 255,
383
+ d[idx + 1] / 255,
384
+ d[idx + 2] / 255,
385
+ d[idx + 3] / 255
386
+ ];
387
+ }
388
+ // p: screen-space barycentric weights; invW: per-vertex 1/w; attrs: array of VecN
389
+ perspInterp(attrs, invWs, lambdas) {
390
+ const denom = lambdas[0] * invWs[0] + lambdas[1] * invWs[1] + lambdas[2] * invWs[2];
391
+ const n = attrs[0].length;
392
+ const out = new Array(n).fill(0);
393
+ for (let j = 0; j < n; j++) {
394
+ out[j] = (lambdas[0] * attrs[0][j] * invWs[0] + lambdas[1] * attrs[1][j] * invWs[1] + lambdas[2] * attrs[2][j] * invWs[2]) / denom;
395
+ }
396
+ return out;
397
+ }
398
+ drawMesh(mesh, camera, light, material, cullBackFaces = true, gammaOut = true) {
399
+ const { positions, normals, uvs, indices, model } = mesh;
400
+ const view = camera.view;
401
+ const proj = camera.proj;
402
+ const mvp = mat4.create();
403
+ mat4.multiply(mvp, proj, mat4.multiply(mat4.create(), view, model));
404
+ const normalMat = mat3.create();
405
+ mat3.normalFromMat4(normalMat, model);
406
+ const vertCount = positions.length / 3 | 0;
407
+ const idx = indices || (() => {
408
+ const a = new Uint32Array(vertCount);
409
+ for (let i = 0; i < vertCount; i++) a[i] = i;
410
+ return a;
411
+ })();
412
+ let useNormals = normals;
413
+ if (!useNormals) {
414
+ useNormals = computeSmoothNormals(positions, idx);
415
+ }
416
+ const vScreen = new Array(vertCount);
417
+ const vInvW = new Float32Array(vertCount);
418
+ const vNDCz = new Float32Array(vertCount);
419
+ const vWorldN = new Array(vertCount);
420
+ for (let i = 0; i < vertCount; i++) {
421
+ const p = vec4.fromValues(
422
+ positions[i * 3 + 0],
423
+ positions[i * 3 + 1],
424
+ positions[i * 3 + 2],
425
+ 1
426
+ );
427
+ const c = vec4.create();
428
+ vec4.transformMat4(c, p, mvp);
429
+ const invW = 1 / c[3];
430
+ const ndcX = c[0] * invW;
431
+ const ndcY = c[1] * invW;
432
+ const ndcZ = c[2] * invW;
433
+ const sx = Math.round((ndcX * 0.5 + 0.5) * (this.w - 1));
434
+ const sy = Math.round((1 - (ndcY * 0.5 + 0.5)) * (this.h - 1));
435
+ vScreen[i] = [sx, sy];
436
+ vInvW[i] = invW;
437
+ vNDCz[i] = ndcZ;
438
+ const n = vec3.fromValues(
439
+ useNormals[i * 3 + 0],
440
+ useNormals[i * 3 + 1],
441
+ useNormals[i * 3 + 2]
442
+ );
443
+ const nw = vec3.create();
444
+ vec3.transformMat3(nw, n, normalMat);
445
+ vWorldN[i] = nw;
446
+ }
447
+ for (let i = 0; i < idx.length; i += 3) {
448
+ const i0 = idx[i + 0] >>> 0;
449
+ const i1 = idx[i + 1] >>> 0;
450
+ const i2 = idx[i + 2] >>> 0;
451
+ if (!(isFinite(vInvW[i0]) && isFinite(vInvW[i1]) && isFinite(vInvW[i2])))
452
+ continue;
453
+ const v0 = vScreen[i0], v1 = vScreen[i1], v2 = vScreen[i2];
454
+ const area = edge(v0, v1, v2);
455
+ if (area === 0) continue;
456
+ if (cullBackFaces && area < 0) continue;
457
+ let minX = Math.max(0, Math.min(v0[0], v1[0], v2[0]) | 0);
458
+ let maxX = Math.min(this.w - 1, Math.max(v0[0], v1[0], v2[0]) | 0);
459
+ let minY = Math.max(0, Math.min(v0[1], v1[1], v2[1]) | 0);
460
+ let maxY = Math.min(this.h - 1, Math.max(v0[1], v1[1], v2[1]) | 0);
461
+ const invW = [vInvW[i0], vInvW[i1], vInvW[i2]];
462
+ const ndcZ = [vNDCz[i0], vNDCz[i1], vNDCz[i2]];
463
+ const nws = [vWorldN[i0], vWorldN[i1], vWorldN[i2]];
464
+ const uv = uvs ? [
465
+ [uvs[i0 * 2 + 0], uvs[i0 * 2 + 1]],
466
+ [uvs[i1 * 2 + 0], uvs[i1 * 2 + 1]],
467
+ [uvs[i2 * 2 + 0], uvs[i2 * 2 + 1]]
468
+ ] : null;
469
+ for (let y = minY; y <= maxY; y++) {
470
+ for (let x = minX; x <= maxX; x++) {
471
+ const p = [x + 0.5, y + 0.5];
472
+ const w0 = edge(v1, v2, p);
473
+ const w1 = edge(v2, v0, p);
474
+ const w2 = edge(v0, v1, p);
475
+ if (w0 < 0 || w1 < 0 || w2 < 0) continue;
476
+ const invArea = 1 / area;
477
+ const l0 = w0 * invArea, l1 = w1 * invArea, l2 = w2 * invArea;
478
+ const zndc = l0 * ndcZ[0] + l1 * ndcZ[1] + l2 * ndcZ[2];
479
+ const z01 = zndc * 0.5 + 0.5;
480
+ const di = y * this.w + x;
481
+ if (z01 >= this.depth[di]) continue;
482
+ this.depth[di] = z01;
483
+ let baseColor = material.baseColorFactor.slice(0, 4);
484
+ if (uv && material.baseColorTexture) {
485
+ const uvp = this.perspInterp(uv, invW, [l0, l1, l2]);
486
+ const texel = this.sampleTextureNearest(
487
+ material.baseColorTexture,
488
+ uvp[0],
489
+ uvp[1]
490
+ );
491
+ baseColor = mulColor(baseColor, texel);
492
+ }
493
+ const np = this.perspInterp(nws, invW, [l0, l1, l2]);
494
+ const nlen = Math.hypot(np[0], np[1], np[2]) || 1;
495
+ const nrm = [np[0] / nlen, np[1] / nlen, np[2] / nlen];
496
+ const lightDir = light && light.dir ? light.dir : DEFAULT_LIGHT_DIR;
497
+ const ambient = clamp(
498
+ light && typeof light.ambient === "number" ? light.ambient : DEFAULT_RENDER_OPTIONS.ambient,
499
+ 0,
500
+ 1
501
+ );
502
+ const L = vec3.normalize(
503
+ vec3.create(),
504
+ vec3.fromValues(lightDir[0], lightDir[1], lightDir[2])
505
+ );
506
+ const ndotl = Math.max(
507
+ 0,
508
+ nrm[0] * -L[0] + nrm[1] * -L[1] + nrm[2] * -L[2]
509
+ );
510
+ const lit = ambient + (1 - ambient) * ndotl;
511
+ let r = baseColor[0] * lit;
512
+ let g = baseColor[1] * lit;
513
+ let b = baseColor[2] * lit;
514
+ let a = baseColor[3];
515
+ if (gammaOut) {
516
+ r = srgbEncodeLinear01(clamp(r, 0, 1));
517
+ g = srgbEncodeLinear01(clamp(g, 0, 1));
518
+ b = srgbEncodeLinear01(clamp(b, 0, 1));
519
+ } else {
520
+ r = clamp(r, 0, 1);
521
+ g = clamp(g, 0, 1);
522
+ b = clamp(b, 0, 1);
523
+ }
524
+ this.setPixel(
525
+ x,
526
+ y,
527
+ r * 255 | 0,
528
+ g * 255 | 0,
529
+ b * 255 | 0,
530
+ clamp(a, 0, 1) * 255 | 0
531
+ );
532
+ }
533
+ }
534
+ }
535
+ }
536
+ };
537
+ function edge(a, b, p) {
538
+ return (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);
539
+ }
540
+ function computeSmoothNormals(positions, indices) {
541
+ const n = new Float32Array(positions.length);
542
+ const vc = positions.length / 3 | 0;
543
+ const idx = indices || (() => {
544
+ const a = new Uint32Array(vc);
545
+ for (let i = 0; i < vc; i++) a[i] = i;
546
+ return a;
547
+ })();
548
+ for (let i = 0; i < idx.length; i += 3) {
549
+ const i0 = idx[i + 0], i1 = idx[i + 1], i2 = idx[i + 2];
550
+ const p0 = [
551
+ positions[i0 * 3 + 0],
552
+ positions[i0 * 3 + 1],
553
+ positions[i0 * 3 + 2]
554
+ ];
555
+ const p1 = [
556
+ positions[i1 * 3 + 0],
557
+ positions[i1 * 3 + 1],
558
+ positions[i1 * 3 + 2]
559
+ ];
560
+ const p2 = [
561
+ positions[i2 * 3 + 0],
562
+ positions[i2 * 3 + 1],
563
+ positions[i2 * 3 + 2]
564
+ ];
565
+ const v10 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
566
+ const v20 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
567
+ const nx = v10[1] * v20[2] - v10[2] * v20[1];
568
+ const ny = v10[2] * v20[0] - v10[0] * v20[2];
569
+ const nz = v10[0] * v20[1] - v10[1] * v20[0];
570
+ n[i0 * 3 + 0] += nx;
571
+ n[i0 * 3 + 1] += ny;
572
+ n[i0 * 3 + 2] += nz;
573
+ n[i1 * 3 + 0] += nx;
574
+ n[i1 * 3 + 1] += ny;
575
+ n[i1 * 3 + 2] += nz;
576
+ n[i2 * 3 + 0] += nx;
577
+ n[i2 * 3 + 1] += ny;
578
+ n[i2 * 3 + 2] += nz;
579
+ }
580
+ for (let i = 0; i < vc; i++) {
581
+ const nx = n[i * 3 + 0], ny = n[i * 3 + 1], nz = n[i * 3 + 2];
582
+ const inv = 1 / (Math.hypot(nx, ny, nz) || 1);
583
+ n[i * 3 + 0] = nx * inv;
584
+ n[i * 3 + 1] = ny * inv;
585
+ n[i * 3 + 2] = nz * inv;
586
+ }
587
+ return n;
588
+ }
589
+ function computeWorldAABB(drawCalls) {
590
+ const min = [Infinity, Infinity, Infinity];
591
+ const max = [-Infinity, -Infinity, -Infinity];
592
+ const tmp = vec4.create();
593
+ for (const dc of drawCalls) {
594
+ const { positions, model } = dc;
595
+ for (let i = 0; i < positions.length; i += 3) {
596
+ vec4.set(tmp, positions[i], positions[i + 1], positions[i + 2], 1);
597
+ vec4.transformMat4(tmp, tmp, model);
598
+ min[0] = Math.min(min[0], tmp[0]);
599
+ min[1] = Math.min(min[1], tmp[1]);
600
+ min[2] = Math.min(min[2], tmp[2]);
601
+ max[0] = Math.max(max[0], tmp[0]);
602
+ max[1] = Math.max(max[1], tmp[1]);
603
+ max[2] = Math.max(max[2], tmp[2]);
604
+ }
605
+ }
606
+ return { min, max };
607
+ }
608
+ function buildCamera(drawCalls, width, height, fovDeg, camPosOpt, lookAtOpt) {
609
+ const aspect = width / height;
610
+ const near = 0.01, far = 1e3;
611
+ const proj = mat4.create();
612
+ mat4.perspective(proj, toRad(fovDeg), aspect, near, far);
613
+ let eye, center;
614
+ if (camPosOpt) {
615
+ eye = vec3.fromValues(camPosOpt[0], camPosOpt[1], camPosOpt[2]);
616
+ if (lookAtOpt)
617
+ center = vec3.fromValues(lookAtOpt[0], lookAtOpt[1], lookAtOpt[2]);
618
+ else {
619
+ const aabb = computeWorldAABB(drawCalls);
620
+ center = vec3.fromValues(
621
+ 0.5 * (aabb.min[0] + aabb.max[0]),
622
+ 0.5 * (aabb.min[1] + aabb.max[1]),
623
+ 0.5 * (aabb.min[2] + aabb.max[2])
624
+ );
625
+ }
626
+ } else {
627
+ const aabb = computeWorldAABB(drawCalls);
628
+ center = vec3.fromValues(
629
+ 0.5 * (aabb.min[0] + aabb.max[0]),
630
+ 0.5 * (aabb.min[1] + aabb.max[1]),
631
+ 0.5 * (aabb.min[2] + aabb.max[2])
632
+ );
633
+ const diag = vec3.distance(
634
+ vec3.fromValues(aabb.min[0], aabb.min[1], aabb.min[2]),
635
+ vec3.fromValues(aabb.max[0], aabb.max[1], aabb.max[2])
636
+ );
637
+ const radius = diag * 0.5;
638
+ const fov = toRad(fovDeg);
639
+ const dist = radius / Math.tan(fov * 0.5) + radius * 0.5;
640
+ eye = vec3.fromValues(
641
+ center[0] + dist,
642
+ center[1] + dist * 0.3,
643
+ center[2] + dist
644
+ );
645
+ }
646
+ const up = vec3.fromValues(0, 1, 0);
647
+ const view = mat4.create();
648
+ mat4.lookAt(view, eye, center, up);
649
+ return { view, proj };
650
+ }
651
+ async function renderGLTFToPNGBuffer(gltfPath, options = {}) {
652
+ const merged = {
653
+ ...DEFAULT_RENDER_OPTIONS,
654
+ ...options,
655
+ lightDir: options.lightDir != null ? options.lightDir : DEFAULT_RENDER_OPTIONS.lightDir,
656
+ camPos: options.camPos !== void 0 ? options.camPos : DEFAULT_RENDER_OPTIONS.camPos,
657
+ lookAt: options.lookAt !== void 0 ? options.lookAt : DEFAULT_RENDER_OPTIONS.lookAt
658
+ };
659
+ const { drawCalls } = await loadGLTF(gltfPath);
660
+ const camera = buildCamera(
661
+ drawCalls,
662
+ merged.width,
663
+ merged.height,
664
+ merged.fov,
665
+ merged.camPos ?? null,
666
+ merged.lookAt ?? null
667
+ );
668
+ const renderer = new SoftwareRenderer(merged.width, merged.height);
669
+ for (const dc of drawCalls) {
670
+ renderer.drawMesh(
671
+ dc,
672
+ camera,
673
+ { dir: merged.lightDir, ambient: merged.ambient },
674
+ dc.material,
675
+ merged.cull,
676
+ merged.gamma
677
+ );
678
+ }
679
+ return await encodePNGToBuffer(renderer.img);
680
+ }
681
+ async function renderGLTFToPNGFile(gltfPath, outputPath, options = {}) {
682
+ const pngBuffer = await renderGLTFToPNGBuffer(gltfPath, options);
683
+ await fs.promises.writeFile(outputPath, pngBuffer);
684
+ return pngBuffer;
685
+ }
686
+ function parseCliArgs(args) {
687
+ const out = { _: [] };
688
+ for (let i = 0; i < args.length; i++) {
689
+ const tok = args[i];
690
+ if (tok.startsWith("--")) {
691
+ const key = tok.slice(2);
692
+ const next = args[i + 1];
693
+ if (next && !next.startsWith("--")) {
694
+ out[key] = next;
695
+ i++;
696
+ } else out[key] = true;
697
+ } else {
698
+ out._.push(tok);
699
+ }
700
+ }
701
+ return out;
702
+ }
703
+ function parseVec3(value) {
704
+ if (typeof value !== "string") return null;
705
+ const parts = value.split(",").map(Number);
706
+ if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return null;
707
+ return [parts[0], parts[1], parts[2]];
708
+ }
709
+ function isMainModule(meta) {
710
+ if (meta && typeof meta.main === "boolean") return !!meta.main;
711
+ if (!process.argv[1]) return false;
712
+ try {
713
+ const entryHref = pathToFileURL(process.argv[1]).href;
714
+ return entryHref === meta.url;
715
+ } catch {
716
+ return false;
717
+ }
718
+ }
719
+ async function runCLI() {
720
+ const argv = parseCliArgs(process.argv.slice(2));
721
+ if (argv._.length === 0) {
722
+ console.error(
723
+ "Usage: node render_gltf_soft.js model.gltf [--out out.png] [--w 960] [--h 540] [--fov 60]"
724
+ );
725
+ process.exit(1);
726
+ }
727
+ const gltfPath = argv._[0];
728
+ const outPath = typeof argv.out === "string" ? argv.out : "out.png";
729
+ const width = parseInt(typeof argv.w === "string" ? argv.w : `${DEFAULT_RENDER_OPTIONS.width}`, 10);
730
+ const height = parseInt(typeof argv.h === "string" ? argv.h : `${DEFAULT_RENDER_OPTIONS.height}`, 10);
731
+ const fov = parseFloat(typeof argv.fov === "string" ? argv.fov : `${DEFAULT_RENDER_OPTIONS.fov}`);
732
+ const ambient = clamp(
733
+ parseFloat(typeof argv.ambient === "string" ? argv.ambient : `${DEFAULT_RENDER_OPTIONS.ambient}`),
734
+ 0,
735
+ 1
736
+ );
737
+ const lightDir = parseVec3(argv.light) ?? DEFAULT_LIGHT_DIR;
738
+ const camPos = parseVec3(argv.cam);
739
+ const lookAt = parseVec3(argv.look);
740
+ const cull = argv.noCull ? false : true;
741
+ const gamma = argv.noGamma ? false : true;
742
+ await renderGLTFToPNGFile(gltfPath, outPath, {
743
+ width,
744
+ height,
745
+ fov,
746
+ ambient,
747
+ lightDir,
748
+ camPos,
749
+ lookAt,
750
+ cull,
751
+ gamma
752
+ });
753
+ console.log(`Wrote ${outPath} (${width}x${height})`);
754
+ }
755
+ if (isMainModule(import.meta)) {
756
+ runCLI().catch((err) => {
757
+ console.error(err);
758
+ process.exit(1);
759
+ });
760
+ }
761
+ export {
762
+ DEFAULT_LIGHT_DIR,
763
+ DEFAULT_RENDER_OPTIONS,
764
+ SoftwareRenderer,
765
+ buildCamera,
766
+ loadGLTF,
767
+ renderGLTFToPNGBuffer,
768
+ renderGLTFToPNGFile
769
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "poppygl",
3
+ "main": "dist/index.js",
4
+ "version": "0.0.1",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "scripts": {
9
+ "build": "tsup-node ./lib/index.ts --format esm",
10
+ "format": "biome format --write .",
11
+ "format:check": "biome format .",
12
+ "test": "bun test"
13
+ },
14
+ "type": "module",
15
+ "devDependencies": {
16
+ "@biomejs/biome": "^2.2.4",
17
+ "@types/bun": "^1.2.23",
18
+ "looks-same": "^10.0.1",
19
+ "tsup": "^8.5.0"
20
+ },
21
+ "peerDependencies": {
22
+ "typescript": "^5"
23
+ },
24
+ "dependencies": {
25
+ "gl-matrix": "^3.4.4",
26
+ "pureimage": "^0.4.18"
27
+ }
28
+ }