poppygl 0.0.1 → 0.0.2

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 (3) hide show
  1. package/README.md +107 -8
  2. package/dist/index.js +806 -568
  3. package/package.json +13 -2
package/dist/index.js CHANGED
@@ -1,12 +1,86 @@
1
- #!/usr/bin/env node
1
+ // lib/camera/buildCamera.ts
2
+ import { mat4, vec3 } from "gl-matrix";
2
3
 
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";
4
+ // lib/gltf/computeWorldAABB.ts
5
+ import { vec4 } from "gl-matrix";
6
+ function computeWorldAABB(drawCalls) {
7
+ const min = [Infinity, Infinity, Infinity];
8
+ const max = [-Infinity, -Infinity, -Infinity];
9
+ const tmp = vec4.create();
10
+ for (const dc of drawCalls) {
11
+ const { positions, model } = dc;
12
+ for (let i = 0; i < positions.length; i += 3) {
13
+ vec4.set(tmp, positions[i], positions[i + 1], positions[i + 2], 1);
14
+ vec4.transformMat4(tmp, tmp, model);
15
+ min[0] = Math.min(min[0], tmp[0]);
16
+ min[1] = Math.min(min[1], tmp[1]);
17
+ min[2] = Math.min(min[2], tmp[2]);
18
+ max[0] = Math.max(max[0], tmp[0]);
19
+ max[1] = Math.max(max[1], tmp[1]);
20
+ max[2] = Math.max(max[2], tmp[2]);
21
+ }
22
+ }
23
+ return { min, max };
24
+ }
25
+
26
+ // lib/utils/toRad.ts
27
+ var toRad = (degrees) => degrees * Math.PI / 180;
28
+
29
+ // lib/camera/buildCamera.ts
30
+ function buildCamera(drawCalls, width, height, fovDeg, camPos, lookAt) {
31
+ const aspect = width / height;
32
+ const near = 0.01;
33
+ const far = 1e3;
34
+ const proj = mat4.create();
35
+ mat4.perspective(proj, toRad(fovDeg), aspect, near, far);
36
+ let eye;
37
+ let center;
38
+ if (camPos) {
39
+ eye = vec3.fromValues(camPos[0], camPos[1], camPos[2]);
40
+ if (lookAt) {
41
+ center = vec3.fromValues(lookAt[0], lookAt[1], lookAt[2]);
42
+ } else {
43
+ const aabb = computeWorldAABB(drawCalls);
44
+ center = vec3.fromValues(
45
+ 0.5 * (aabb.min[0] + aabb.max[0]),
46
+ 0.5 * (aabb.min[1] + aabb.max[1]),
47
+ 0.5 * (aabb.min[2] + aabb.max[2])
48
+ );
49
+ }
50
+ } else {
51
+ const aabb = computeWorldAABB(drawCalls);
52
+ center = vec3.fromValues(
53
+ 0.5 * (aabb.min[0] + aabb.max[0]),
54
+ 0.5 * (aabb.min[1] + aabb.max[1]),
55
+ 0.5 * (aabb.min[2] + aabb.max[2])
56
+ );
57
+ const diag = vec3.distance(
58
+ vec3.fromValues(aabb.min[0], aabb.min[1], aabb.min[2]),
59
+ vec3.fromValues(aabb.max[0], aabb.max[1], aabb.max[2])
60
+ );
61
+ const radius = diag * 0.5;
62
+ const fov = toRad(fovDeg);
63
+ const dist = radius / Math.tan(fov * 0.5) + radius * 0.5;
64
+ eye = vec3.fromValues(
65
+ center[0] + dist,
66
+ center[1] + dist * 0.3,
67
+ center[2] + dist
68
+ );
69
+ }
70
+ const up = vec3.fromValues(0, 1, 0);
71
+ const view = mat4.create();
72
+ mat4.lookAt(view, eye, center, up);
73
+ return { view, proj };
74
+ }
75
+
76
+ // lib/image/createUint8Bitmap.ts
77
+ var createUint8Bitmap = (width, height) => ({
78
+ width,
79
+ height,
80
+ data: new Uint8ClampedArray(width * height * 4)
81
+ });
82
+
83
+ // lib/render/getDefaultRenderOptions.ts
10
84
  var DEFAULT_LIGHT_DIR = [-0.4, -0.9, -0.2];
