@volter/blender-engine 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/browser/session.py +81 -1
- package/browser/three/blender-runtime-view.ts +40 -18
- package/browser/worker.ts +20 -4
- package/package.json +1 -1
package/browser/session.py
CHANGED
|
@@ -143,6 +143,25 @@ REPLY = os.path.join(ROOT, "reply")
|
|
|
143
143
|
# (`blender-wali-engine.mts`), where the file is not a fallback but the
|
|
144
144
|
# native door onto the same bytes.
|
|
145
145
|
EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
|
|
146
|
+
|
|
147
|
+
# WHERE THE ENGINE'S HEAP WENT, stage by stage: the heap only grows, so its size after a stage says
|
|
148
|
+
# nothing about the stage before it; this records `_blender_web.memory()` at each, in MB
|
|
149
|
+
# ([label, heap, mallocInUse, mallocFree, guarded, guardedPeak]), and a script reads `MEMORY_MARKS`
|
|
150
|
+
# (the last 64) to say which stage raised the high-water mark. MEASURED 2026-09-29 on the
|
|
151
|
+
# Stoneguard file: the first export's door call took the heap from 1,442 to 3,644 MB while in-use
|
|
152
|
+
# rose 28 MB and the guarded peak did not move -- OpenSubdiv's limit-surface evaluator for one
|
|
153
|
+
# head (quality 3), which native Blender pays too (+1.2 GB peak RSS there).
|
|
154
|
+
MEMORY_MARKS = []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _mark(label):
|
|
158
|
+
try:
|
|
159
|
+
reading = _blender_web.memory()
|
|
160
|
+
except Exception: # noqa: BLE001 -- an engine without the door records nothing
|
|
161
|
+
return
|
|
162
|
+
MEMORY_MARKS.append([label] + [round(reading.get(key, 0) / 1048576)
|
|
163
|
+
for key in ("heap", "mallocInUse", "mallocFree", "guarded", "guardedPeak")])
|
|
164
|
+
del MEMORY_MARKS[:-64]
|
|
146
165
|
_real_stderr = sys.stderr
|
|
147
166
|
|
|
148
167
|
|
|
@@ -1358,6 +1377,7 @@ class Session:
|
|
|
1358
1377
|
"""
|
|
1359
1378
|
scene = bpy.context.scene
|
|
1360
1379
|
graphs = material_graphs(scene)
|
|
1380
|
+
_mark("export:graphs")
|
|
1361
1381
|
# An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
|
|
1362
1382
|
# (`overlay_empty.hh` `image_sync`).
|
|
1363
1383
|
empty_images = {obj.data.name for obj in scene.objects
|
|
@@ -1378,7 +1398,13 @@ class Session:
|
|
|
1378
1398
|
# An engine without the pull door ships them all in this call, as before.
|
|
1379
1399
|
if hasattr(_blender_web, "export_mesh"):
|
|
1380
1400
|
options["defer"] = True
|
|
1381
|
-
|
|
1401
|
+
if hasattr(_blender_web, "memory_reset_peak"):
|
|
1402
|
+
_blender_web.memory_reset_peak()
|
|
1403
|
+
exported = _blender_web.export_frame(json.dumps(options))
|
|
1404
|
+
_mark("export:door")
|
|
1405
|
+
frame = json.loads(exported)
|
|
1406
|
+
del exported
|
|
1407
|
+
_mark("export:parsed")
|
|
1382
1408
|
error = frame.get("error")
|
|
1383
1409
|
if error:
|
|
1384
1410
|
raise RuntimeError("Blender export door: %s" % error)
|
|
@@ -1507,8 +1533,11 @@ class Session:
|
|
|
1507
1533
|
|
|
1508
1534
|
def _present(self, capture=None):
|
|
1509
1535
|
known = dict(self._known)
|
|
1536
|
+
_mark("present:before")
|
|
1510
1537
|
frame = self._export()
|
|
1538
|
+
_mark("present:exported")
|
|
1511
1539
|
pulled = self._pull(frame)
|
|
1540
|
+
_mark("present:pulled")
|
|
1512
1541
|
self._drop_unreachable_textures(frame)
|
|
1513
1542
|
# WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
|
|
1514
1543
|
# whose columns crossed, and the ids that went as a reference. The
|
|
@@ -1587,6 +1616,8 @@ class Session:
|
|
|
1587
1616
|
for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
|
|
1588
1617
|
("image", images, _blender_web.export_image)):
|
|
1589
1618
|
for key in keys:
|
|
1619
|
+
if kind == "image" and self._send_encoded_image(key, frame["images"][key]):
|
|
1620
|
+
continue
|
|
1590
1621
|
options["key"] = key
|
|
1591
1622
|
piece = json.loads(door(json.dumps(options)))
|
|
1592
1623
|
if piece.get("error"):
|
|
@@ -1598,6 +1629,53 @@ class Session:
|
|
|
1598
1629
|
ask({kind: key, "piece": piece[kind]})
|
|
1599
1630
|
return True
|
|
1600
1631
|
|
|
1632
|
+
def _send_encoded_image(self, key, deferred):
|
|
1633
|
+
"""A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
|
|
1634
|
+
|
|
1635
|
+
A modelling program keeps a picture compressed as its file and lets the GPU hold the
|
|
1636
|
+
decoded texture; only this side's raster door decoded every picture into RGBA and shipped
|
|
1637
|
+
that. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material pictures are 171 MB as
|
|
1638
|
+
their packed files and 1,343 MB as RGBA, and the hosted tab's renderer passed 10 GB while
|
|
1639
|
+
it opened. So a picture whose texels ARE its file -- a plain file or packed PNG or JPEG,
|
|
1640
|
+
not edited in this session -- is written to the engine's filesystem as those bytes and
|
|
1641
|
+
crosses as they are (`worker.ts`), decoded by the browser. The rest keep the raster door:
|
|
1642
|
+
a key with `@` (a roughness remap bakes new texels), a picture painted here (`is_dirty`),
|
|
1643
|
+
a UDIM or generated one, or bytes neither decoder reads.
|
|
1644
|
+
|
|
1645
|
+
Answers whether it was sent."""
|
|
1646
|
+
if "@" in key:
|
|
1647
|
+
return False
|
|
1648
|
+
image = bpy.data.images.get(key)
|
|
1649
|
+
if image is None or image.source != "FILE" or image.is_dirty:
|
|
1650
|
+
return False
|
|
1651
|
+
try:
|
|
1652
|
+
if image.packed_file is not None:
|
|
1653
|
+
data = bytes(image.packed_file.data)
|
|
1654
|
+
else:
|
|
1655
|
+
with open(bpy.path.abspath(image.filepath), "rb") as fh:
|
|
1656
|
+
data = fh.read()
|
|
1657
|
+
except (OSError, TypeError, ValueError):
|
|
1658
|
+
return False
|
|
1659
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
1660
|
+
mime = "image/png"
|
|
1661
|
+
elif data[:3] == b"\xff\xd8\xff":
|
|
1662
|
+
mime = "image/jpeg"
|
|
1663
|
+
else:
|
|
1664
|
+
return False
|
|
1665
|
+
path = os.path.join(ROOT, "encoded-image.bin")
|
|
1666
|
+
with open(path, "wb") as fh:
|
|
1667
|
+
fh.write(data)
|
|
1668
|
+
try:
|
|
1669
|
+
ask({"image": key, "piece": {
|
|
1670
|
+
"revision": deferred["revision"],
|
|
1671
|
+
"mime": mime,
|
|
1672
|
+
"colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
|
|
1673
|
+
"encodedPath": path,
|
|
1674
|
+
}})
|
|
1675
|
+
finally:
|
|
1676
|
+
os.remove(path)
|
|
1677
|
+
return True
|
|
1678
|
+
|
|
1601
1679
|
def _drop_unreachable_textures(self, frame):
|
|
1602
1680
|
"""A picture the export could not read is a WARNING, not a refusal.
|
|
1603
1681
|
|
|
@@ -1767,7 +1845,9 @@ class Session:
|
|
|
1767
1845
|
self.document = os.path.join(self.project, relative_path)
|
|
1768
1846
|
if not os.path.exists(self.document):
|
|
1769
1847
|
return {"document": relative_path, "opened": False}
|
|
1848
|
+
_mark("open:before")
|
|
1770
1849
|
bpy.ops.wm.open_mainfile(filepath=self.document)
|
|
1850
|
+
_mark("open:after")
|
|
1771
1851
|
size = os.path.getsize(self.document)
|
|
1772
1852
|
# THE STAGED COPY GOES ONCE BLENDER HAS READ IT. It sits in the engine's heap (WasmFS keeps
|
|
1773
1853
|
# file data in linear memory) and nothing reads it again: the load took everything into
|
|
@@ -249,9 +249,13 @@ export async function texturesReady(): Promise<void> {
|
|
|
249
249
|
while (pendingTextures.size > 0) await Promise.all([...pendingTextures]);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
/** An image file's own bytes (PNG or JPEG) as a three texture, decoded by the browser's own
|
|
253
|
+
* decoder. */
|
|
254
|
+
function loadEncodedTexture(
|
|
255
|
+
encoded: Uint8Array,
|
|
256
|
+
mime: 'image/png' | 'image/jpeg',
|
|
257
|
+
): { texture: THREE.Texture; ready: Promise<void> } {
|
|
258
|
+
const blob = new Blob([encoded as BlobPart], { type: mime });
|
|
255
259
|
const texture = new THREE.Texture();
|
|
256
260
|
let disposed = false;
|
|
257
261
|
texture.addEventListener('dispose', () => {
|
|
@@ -275,8 +279,14 @@ function loadPngTexture(png: Uint8Array): { texture: THREE.Texture; ready: Promi
|
|
|
275
279
|
//
|
|
276
280
|
// Its alpha is asked for here too: the browser's `default` premultiplies (Chrome's does), and
|
|
277
281
|
// every reader of this texture takes the file's own texels, straight unless the image says it
|
|
278
|
-
// stores them premultiplied, which the reader then undoes.
|
|
279
|
-
|
|
282
|
+
// stores them premultiplied, which the reader then undoes. And its colour is the file's own: no
|
|
283
|
+
// embedded profile or gamma is applied, because Blender reads the texels as stored and applies
|
|
284
|
+
// the image's colour space setting itself, as the sampler does here.
|
|
285
|
+
const decoding = createImageBitmap(blob, {
|
|
286
|
+
imageOrientation: 'flipY',
|
|
287
|
+
premultiplyAlpha: 'none',
|
|
288
|
+
colorSpaceConversion: 'none',
|
|
289
|
+
})
|
|
280
290
|
.then((bitmap) => {
|
|
281
291
|
if (disposed) {
|
|
282
292
|
bitmap.close();
|
|
@@ -392,14 +402,20 @@ const rasterImageSchema = z
|
|
|
392
402
|
}).strict()).optional(),
|
|
393
403
|
})
|
|
394
404
|
.strict();
|
|
395
|
-
|
|
405
|
+
/** An image as its FILE'S OWN BYTES, PNG or JPEG (`session.py::_encoded_image`): what the file
|
|
406
|
+
* already carries compressed, decoded by the browser into the texture, so neither side holds
|
|
407
|
+
* the picture as raw RGBA. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material images
|
|
408
|
+
* are 171 MB as their packed files and 1,343 MB as RGBA. */
|
|
409
|
+
const encodedImageSchema = z
|
|
396
410
|
.object({
|
|
397
411
|
revision: z.number().int().nonnegative(),
|
|
398
|
-
|
|
399
|
-
|
|
412
|
+
encoded: z.instanceof(Uint8Array).optional(),
|
|
413
|
+
encodedBase64: z.string().optional(),
|
|
414
|
+
mime: z.enum(['image/png', 'image/jpeg']),
|
|
415
|
+
colorspace: z.enum(['sRGB', 'data']).optional(),
|
|
400
416
|
})
|
|
401
417
|
.strict();
|
|
402
|
-
const frameImageSchema = z.union([rasterImageSchema,
|
|
418
|
+
const frameImageSchema = z.union([rasterImageSchema, encodedImageSchema]);
|
|
403
419
|
|
|
404
420
|
const textureReferenceSchema = z
|
|
405
421
|
.object({
|
|
@@ -694,7 +710,7 @@ export class BlenderRuntimeView {
|
|
|
694
710
|
width: number;
|
|
695
711
|
height: number;
|
|
696
712
|
revision: number;
|
|
697
|
-
|
|
713
|
+
encoded?: { bytes: Uint8Array; mime: 'image/png' | 'image/jpeg'; colorspace: 'sRGB' | 'data' };
|
|
698
714
|
ready?: Promise<void>;
|
|
699
715
|
/** A UDIM image's tiles as a graph samples them (`udimTextures`). */
|
|
700
716
|
udim?: {tiles: THREE.DataArrayTexture; map: THREE.DataTexture; frame: NonNullable<z.infer<typeof rasterImageSchema>['tiles']>};
|
|
@@ -1609,24 +1625,25 @@ export class BlenderRuntimeView {
|
|
|
1609
1625
|
});
|
|
1610
1626
|
continue;
|
|
1611
1627
|
}
|
|
1612
|
-
// A FILE'S OWN
|
|
1628
|
+
// A FILE'S OWN BYTES, decoded once here. Its size is the bitmap's, so the
|
|
1613
1629
|
// cache records none: the next raster for this name, whatever its size,
|
|
1614
1630
|
// is a rebuild rather than an upload into a texture whose dimensions
|
|
1615
1631
|
// this side never learned.
|
|
1616
|
-
const
|
|
1617
|
-
if (!
|
|
1632
|
+
const encoded = data.encoded ?? (data.encodedBase64 ? bytesFromBase64(data.encodedBase64) : undefined);
|
|
1633
|
+
if (!encoded) throw new Error(`Runtime image ${name} carries no bytes`);
|
|
1618
1634
|
held?.texture.dispose();
|
|
1619
1635
|
held?.udim?.tiles.dispose();
|
|
1620
1636
|
held?.udim?.map.dispose();
|
|
1621
|
-
const
|
|
1622
|
-
texture
|
|
1637
|
+
const colorspace = data.colorspace ?? 'sRGB';
|
|
1638
|
+
const { texture, ready } = loadEncodedTexture(encoded, data.mime);
|
|
1639
|
+
texture.colorSpace = colorspace === 'data' ? THREE.NoColorSpace : THREE.SRGBColorSpace;
|
|
1623
1640
|
this.textures.set(name, {
|
|
1624
1641
|
texture,
|
|
1625
1642
|
ready,
|
|
1626
1643
|
width: 0,
|
|
1627
1644
|
height: 0,
|
|
1628
1645
|
revision: data.revision,
|
|
1629
|
-
|
|
1646
|
+
encoded: { bytes: encoded.slice(), mime: data.mime, colorspace },
|
|
1630
1647
|
});
|
|
1631
1648
|
}
|
|
1632
1649
|
for (const [id, data] of Object.entries(next.materials)) {
|
|
@@ -2034,8 +2051,13 @@ export class BlenderRuntimeView {
|
|
|
2034
2051
|
for (const name of images) {
|
|
2035
2052
|
const held = this.textures.get(name);
|
|
2036
2053
|
if (!held) throw new Error(`Blender capture image ${name} is not resident`);
|
|
2037
|
-
if (held.
|
|
2038
|
-
frame.images[name] = {
|
|
2054
|
+
if (held.encoded) {
|
|
2055
|
+
frame.images[name] = {
|
|
2056
|
+
revision: held.revision,
|
|
2057
|
+
encoded: held.encoded.bytes.slice(),
|
|
2058
|
+
mime: held.encoded.mime,
|
|
2059
|
+
colorspace: held.encoded.colorspace,
|
|
2060
|
+
};
|
|
2039
2061
|
} else {
|
|
2040
2062
|
const data = held.texture.image?.data;
|
|
2041
2063
|
if (!(data instanceof Uint8Array))
|
package/browser/worker.ts
CHANGED
|
@@ -300,10 +300,26 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
300
300
|
}
|
|
301
301
|
if (mesh !== undefined || image !== undefined) {
|
|
302
302
|
if (!pending) throw new Error(`The Blender session sent ${mesh ?? image} before its frame`);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
};
|
|
303
|
+
// A PICTURE AS ITS FILE'S OWN BYTES (`session.py::_encoded_image`): the session wrote
|
|
304
|
+
// them to `encodedPath` rather than decoding them into the arena, and they cross as they
|
|
305
|
+
// are; the record keeps their digest, as it does a column's.
|
|
306
|
+
const encodedPath = (piece as { encodedPath?: unknown } | undefined)?.encodedPath;
|
|
307
|
+
let copied: Copied;
|
|
308
|
+
if (image !== undefined && typeof encodedPath === 'string') {
|
|
309
|
+
const { encodedPath: _path, ...rest } = piece as Record<string, unknown>;
|
|
310
|
+
const bytes = (await holder.engine.files.readFile(encodedPath)).slice();
|
|
311
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
312
|
+
const sha256 = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
313
|
+
copied = {
|
|
314
|
+
typed: { ...rest, encoded: bytes },
|
|
315
|
+
description: { ...rest, encoded: { dtype: 'u8', length: bytes.length, sha256 } },
|
|
316
|
+
};
|
|
317
|
+
} else {
|
|
318
|
+
copied = {
|
|
319
|
+
typed: columnsToTypedArrays(arena, piece),
|
|
320
|
+
description: await describeFrame(arena, piece),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
307
323
|
if (mesh !== undefined) pending.meshes.set(mesh, copied);
|
|
308
324
|
else pending.images.set(image as string, copied);
|
|
309
325
|
return {};
|