poppygl 0.0.1 → 0.0.3

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