11
85
  var DEFAULT_RENDER_OPTIONS = {
12
86
  width: 800,
@@ -19,359 +93,117 @@ var DEFAULT_RENDER_OPTIONS = {
19
93
  camPos: null,
20
94
  lookAt: null
21
95
  };
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);
96
+ function getDefaultRenderOptions() {
97
+ return { ...DEFAULT_RENDER_OPTIONS };
31
98
  }
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;
99
+
100
+ // lib/render/SoftwareRenderer.ts
101
+ import { mat3, mat4 as mat42, vec3 as vec32, vec4 as vec42 } from "gl-matrix";
102
+
103
+ // lib/gltf/computeSmoothNormals.ts
104
+ function computeSmoothNormals(positions, indices) {
105
+ const normals = new Float32Array(positions.length);
106
+ const vertexCount = positions.length / 3 | 0;
107
+ const indexArray = indices ?? (() => {
108
+ const arr = new Uint32Array(vertexCount);
109
+ for (let i = 0; i < vertexCount; i++) arr[i] = i;
110
+ return arr;
111
+ })();
112
+ for (let i = 0; i < indexArray.length; i += 3) {
113
+ const i0 = indexArray[i + 0];
114
+ const i1 = indexArray[i + 1];
115
+ const i2 = indexArray[i + 2];
116
+ const p0 = [
117
+ positions[i0 * 3 + 0],
118
+ positions[i0 * 3 + 1],
119
+ positions[i0 * 3 + 2]
120
+ ];
121
+ const p1 = [
122
+ positions[i1 * 3 + 0],
123
+ positions[i1 * 3 + 1],
124
+ positions[i1 * 3 + 2]
125
+ ];
126
+ const p2 = [
127
+ positions[i2 * 3 + 0],
128
+ positions[i2 * 3 + 1],
129
+ positions[i2 * 3 + 2]
130
+ ];
131
+ const v10 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
132
+ const v20 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
133
+ const nx = v10[1] * v20[2] - v10[2] * v20[1];
134
+ const ny = v10[2] * v20[0] - v10[0] * v20[2];
135
+ const nz = v10[0] * v20[1] - v10[1] * v20[0];
136
+ normals[i0 * 3 + 0] += nx;
137
+ normals[i0 * 3 + 1] += ny;
138
+ normals[i0 * 3 + 2] += nz;
139
+ normals[i1 * 3 + 0] += nx;
140
+ normals[i1 * 3 + 1] += ny;
141
+ normals[i1 * 3 + 2] += nz;
142
+ normals[i2 * 3 + 0] += nx;
143
+ normals[i2 * 3 + 1] += ny;
144
+ normals[i2 * 3 + 2] += nz;
145
+ }
146
+ for (let i = 0; i < vertexCount; i++) {
147
+ const nx = normals[i * 3 + 0];
148
+ const ny = normals[i * 3 + 1];
149
+ const nz = normals[i * 3 + 2];
150
+ const invLength = 1 / (Math.hypot(nx, ny, nz) || 1);
151
+ normals[i * 3 + 0] = nx * invLength;
152
+ normals[i * 3 + 1] = ny * invLength;
153
+ normals[i * 3 + 2] = nz * invLength;
154
+ }
155
+ return normals;
56
156
  }
157
+
158
+ // lib/utils/mulColor.ts
57
159
  function mulColor(a, b) {
58
160
  return [a[0] * b[0], a[1] * b[1], a[2] * b[2], a[3] * b[3]];
59
161
  }
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 };
162
+
163
+ // lib/utils/srgbEncodeLinear01.ts
164
+ function srgbEncodeLinear01(linear) {
165
+ if (linear <= 31308e-7) return 12.92 * linear;
166
+ return 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
344
167
  }
168
+
169
+ // lib/utils/clamp.ts
170
+ var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
171
+
172
+ // lib/render/SoftwareRenderer.ts
345
173
  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;
174
+ width;
175
+ height;
176
+ bitmap;
177
+ depth;
178
+ constructor(width, height, imageFactory = createUint8Bitmap) {
179
+ this.width = width;
180
+ this.height = height;
181
+ this.bitmap = imageFactory(width, height);
182
+ this.depth = new Float32Array(width * height);
353
183
  this.clear([0, 0, 0, 255]);
354
184
  }
185
+ get buffer() {
186
+ return this.bitmap.data;
187
+ }
355
188
  clear(colorRGBA = [0, 0, 0, 255]) {
356
189
  const [r, g, b, a] = colorRGBA;
357
- for (let i = 0; i < this.w * this.h; i++) {
190
+ for (let i = 0; i < this.width * this.height; i++) {
358
191
  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;
192
+ this.buffer[j + 0] = r;
193
+ this.buffer[j + 1] = g;
194
+ this.buffer[j + 2] = b;
195
+ this.buffer[j + 3] = a;
363
196
  this.depth[i] = Infinity;
364
197
  }
365
198
  }
366
199
  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;
200
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
201
+ const idx = (y * this.width + x) * 4;
202
+ this.buffer[idx + 0] = r;
203
+ this.buffer[idx + 1] = g;
204
+ this.buffer[idx + 2] = b;
205
+ this.buffer[idx + 3] = a;
373
206
  }
374
- // Sample nearest from pureimage bitmap (u,v in [0,1])
375
207
  sampleTextureNearest(img, u, v) {
376
208
  if (!img) return [1, 1, 1, 1];
377
209
  const x = clamp(Math.floor(u * (img.width - 1)), 0, img.width - 1);
@@ -385,7 +217,6 @@ var SoftwareRenderer = class {
385
217
  d[idx + 3] / 255
386
218
  ];
387
219
  }
388
- // p: screen-space barycentric weights; invW: per-vertex 1/w; attrs: array of VecN
389
220
  perspInterp(attrs, invWs, lambdas) {
390
221
  const denom = lambdas[0] * invWs[0] + lambdas[1] * invWs[1] + lambdas[2] * invWs[2];
391
222
  const n = attrs[0].length;
@@ -399,50 +230,50 @@ var SoftwareRenderer = class {
399
230
  const { positions, normals, uvs, indices, model } = mesh;
400
231
  const view = camera.view;
401
232
  const proj = camera.proj;
402
- const mvp = mat4.create();
403
- mat4.multiply(mvp, proj, mat4.multiply(mat4.create(), view, model));
233
+ const mvp = mat42.create();
234
+ mat42.multiply(mvp, proj, mat42.multiply(mat42.create(), view, model));
404
235
  const normalMat = mat3.create();
405
236
  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;
237
+ const vertexCount = positions.length / 3 | 0;
238
+ const idx = indices ?? (() => {
239
+ const a = new Uint32Array(vertexCount);
240
+ for (let i = 0; i < vertexCount; i++) a[i] = i;
410
241
  return a;
411
242
  })();
412
243
  let useNormals = normals;
413
244
  if (!useNormals) {
414
245
  useNormals = computeSmoothNormals(positions, idx);
415
246
  }
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(
247
+ const vScreen = new Array(vertexCount);
248
+ const vInvW = new Float32Array(vertexCount);
249
+ const vNDCz = new Float32Array(vertexCount);
250
+ const vWorldN = new Array(vertexCount);
251
+ for (let i = 0; i < vertexCount; i++) {
252
+ const p = vec42.fromValues(
422
253
  positions[i * 3 + 0],
423
254
  positions[i * 3 + 1],
424
255
  positions[i * 3 + 2],
425
256
  1
426
257
  );
427
- const c = vec4.create();
428
- vec4.transformMat4(c, p, mvp);
258
+ const c = vec42.create();
259
+ vec42.transformMat4(c, p, mvp);
429
260
  const invW = 1 / c[3];
430
261
  const ndcX = c[0] * invW;
431
262
  const ndcY = c[1] * invW;
432
263
  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));
264
+ const sx = Math.round((ndcX * 0.5 + 0.5) * (this.width - 1));
265
+ const sy = Math.round((1 - (ndcY * 0.5 + 0.5)) * (this.height - 1));
435
266
  vScreen[i] = [sx, sy];
436
267
  vInvW[i] = invW;
437
268
  vNDCz[i] = ndcZ;
438
- const n = vec3.fromValues(
269
+ const n = vec32.fromValues(
439
270
  useNormals[i * 3 + 0],
440
271
  useNormals[i * 3 + 1],
441
272
  useNormals[i * 3 + 2]
442
273
  );
443
- const nw = vec3.create();
444
- vec3.transformMat3(nw, n, normalMat);
445
- vWorldN[i] = nw;
274
+ const nw = vec32.create();
275
+ vec32.transformMat3(nw, n, normalMat);
276
+ vWorldN[i] = [nw[0], nw[1], nw[2]];
446
277
  }
447
278
  for (let i = 0; i < idx.length; i += 3) {
448
279
  const i0 = idx[i + 0] >>> 0;
@@ -450,14 +281,16 @@ var SoftwareRenderer = class {
450
281
  const i2 = idx[i + 2] >>> 0;
451
282
  if (!(isFinite(vInvW[i0]) && isFinite(vInvW[i1]) && isFinite(vInvW[i2])))
452
283
  continue;
453
- const v0 = vScreen[i0], v1 = vScreen[i1], v2 = vScreen[i2];
284
+ const v0 = vScreen[i0];
285
+ const v1 = vScreen[i1];
286
+ const v2 = vScreen[i2];
454
287
  const area = edge(v0, v1, v2);
455
288
  if (area === 0) continue;
456
289
  if (cullBackFaces && area < 0) continue;
457
290
  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);
291
+ let maxX = Math.min(this.width - 1, Math.max(v0[0], v1[0], v2[0]) | 0);
459
292
  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);
293
+ let maxY = Math.min(this.height - 1, Math.max(v0[1], v1[1], v2[1]) | 0);
461
294
  const invW = [vInvW[i0], vInvW[i1], vInvW[i2]];
462
295
  const ndcZ = [vNDCz[i0], vNDCz[i1], vNDCz[i2]];
463
296
  const nws = [vWorldN[i0], vWorldN[i1], vWorldN[i2]];
@@ -474,13 +307,20 @@ var SoftwareRenderer = class {
474
307
  const w2 = edge(v0, v1, p);
475
308
  if (w0 < 0 || w1 < 0 || w2 < 0) continue;
476
309
  const invArea = 1 / area;
477
- const l0 = w0 * invArea, l1 = w1 * invArea, l2 = w2 * invArea;
310
+ const l0 = w0 * invArea;
311
+ const l1 = w1 * invArea;
312
+ const l2 = w2 * invArea;
478
313
  const zndc = l0 * ndcZ[0] + l1 * ndcZ[1] + l2 * ndcZ[2];
479
314
  const z01 = zndc * 0.5 + 0.5;
480
- const di = y * this.w + x;
315
+ const di = y * this.width + x;
481
316
  if (z01 >= this.depth[di]) continue;
482
317
  this.depth[di] = z01;
483
- let baseColor = material.baseColorFactor.slice(0, 4);
318
+ let baseColor = [
319
+ material.baseColorFactor[0],
320
+ material.baseColorFactor[1],
321
+ material.baseColorFactor[2],
322
+ material.baseColorFactor[3]
323
+ ];
484
324
  if (uv && material.baseColorTexture) {
485
325
  const uvp = this.perspInterp(uv, invW, [l0, l1, l2]);
486
326
  const texel = this.sampleTextureNearest(
@@ -493,15 +333,15 @@ var SoftwareRenderer = class {
493
333
  const np = this.perspInterp(nws, invW, [l0, l1, l2]);
494
334
  const nlen = Math.hypot(np[0], np[1], np[2]) || 1;
495
335
  const nrm = [np[0] / nlen, np[1] / nlen, np[2] / nlen];
496
- const lightDir = light && light.dir ? light.dir : DEFAULT_LIGHT_DIR;
336
+ const lightDir = light.dir ?? DEFAULT_LIGHT_DIR;
497
337
  const ambient = clamp(
498
- light && typeof light.ambient === "number" ? light.ambient : DEFAULT_RENDER_OPTIONS.ambient,
338
+ light.ambient ?? DEFAULT_RENDER_OPTIONS.ambient,
499
339
  0,
500
340
  1
501
341
  );
502
- const L = vec3.normalize(
503
- vec3.create(),
504
- vec3.fromValues(lightDir[0], lightDir[1], lightDir[2])
342
+ const L = vec32.normalize(
343
+ vec32.create(),
344
+ vec32.fromValues(lightDir[0], lightDir[1], lightDir[2])
505
345
  );
506
346
  const ndotl = Math.max(
507
347
  0,
@@ -537,233 +377,631 @@ var SoftwareRenderer = class {
537
377
  function edge(a, b, p) {
538
378
  return (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]);
539
379
  }
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 = {
380
+
381
+ // lib/render/resolveRenderOptions.ts
382
+ function resolveRenderOptions(options = {}) {
383
+ return {
653
384
  ...DEFAULT_RENDER_OPTIONS,
654
385
  ...options,
655
386
  lightDir: options.lightDir != null ? options.lightDir : DEFAULT_RENDER_OPTIONS.lightDir,
656
387
  camPos: options.camPos !== void 0 ? options.camPos : DEFAULT_RENDER_OPTIONS.camPos,
657
388
  lookAt: options.lookAt !== void 0 ? options.lookAt : DEFAULT_RENDER_OPTIONS.lookAt
658
389
  };
659
- const { drawCalls } = await loadGLTF(gltfPath);
390
+ }
391
+
392
+ // lib/render/renderDrawCalls.ts
393
+ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint8Bitmap) {
394
+ const options = resolveRenderOptions(optionsInput);
660
395
  const camera = buildCamera(
661
396
  drawCalls,
662
- merged.width,
663
- merged.height,
664
- merged.fov,
665
- merged.camPos ?? null,
666
- merged.lookAt ?? null
397
+ options.width,
398
+ options.height,
399
+ options.fov,
400
+ options.camPos ?? null,
401
+ options.lookAt ?? null
402
+ );
403
+ const renderer = new SoftwareRenderer(
404
+ options.width,
405
+ options.height,
406
+ imageFactory
667
407
  );
668
- const renderer = new SoftwareRenderer(merged.width, merged.height);
669
408
  for (const dc of drawCalls) {
670
409
  renderer.drawMesh(
671
410
  dc,
672
411
  camera,
673
- { dir: merged.lightDir, ambient: merged.ambient },
412
+ { dir: options.lightDir, ambient: options.ambient },
674
413
  dc.material,
675
- merged.cull,
676
- merged.gamma
414
+ options.cull,
415
+ options.gamma
677
416
  );
678
417
  }
679
- return await encodePNGToBuffer(renderer.img);
418
+ return { bitmap: renderer.bitmap, camera, options };
680
419
  }
681
- async function renderGLTFToPNGFile(gltfPath, outputPath, options = {}) {
682
- const pngBuffer = await renderGLTFToPNGBuffer(gltfPath, options);
683
- await fs.promises.writeFile(outputPath, pngBuffer);
684
- return pngBuffer;
420
+
421
+ // lib/gltf/createSceneFromGLTF.ts
422
+ import { mat4 as mat43 } from "gl-matrix";
423
+ var COMPONENT_INFO = {
424
+ 5120: {
425
+ name: "BYTE",
426
+ size: 1,
427
+ array: Int8Array,
428
+ norm: (v) => Math.max(-1, v / 127)
429
+ },
430
+ 5121: {
431
+ name: "UNSIGNED_BYTE",
432
+ size: 1,
433
+ array: Uint8Array,
434
+ norm: (v) => v / 255
435
+ },
436
+ 5122: {
437
+ name: "SHORT",
438
+ size: 2,
439
+ array: Int16Array,
440
+ norm: (v) => Math.max(-1, v / 32767)
441
+ },
442
+ 5123: {
443
+ name: "UNSIGNED_SHORT",
444
+ size: 2,
445
+ array: Uint16Array,
446
+ norm: (v) => v / 65535
447
+ },
448
+ 5125: {
449
+ name: "UNSIGNED_INT",
450
+ size: 4,
451
+ array: Uint32Array,
452
+ norm: (v) => v / 4294967295
453
+ },
454
+ 5126: { name: "FLOAT", size: 4, array: Float32Array, norm: (v) => v }
455
+ };
456
+ var NUM_COMPONENTS = {
457
+ SCALAR: 1,
458
+ VEC2: 2,
459
+ VEC3: 3,
460
+ VEC4: 4,
461
+ MAT2: 4,
462
+ MAT3: 9,
463
+ MAT4: 16
464
+ };
465
+ function getBufferViewSlice(ctx, bufferViewIndex, byteOffset) {
466
+ const { gltf, buffers } = ctx;
467
+ const bufferView = gltf.bufferViews?.[bufferViewIndex];
468
+ if (!bufferView)
469
+ throw new Error(`Invalid bufferView index ${bufferViewIndex}`);
470
+ const bufferIndex = bufferView.buffer;
471
+ const buffer = buffers[bufferIndex];
472
+ if (!buffer) throw new Error(`Missing buffer at index ${bufferIndex}`);
473
+ const viewOffset = bufferView.byteOffset ?? 0;
474
+ const totalByteLength = bufferView.byteLength ?? buffer.byteLength - viewOffset;
475
+ const baseOffset = buffer.byteOffset + viewOffset + byteOffset;
476
+ const viewArray = new Uint8Array(
477
+ buffer.buffer,
478
+ buffer.byteOffset + viewOffset,
479
+ totalByteLength
480
+ );
481
+ return {
482
+ array: new Uint8Array(
483
+ viewArray.buffer,
484
+ baseOffset,
485
+ totalByteLength - byteOffset
486
+ ),
487
+ stride: bufferView.byteStride ?? 0,
488
+ byteOffsetInView: baseOffset
489
+ };
685
490
  }
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);
491
+ function readAccessorAsFloat32(ctx, accessorIndex) {
492
+ const { gltf } = ctx;
493
+ const accessor = gltf.accessors?.[accessorIndex];
494
+ if (!accessor) throw new Error(`Invalid accessor index ${accessorIndex}`);
495
+ if (accessor.sparse) throw new Error("Sparse accessors are not supported.");
496
+ const compInfo = COMPONENT_INFO[accessor.componentType];
497
+ const numComponents = NUM_COMPONENTS[accessor.type];
498
+ if (!compInfo || !numComponents)
499
+ throw new Error(
500
+ `Unsupported accessor componentType ${accessor.componentType}`
501
+ );
502
+ const byteOffset = accessor.byteOffset ?? 0;
503
+ const {
504
+ array: src,
505
+ stride: bufferViewStride,
506
+ byteOffsetInView
507
+ } = getBufferViewSlice(ctx, accessor.bufferView, byteOffset);
508
+ const stride = bufferViewStride || compInfo.size * numComponents;
509
+ const count = accessor.count;
510
+ const out = new Float32Array(count * numComponents);
511
+ const absoluteByteOffset = byteOffsetInView;
512
+ const canUseTightView = stride === compInfo.size * numComponents && absoluteByteOffset % compInfo.size === 0;
513
+ if (canUseTightView) {
514
+ const TypedArrayCtor = compInfo.array;
515
+ const typed = new TypedArrayCtor(
516
+ src.buffer,
517
+ src.byteOffset,
518
+ count * numComponents
519
+ );
520
+ if (compInfo.name === "FLOAT" && !accessor.normalized) {
521
+ return new Float32Array(typed.buffer, typed.byteOffset, typed.length);
522
+ }
523
+ for (let i = 0; i < typed.length; i++) {
524
+ out[i] = accessor.normalized ? compInfo.norm(typed[i]) : typed[i];
525
+ }
526
+ return out;
527
+ }
528
+ const dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
529
+ let outIndex = 0;
530
+ for (let i = 0; i < count; i++) {
531
+ const elementOffset = i * stride;
532
+ for (let c = 0; c < numComponents; c++) {
533
+ const componentOffset = elementOffset + c * compInfo.size;
534
+ let value;
535
+ switch (accessor.componentType) {
536
+ case 5120:
537
+ value = dataView.getInt8(componentOffset);
538
+ break;
539
+ case 5121:
540
+ value = dataView.getUint8(componentOffset);
541
+ break;
542
+ case 5122:
543
+ value = dataView.getInt16(componentOffset, true);
544
+ break;
545
+ case 5123:
546
+ value = dataView.getUint16(componentOffset, true);
547
+ break;
548
+ case 5125:
549
+ value = dataView.getUint32(componentOffset, true);
550
+ break;
551
+ case 5126:
552
+ value = dataView.getFloat32(componentOffset, true);
553
+ break;
554
+ default:
555
+ throw new Error(`Unknown componentType ${accessor.componentType}`);
556
+ }
557
+ out[outIndex++] = accessor.normalized ? compInfo.norm(value) : value;
699
558
  }
700
559
  }
701
560
  return out;
702
561
  }
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]];
562
+ function readIndices(ctx, accessorIndex) {
563
+ const { gltf } = ctx;
564
+ const accessor = gltf.accessors?.[accessorIndex];
565
+ if (!accessor) throw new Error(`Invalid accessor index ${accessorIndex}`);
566
+ if (accessor.type !== "SCALAR")
567
+ throw new Error("Index accessor must be SCALAR");
568
+ const compInfo = COMPONENT_INFO[accessor.componentType];
569
+ if (!compInfo)
570
+ throw new Error(
571
+ `Unsupported index component type ${accessor.componentType}`
572
+ );
573
+ if (accessor.componentType !== 5121 && accessor.componentType !== 5123 && accessor.componentType !== 5125) {
574
+ throw new Error("Index componentType must be UNSIGNED_BYTE/SHORT/INT");
575
+ }
576
+ const byteOffset = accessor.byteOffset ?? 0;
577
+ const {
578
+ array: src,
579
+ stride: bufferViewStride,
580
+ byteOffsetInView
581
+ } = getBufferViewSlice(ctx, accessor.bufferView, byteOffset);
582
+ const stride = bufferViewStride || compInfo.size;
583
+ const count = accessor.count;
584
+ const absoluteByteOffset = byteOffsetInView;
585
+ const canUseTightView = stride === compInfo.size && absoluteByteOffset % compInfo.size === 0;
586
+ if (canUseTightView) {
587
+ const TypedArrayCtor = compInfo.array;
588
+ const typed = new TypedArrayCtor(src.buffer, src.byteOffset, count);
589
+ if (accessor.componentType === 5125) {
590
+ return new Uint32Array(typed.buffer, typed.byteOffset, typed.length);
591
+ }
592
+ return new Uint32Array(typed);
593
+ }
594
+ const dataView = new DataView(src.buffer, src.byteOffset, src.byteLength);
595
+ const out = new Uint32Array(count);
596
+ for (let i = 0; i < count; i++) {
597
+ const elementOffset = i * stride;
598
+ switch (accessor.componentType) {
599
+ case 5121:
600
+ out[i] = dataView.getUint8(elementOffset);
601
+ break;
602
+ case 5123:
603
+ out[i] = dataView.getUint16(elementOffset, true);
604
+ break;
605
+ case 5125:
606
+ out[i] = dataView.getUint32(elementOffset, true);
607
+ break;
608
+ }
609
+ }
610
+ return out;
708
611
  }
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;
612
+ function nodeLocalMatrix(node) {
613
+ if (node.matrix) {
614
+ const m2 = mat43.create();
615
+ for (let i = 0; i < 16; i++) m2[i] = node.matrix[i];
616
+ return m2;
717
617
  }
618
+ const translation = node.translation || [0, 0, 0];
619
+ const rotation = node.rotation || [0, 0, 0, 1];
620
+ const scale = node.scale || [1, 1, 1];
621
+ const m = mat43.create();
622
+ const tmp = mat43.create();
623
+ mat43.fromRotationTranslationScale(tmp, rotation, translation, scale);
624
+ mat43.copy(m, tmp);
625
+ return m;
718
626
  }
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]"
627
+ function getMaterial(gltf, textures, images, materialIndex) {
628
+ const material = (gltf.materials || [])[materialIndex ?? -1] || {};
629
+ const pbr = material.pbrMetallicRoughness || {};
630
+ const factor = pbr.baseColorFactor || [1, 1, 1, 1];
631
+ let texImg = null;
632
+ if (pbr.baseColorTexture && Number.isInteger(pbr.baseColorTexture.index)) {
633
+ const tex = textures[pbr.baseColorTexture.index];
634
+ if (tex && Number.isInteger(tex.source)) {
635
+ texImg = images[tex.source] || null;
636
+ }
637
+ }
638
+ return { baseColorFactor: factor, baseColorTexture: texImg };
639
+ }
640
+ function createSceneFromGLTF(gltf, resources) {
641
+ const ctx = { gltf, buffers: resources.buffers };
642
+ const textures = gltf.textures || [];
643
+ const nodes = gltf.nodes || [];
644
+ const meshes = gltf.meshes || [];
645
+ const scenes = gltf.scenes || [];
646
+ const sceneIndex = Number.isInteger(gltf.scene) ? gltf.scene : 0;
647
+ const scene = scenes[sceneIndex] || { nodes: [] };
648
+ const drawCalls = [];
649
+ function traverse(nodeIndex, parentMatrix) {
650
+ const node = nodes[nodeIndex];
651
+ if (!node) return;
652
+ const local = nodeLocalMatrix(node);
653
+ const world = mat43.create();
654
+ mat43.multiply(world, parentMatrix, local);
655
+ if (Number.isInteger(node.mesh)) {
656
+ const mesh = meshes[node.mesh];
657
+ if (mesh) {
658
+ for (const primitive of mesh.primitives || []) {
659
+ if (primitive.mode != null && primitive.mode !== 4) continue;
660
+ const posAcc = primitive.attributes?.POSITION;
661
+ if (posAcc == null) continue;
662
+ const normalsAcc = primitive.attributes?.NORMAL;
663
+ const uvAcc = primitive.attributes?.TEXCOORD_0;
664
+ const positions = readAccessorAsFloat32(ctx, posAcc);
665
+ const normals = normalsAcc != null ? readAccessorAsFloat32(ctx, normalsAcc) : null;
666
+ const uvs = uvAcc != null ? readAccessorAsFloat32(ctx, uvAcc) : null;
667
+ const indices = primitive.indices != null ? readIndices(ctx, primitive.indices) : null;
668
+ const material = getMaterial(
669
+ gltf,
670
+ textures,
671
+ resources.images,
672
+ primitive.material
673
+ );
674
+ drawCalls.push({
675
+ positions,
676
+ normals,
677
+ uvs,
678
+ indices,
679
+ model: world,
680
+ material
681
+ });
682
+ }
683
+ }
684
+ }
685
+ for (const childIndex of node.children || []) {
686
+ traverse(childIndex, world);
687
+ }
688
+ }
689
+ const identity = mat43.create();
690
+ for (const rootIndex of scene.nodes || []) {
691
+ traverse(rootIndex, identity);
692
+ }
693
+ return { gltf, drawCalls };
694
+ }
695
+
696
+ // lib/gltf/resourceUtils.ts
697
+ import { PassThrough } from "stream";
698
+ import PImage from "pureimage";
699
+ function bufferFromDataURI(uri) {
700
+ const match = uri.match(/^data:.*?;base64,(.*)$/);
701
+ if (!match) throw new Error(`Unsupported data URI: ${uri.slice(0, 64)}...`);
702
+ return Buffer.from(match[1], "base64");
703
+ }
704
+ function isPNG(filenameOrUri) {
705
+ return /\.png(\?|$)/i.test(filenameOrUri) || /image\/png/i.test(filenameOrUri);
706
+ }
707
+ function isJPG(filenameOrUri) {
708
+ return /(\.jpe?g(\?|$)|image\/jpe?g)/i.test(filenameOrUri);
709
+ }
710
+ function detectMimeTypeFromBuffer(buf, hint) {
711
+ if (hint) return hint;
712
+ 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)
713
+ return "image/png";
714
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 216) return "image/jpeg";
715
+ return null;
716
+ }
717
+ function bufferToStream(buf) {
718
+ const stream = new PassThrough();
719
+ stream.end(Buffer.from(buf));
720
+ return stream;
721
+ }
722
+ async function decodeImageFromBuffer(buf, mimeType) {
723
+ const type = detectMimeTypeFromBuffer(buf, mimeType);
724
+ if (type === "image/png")
725
+ return PImage.decodePNGFromStream(bufferToStream(buf));
726
+ if (type === "image/jpeg" || type === "image/jpg")
727
+ return PImage.decodeJPEGFromStream(bufferToStream(buf));
728
+ throw new Error(`Unsupported embedded image mimeType: ${mimeType ?? "unknown"}`);
729
+ }
730
+
731
+ // lib/gltf/parseGLB.ts
732
+ var MAGIC = 1179937895;
733
+ var JSON_CHUNK_TYPE = 1313821514;
734
+ var BIN_CHUNK_TYPE = 5130562;
735
+ function parseGLB(arrayBuffer) {
736
+ const view = new DataView(arrayBuffer);
737
+ const magic = view.getUint32(0, true);
738
+ if (magic !== MAGIC) throw new Error("Invalid GLB header magic.");
739
+ const version = view.getUint32(4, true);
740
+ if (version !== 2) throw new Error(`Unsupported GLB version ${version}.`);
741
+ const length = view.getUint32(8, true);
742
+ const decoder = new TextDecoder();
743
+ let json = null;
744
+ let binaryChunk = null;
745
+ const chunks = [];
746
+ let offset = 12;
747
+ while (offset < length) {
748
+ if (offset + 8 > length) throw new Error("Truncated GLB chunk header.");
749
+ const chunkLength = view.getUint32(offset, true);
750
+ const chunkType = view.getUint32(offset + 4, true);
751
+ offset += 8;
752
+ if (offset + chunkLength > length) throw new Error("Truncated GLB chunk data.");
753
+ const chunkData = new Uint8Array(arrayBuffer, offset, chunkLength);
754
+ offset += chunkLength;
755
+ if (chunkType === JSON_CHUNK_TYPE) {
756
+ json = JSON.parse(decoder.decode(chunkData));
757
+ } else if (chunkType === BIN_CHUNK_TYPE) {
758
+ binaryChunk = chunkData;
759
+ } else {
760
+ chunks.push({ type: chunkType, data: chunkData });
761
+ }
762
+ }
763
+ if (!json) throw new Error("GLB file is missing JSON chunk.");
764
+ return { gltf: json, binaryChunk, chunks };
765
+ }
766
+
767
+ // lib/gltf/loadGLTFWithResourcesFromURL.ts
768
+ function ensureFetch(fetchImpl) {
769
+ const impl = fetchImpl ?? globalThis.fetch;
770
+ if (!impl) throw new Error("Global fetch API is not available; provide fetchImpl.");
771
+ return impl;
772
+ }
773
+ async function fetchBinary(fetchImpl, url) {
774
+ const res = await fetchImpl(url);
775
+ if (!res.ok) {
776
+ throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
777
+ }
778
+ const buf = await res.arrayBuffer();
779
+ return new Uint8Array(buf);
780
+ }
781
+ async function loadGLTFWithResourcesFromURL(url, options = {}) {
782
+ const fetchImpl = ensureFetch(options.fetchImpl);
783
+ const response = await fetchImpl(url);
784
+ if (!response.ok)
785
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
786
+ const resolvedURL = new URL(response.url || url);
787
+ const baseURL = resolvedURL;
788
+ const sourceBuffer = await response.arrayBuffer();
789
+ const header = sourceBuffer.byteLength >= 4 ? new DataView(sourceBuffer, 0, 4) : null;
790
+ const isGLB = header?.getUint32(0, true) === 1179937895;
791
+ let gltf;
792
+ const totalBuffers = () => Array.isArray(gltf?.buffers) ? gltf.buffers.length : 0;
793
+ let buffers = [];
794
+ if (isGLB) {
795
+ const parsed = parseGLB(sourceBuffer);
796
+ gltf = parsed.gltf;
797
+ buffers = new Array(totalBuffers()).fill(null);
798
+ if (parsed.binaryChunk) buffers[0] = parsed.binaryChunk;
799
+ } else {
800
+ const text = new TextDecoder().decode(new Uint8Array(sourceBuffer));
801
+ gltf = JSON.parse(text);
802
+ buffers = new Array(totalBuffers()).fill(null);
803
+ }
804
+ async function resolveBuffer(index, entry) {
805
+ if (buffers[index]) return buffers[index];
806
+ if (entry?.uri) {
807
+ if (entry.uri.startsWith("data:")) {
808
+ const buf2 = bufferFromDataURI(entry.uri);
809
+ buffers[index] = buf2;
810
+ return buf2;
811
+ }
812
+ const resourceURL = new URL(
813
+ decodeURIComponent(entry.uri),
814
+ baseURL
815
+ ).toString();
816
+ const buf = await fetchBinary(fetchImpl, resourceURL);
817
+ buffers[index] = buf;
818
+ return buf;
819
+ }
820
+ throw new Error(`Buffer ${index} is missing a uri and no GLB chunk was provided.`);
821
+ }
822
+ await Promise.all(
823
+ (gltf.buffers || []).map(async (entry, idx) => {
824
+ await resolveBuffer(idx, entry);
825
+ })
826
+ );
827
+ async function resolveImage(img) {
828
+ if (img.uri) {
829
+ if (img.uri.startsWith("data:")) {
830
+ const buf2 = bufferFromDataURI(img.uri);
831
+ return decodeImageFromBuffer(buf2, img.mimeType);
832
+ }
833
+ const resourceURL = new URL(
834
+ decodeURIComponent(img.uri),
835
+ baseURL
836
+ ).toString();
837
+ const hintedMime = img.mimeType ? img.mimeType : isPNG(img.uri) ? "image/png" : isJPG(img.uri) ? "image/jpeg" : null;
838
+ const buf = await fetchBinary(fetchImpl, resourceURL);
839
+ return decodeImageFromBuffer(buf, hintedMime);
840
+ }
841
+ if (typeof img.bufferView === "number") {
842
+ const bufferView = gltf.bufferViews?.[img.bufferView];
843
+ if (!bufferView)
844
+ throw new Error(`Invalid image bufferView index ${img.bufferView}`);
845
+ const buffer = await resolveBuffer(bufferView.buffer, gltf.buffers?.[bufferView.buffer]);
846
+ if (!buffer)
847
+ throw new Error(`Missing buffer for image bufferView ${img.bufferView}`);
848
+ const byteOffset = bufferView.byteOffset ?? 0;
849
+ const byteLength = bufferView.byteLength;
850
+ if (typeof byteLength !== "number") {
851
+ throw new Error(
852
+ `bufferView ${img.bufferView} missing byteLength for image.`
853
+ );
854
+ }
855
+ const slice = buffer.subarray(byteOffset, byteOffset + byteLength);
856
+ return decodeImageFromBuffer(slice, img.mimeType);
857
+ }
858
+ throw new Error(
859
+ "images[*] entry missing uri or bufferView; unsupported in this loader."
724
860
  );
725
- process.exit(1);
726
861
  }
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
862
+ const images = await Promise.all(
863
+ (gltf.images || []).map((img) => resolveImage(img))
736
864
  );
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
865
+ return {
866
+ gltf,
867
+ resources: {
868
+ buffers,
869
+ images
870
+ }
871
+ };
872
+ }
873
+
874
+ // lib/image/pureImageFactory.ts
875
+ import PImage2 from "pureimage";
876
+ var pureImageFactory = (width, height) => PImage2.make(width, height);
877
+
878
+ // lib/image/encodePNGToBuffer.ts
879
+ import { PassThrough as PassThrough2 } from "stream";
880
+ import PImage3 from "pureimage";
881
+ async function encodePNGToBuffer(image) {
882
+ const passThrough = new PassThrough2();
883
+ const chunks = [];
884
+ passThrough.on("data", (chunk) => {
885
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
886
+ });
887
+ const resultPromise = new Promise((resolve, reject) => {
888
+ passThrough.on("end", () => resolve(Buffer.concat(chunks)));
889
+ passThrough.on("error", reject);
752
890
  });
753
- console.log(`Wrote ${outPath} (${width}x${height})`);
891
+ await PImage3.encodePNGToStream(image, passThrough);
892
+ return await resultPromise;
754
893
  }
755
- if (isMainModule(import.meta)) {
756
- runCLI().catch((err) => {
757
- console.error(err);
758
- process.exit(1);
894
+
895
+ // lib/render/renderGLTFToPNGBufferFromURL.ts
896
+ async function renderGLTFToPNGBufferFromURL(url, options = {}) {
897
+ const { fetchImpl, ...renderOptions } = options;
898
+ const { gltf, resources } = await loadGLTFWithResourcesFromURL(url, {
899
+ fetchImpl
759
900
  });
901
+ const scene = createSceneFromGLTF(gltf, resources);
902
+ const { bitmap } = renderDrawCalls(
903
+ scene.drawCalls,
904
+ renderOptions,
905
+ pureImageFactory
906
+ );
907
+ return encodePNGToBuffer(bitmap);
908
+ }
909
+
910
+ // lib/render/renderGLTFToPNGBufferFromGLBBuffer.ts
911
+ function normalizeToArrayBuffer(source) {
912
+ if (source instanceof ArrayBuffer) return source;
913
+ const { buffer, byteOffset, byteLength } = source;
914
+ return buffer.slice(byteOffset, byteOffset + byteLength);
915
+ }
916
+ async function renderGLTFToPNGBufferFromGLBBuffer(glb, options = {}) {
917
+ const arrayBuffer = normalizeToArrayBuffer(glb);
918
+ const { gltf, binaryChunk } = parseGLB(arrayBuffer);
919
+ const totalBuffers = Array.isArray(gltf.buffers) ? gltf.buffers.length : 0;
920
+ const buffers = new Array(totalBuffers);
921
+ for (let index = 0; index < totalBuffers; index++) {
922
+ const entry = gltf.buffers[index];
923
+ if (entry?.uri) {
924
+ if (!entry.uri.startsWith("data:")) {
925
+ throw new Error(
926
+ `Buffer ${index} references external resource ${entry.uri}. Use renderGLTFToPNGBufferFromURL for GLBs with external buffer URIs.`
927
+ );
928
+ }
929
+ buffers[index] = bufferFromDataURI(entry.uri);
930
+ continue;
931
+ }
932
+ if (!binaryChunk) {
933
+ throw new Error(`GLB is missing the binary chunk required for buffer ${index}.`);
934
+ }
935
+ buffers[index] = binaryChunk;
936
+ }
937
+ const resolveBufferViewSlice = (bufferViewIndex) => {
938
+ const bufferView = gltf.bufferViews?.[bufferViewIndex];
939
+ if (!bufferView) {
940
+ throw new Error(`Invalid bufferView index ${bufferViewIndex}.`);
941
+ }
942
+ const bufferIndex = bufferView.buffer;
943
+ const buffer = buffers[bufferIndex];
944
+ if (!buffer) {
945
+ throw new Error(
946
+ `Missing buffer data for bufferView ${bufferViewIndex} (buffer ${bufferIndex}).`
947
+ );
948
+ }
949
+ const byteOffset = bufferView.byteOffset ?? 0;
950
+ const byteLength = bufferView.byteLength;
951
+ if (typeof byteLength !== "number") {
952
+ throw new Error(`bufferView ${bufferViewIndex} is missing byteLength.`);
953
+ }
954
+ return buffer.subarray(byteOffset, byteOffset + byteLength);
955
+ };
956
+ const images = await Promise.all(
957
+ (gltf.images ?? []).map(async (image, imageIndex) => {
958
+ if (image.uri) {
959
+ if (!image.uri.startsWith("data:")) {
960
+ throw new Error(
961
+ `Image ${imageIndex} references external resource ${image.uri}. Embed textures or load via renderGLTFToPNGBufferFromURL.`
962
+ );
963
+ }
964
+ const data = bufferFromDataURI(image.uri);
965
+ return decodeImageFromBuffer(data, image.mimeType);
966
+ }
967
+ if (typeof image.bufferView === "number") {
968
+ const slice = resolveBufferViewSlice(image.bufferView);
969
+ return decodeImageFromBuffer(slice, image.mimeType);
970
+ }
971
+ throw new Error(
972
+ `Image ${imageIndex} must supply a data URI or bufferView for GLB rendering.`
973
+ );
974
+ })
975
+ );
976
+ const scene = createSceneFromGLTF(gltf, { buffers, images });
977
+ const { bitmap } = renderDrawCalls(scene.drawCalls, options, pureImageFactory);
978
+ return encodePNGToBuffer(bitmap);
979
+ }
980
+
981
+ // lib/index.ts
982
+ function renderSceneFromGLTF(scene, options, imageFactory) {
983
+ return renderDrawCalls(scene.drawCalls, options, imageFactory);
760
984
  }
761
985
  export {
762
986
  DEFAULT_LIGHT_DIR,
763
987
  DEFAULT_RENDER_OPTIONS,
764
988
  SoftwareRenderer,
989
+ bufferFromDataURI,
765
990
  buildCamera,
766
- loadGLTF,
767
- renderGLTFToPNGBuffer,
768
- renderGLTFToPNGFile
991
+ computeSmoothNormals,
992
+ computeWorldAABB,
993
+ createSceneFromGLTF,
994
+ createUint8Bitmap,
995
+ decodeImageFromBuffer,
996
+ encodePNGToBuffer,
997
+ getDefaultRenderOptions,
998
+ isJPG,
999
+ isPNG,
1000
+ loadGLTFWithResourcesFromURL,
1001
+ pureImageFactory,
1002
+ renderDrawCalls,
1003
+ renderGLTFToPNGBufferFromGLBBuffer,
1004
+ renderGLTFToPNGBufferFromURL,
1005
+ renderSceneFromGLTF,
1006
+ resolveRenderOptions
769
1007
  };