@volter/blender-engine 0.1.7 → 0.1.9
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/blender-emscripten-engine.mts +57 -6
- package/browser/blender-engine.mts +2 -0
- package/browser/runtime.ts +5 -5
- package/browser/session.py +200 -16
- package/browser/three/blender-graph-material.ts +6 -2
- package/browser/three/blender-node-graph.ts +15 -6
- package/browser/three/blender-physical-material.ts +11 -4
- package/browser/three/blender-runtime-camera-view.ts +137 -0
- package/browser/three/blender-runtime-cursor.ts +243 -0
- package/browser/three/blender-runtime-extras.ts +772 -0
- package/browser/three/blender-runtime-lighting.ts +19 -4
- package/browser/three/blender-runtime-view.ts +452 -5
- package/browser/three/blender-workbench-material.ts +115 -0
- package/browser/worker.ts +3 -0
- package/package.json +1 -1
|
@@ -133,9 +133,37 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
|
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD. The wasm (86 MB decoded), the
|
|
138
|
+
* `.data` package (53 MB) and the Essentials payload are immutable for a build,
|
|
139
|
+
* and the page keeps them in Cache Storage under the digest the artifact door
|
|
140
|
+
* reports, so a later open reads them at the browser's own speed instead of
|
|
141
|
+
* from the editor's server. Measured in the browser substrate, where that
|
|
142
|
+
* server runs in the tab: the three took 7.2 s per open (4.2 s for the wasm
|
|
143
|
+
* alone), about half of it decoding their brotli. A file the door gives no
|
|
144
|
+
* digest for is fetched as it always was.
|
|
145
|
+
*/
|
|
146
|
+
const ARTIFACT_CACHE = 'volter-blender-artifacts';
|
|
147
|
+
|
|
148
|
+
async function cachedArtifact(file: string, digest: string | undefined): Promise<Response> {
|
|
149
|
+
const url = artifactUrl(file);
|
|
150
|
+
if (!digest || typeof caches === 'undefined') return fetch(url);
|
|
151
|
+
const key = `${url}?sha256=${digest}`;
|
|
152
|
+
const cache = await caches.open(ARTIFACT_CACHE);
|
|
153
|
+
const hit = await cache.match(key);
|
|
154
|
+
if (hit) return hit;
|
|
155
|
+
const response = await fetch(url);
|
|
156
|
+
if (!response.ok) return response;
|
|
157
|
+
// One build's bytes per file: an older build's are dropped as this one lands.
|
|
158
|
+
for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
|
|
159
|
+
const kept = new Response(await response.blob(), { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
|
|
160
|
+
await cache.put(key, kept.clone());
|
|
161
|
+
return kept;
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
export async function startEmscriptenBlenderEngine(
|
|
137
165
|
options: BlenderEngineOptions,
|
|
138
|
-
|
|
166
|
+
status: BlenderArtifactStatus,
|
|
139
167
|
): Promise<BlenderEngine> {
|
|
140
168
|
if (!self.crossOriginIsolated)
|
|
141
169
|
throw new Error(
|
|
@@ -144,7 +172,17 @@ export async function startEmscriptenBlenderEngine(
|
|
|
144
172
|
'every response). This worker reports crossOriginIsolated=false.',
|
|
145
173
|
);
|
|
146
174
|
const glueUrl = artifactUrl('blender_browser.js');
|
|
147
|
-
const
|
|
175
|
+
const digests = status.digests ?? {};
|
|
176
|
+
// The `.data` package is handed to the glue whole (`getPreloadedPackage`),
|
|
177
|
+
// so it is read before the module starts; the wasm streams in beside it.
|
|
178
|
+
const [factory, preloaded] = await Promise.all([
|
|
179
|
+
loadFactory(glueUrl),
|
|
180
|
+
cachedArtifact('blender_browser.data', digests['blender_browser.data']).then(async (response) => {
|
|
181
|
+
if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
|
|
182
|
+
return response.arrayBuffer();
|
|
183
|
+
}),
|
|
184
|
+
]);
|
|
185
|
+
let bootError: unknown = null;
|
|
148
186
|
const started = performance.now();
|
|
149
187
|
let readyLine: string | null = null;
|
|
150
188
|
// BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS.
|
|
@@ -164,6 +202,16 @@ export async function startEmscriptenBlenderEngine(
|
|
|
164
202
|
const module = await factory({
|
|
165
203
|
arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
|
|
166
204
|
locateFile: (file: string) => artifactUrl(file),
|
|
205
|
+
getPreloadedPackage: () => preloaded,
|
|
206
|
+
instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
|
|
207
|
+
void (async () => {
|
|
208
|
+
const response = await cachedArtifact('blender_browser.wasm', digests['blender_browser.wasm']);
|
|
209
|
+
if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
|
|
210
|
+
const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
|
|
211
|
+
receive(instance, compiled);
|
|
212
|
+
})().catch((error: unknown) => { bootError = error; });
|
|
213
|
+
return {};
|
|
214
|
+
},
|
|
167
215
|
// The pthreads load the SAME patched glue this worker just evaluated.
|
|
168
216
|
mainScriptUrlOrBlob: glueUrl,
|
|
169
217
|
print: (text: string) => say('log', text),
|
|
@@ -209,7 +257,10 @@ export async function startEmscriptenBlenderEngine(
|
|
|
209
257
|
});
|
|
210
258
|
// `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
|
|
211
259
|
// long before the session exists. The ready line is what says it does.
|
|
212
|
-
while (readyLine === null)
|
|
260
|
+
while (readyLine === null) {
|
|
261
|
+
if (bootError) throw bootError instanceof Error ? bootError : new Error(String(bootError));
|
|
262
|
+
await sleep(5);
|
|
263
|
+
}
|
|
213
264
|
const bootMs = performance.now() - started;
|
|
214
265
|
const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
|
|
215
266
|
|
|
@@ -268,7 +319,7 @@ export async function startEmscriptenBlenderEngine(
|
|
|
268
319
|
|
|
269
320
|
const files = moduleFiles(module);
|
|
270
321
|
FS.chmod('/bw/datafiles', 0o755);
|
|
271
|
-
await mountEssentials(files);
|
|
322
|
+
await mountEssentials(files, digests['essentials.bin']);
|
|
272
323
|
const { request } = openSessionChannel(files, options);
|
|
273
324
|
|
|
274
325
|
return {
|
|
@@ -293,9 +344,9 @@ export async function startEmscriptenBlenderEngine(
|
|
|
293
344
|
/** Assets are data, not a second engine. Ship them separately so a data update
|
|
294
345
|
* does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
|
|
295
346
|
* checked before anything enters Blender's filesystem. */
|
|
296
|
-
async function mountEssentials(files: BlenderFiles): Promise<void> {
|
|
347
|
+
async function mountEssentials(files: BlenderFiles, digest: string | undefined): Promise<void> {
|
|
297
348
|
const [indexResponse, payloadResponse] = await Promise.all([
|
|
298
|
-
fetch(artifactUrl('essentials.json')),
|
|
349
|
+
fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest),
|
|
299
350
|
]);
|
|
300
351
|
if (!indexResponse.ok || !payloadResponse.ok)
|
|
301
352
|
throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
|
|
@@ -179,6 +179,8 @@ export interface BlenderArtifactStatus {
|
|
|
179
179
|
encoded: Record<string, 'br'>;
|
|
180
180
|
/** Why it is unavailable, named. Empty when it is available. */
|
|
181
181
|
missing: string[];
|
|
182
|
+
/** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
|
|
183
|
+
digests?: Record<string, string>;
|
|
182
184
|
/** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
|
|
183
185
|
* requires for any non-blob URL. */
|
|
184
186
|
integrity?: string;
|
package/browser/runtime.ts
CHANGED
|
@@ -434,10 +434,10 @@ export class BlenderRuntime {
|
|
|
434
434
|
if (this.#terminated) return Promise.resolve();
|
|
435
435
|
if (this.#stopping) return this.#stopping;
|
|
436
436
|
this.#stopping = (async () => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
437
|
+
// A start that failed opened no document, so there is nothing to flush: its stale
|
|
438
|
+
// rejection must not refuse every later close.
|
|
439
|
+
const started = this.#started ? await this.#started.then(() => true, () => false) : false;
|
|
440
|
+
if (started) await this.#request({ op: 'flush-document' }, true);
|
|
441
441
|
this.terminate();
|
|
442
442
|
})().catch(error => {
|
|
443
443
|
this.#stopping = null;
|
|
@@ -470,7 +470,7 @@ export class BlenderRuntime {
|
|
|
470
470
|
* A `present` is not counted separately — the worker only presents from
|
|
471
471
|
* inside a call this meter is already holding open, and the page-side cost of
|
|
472
472
|
* displaying the frame is main-thread time, which is the long-task observer's
|
|
473
|
-
* subject (`
|
|
473
|
+
* subject (the host's `worker-call-metrics.ts`).
|
|
474
474
|
*/
|
|
475
475
|
metrics(now: number = performance.now()): BlenderCallMetrics {
|
|
476
476
|
let oldest: number | null = null;
|
package/browser/session.py
CHANGED
|
@@ -523,6 +523,10 @@ def draw_camera(obj):
|
|
|
523
523
|
"shift_x": float(camera.shift_x),
|
|
524
524
|
"shift_y": float(camera.shift_y),
|
|
525
525
|
"matrix": [[float(v) for v in row] for row in obj.matrix_world],
|
|
526
|
+
# What the 3D View's camera view darkens outside the frame (`drawviewborder`).
|
|
527
|
+
"passepartout": float(camera.passepartout_alpha) if camera.show_passepartout else 0.0,
|
|
528
|
+
# The size the overlay draws the camera at (`Camera.display_size`, `cam->drawsize`).
|
|
529
|
+
"display_size": float(camera.display_size),
|
|
526
530
|
}
|
|
527
531
|
|
|
528
532
|
|
|
@@ -732,6 +736,107 @@ def _refusal(node):
|
|
|
732
736
|
return None
|
|
733
737
|
|
|
734
738
|
|
|
739
|
+
|
|
740
|
+
def _empty_display(obj):
|
|
741
|
+
"""An empty's display (`overlay_empty.hh`): its type and size, and for an image empty the
|
|
742
|
+
picture, where it sits on the object (`empty_image_offset`), how it meets depth, which side
|
|
743
|
+
shows, in which projections, its opacity when it blends, the object colour the picture is
|
|
744
|
+
multiplied by (`ucolor`) and whether the image stores premultiplied alpha."""
|
|
745
|
+
display = {"display": obj.empty_display_type, "size": float(obj.empty_display_size)}
|
|
746
|
+
if obj.empty_display_type == "IMAGE":
|
|
747
|
+
display["image"] = {
|
|
748
|
+
"name": obj.data.name if obj.data is not None else None,
|
|
749
|
+
"offset": [float(v) for v in obj.empty_image_offset],
|
|
750
|
+
"depth": obj.empty_image_depth,
|
|
751
|
+
"side": obj.empty_image_side,
|
|
752
|
+
"perspective": bool(obj.show_empty_image_perspective),
|
|
753
|
+
"orthographic": bool(obj.show_empty_image_orthographic),
|
|
754
|
+
"axis_aligned": bool(obj.show_empty_image_only_axis_aligned),
|
|
755
|
+
"opacity": float(obj.color[3]) if obj.use_empty_image_alpha else None,
|
|
756
|
+
"tint": [float(v) for v in obj.color[:3]],
|
|
757
|
+
"premultiplied": obj.data is not None and obj.data.alpha_mode == "PREMUL",
|
|
758
|
+
}
|
|
759
|
+
return display
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _saved_view():
|
|
763
|
+
"""The 3D View the file saved, which is where Blender opens it: the `Modeling` workspace's
|
|
764
|
+
(this editor's Model workspace is Blender's Modeling), else the first 3D View any screen
|
|
765
|
+
holds. `RegionView3D`'s pivot, rotation (view to world, `(w, x, y, z)`), distance and
|
|
766
|
+
projection (`PERSP`, `ORTHO`, or `CAMERA` for a view through the scene camera), and the
|
|
767
|
+
`View3D`'s lens."""
|
|
768
|
+
workspace = bpy.data.workspaces.get("Modeling")
|
|
769
|
+
screens = list(workspace.screens) if workspace is not None else []
|
|
770
|
+
screens.extend(bpy.data.screens)
|
|
771
|
+
for screen in screens:
|
|
772
|
+
for area in screen.areas:
|
|
773
|
+
if area.type != "VIEW_3D":
|
|
774
|
+
continue
|
|
775
|
+
space = area.spaces[0]
|
|
776
|
+
region = space.region_3d
|
|
777
|
+
return {
|
|
778
|
+
"lens": float(space.lens),
|
|
779
|
+
"location": [float(v) for v in region.view_location],
|
|
780
|
+
"rotation": [float(v) for v in region.view_rotation],
|
|
781
|
+
"distance": float(region.view_distance),
|
|
782
|
+
"perspective": region.view_perspective,
|
|
783
|
+
}
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _subject_line(scene, view_layer):
|
|
788
|
+
"""The 3D Viewport's second overlay line, as `draw_selected_name` (`view3d_draw.cc`) writes it,
|
|
789
|
+
less the two parts that follow the playhead: the tab draws `(frame)` and the marker on it
|
|
790
|
+
from the frame it is showing, which during playback is not `frame_current` (written once, on
|
|
791
|
+
pause). Here: in Object Mode (or with nothing active) the active collection and a bar, then
|
|
792
|
+
the active object; outside Object Mode its data's name; the active edit or pose bone, or the
|
|
793
|
+
active shape key (` (Soloed)` when pinned). The scene's markers go with it, in list order,
|
|
794
|
+
for `BKE_scene_find_marker_name`. Blender tints the line on a keyframe; that colour is not
|
|
795
|
+
carried, and the ` (Viewer)` suffix belongs to a viewer path this tab does not show."""
|
|
796
|
+
parts = []
|
|
797
|
+
ob = view_layer.objects.active
|
|
798
|
+
if ob is None or ob.mode == "OBJECT":
|
|
799
|
+
# `BKE_collection_ui_name_get`: the master collection reads "Scene Collection", which is
|
|
800
|
+
# also what its RNA name returns.
|
|
801
|
+
parts.append(" " + view_layer.active_layer_collection.collection.name)
|
|
802
|
+
if ob is not None:
|
|
803
|
+
parts.append(" |")
|
|
804
|
+
if ob is not None:
|
|
805
|
+
parts.append(" " + ob.name)
|
|
806
|
+
if ob.mode != "OBJECT" and ob.data is not None:
|
|
807
|
+
parts.append(" | " + ob.data.name)
|
|
808
|
+
if ob.type == "ARMATURE":
|
|
809
|
+
arm = ob.data
|
|
810
|
+
if ob.mode == "EDIT":
|
|
811
|
+
if arm.edit_bones.active is not None:
|
|
812
|
+
parts.append(" : " + arm.edit_bones.active.name)
|
|
813
|
+
elif ob.mode == "POSE" and arm.bones.active is not None and _bone_shown(arm.bones.active):
|
|
814
|
+
parts.append(" : " + arm.bones.active.name)
|
|
815
|
+
elif ob.type in ("MESH", "LATTICE", "CURVE"):
|
|
816
|
+
if ob.type == "MESH" and ob.mode == "WEIGHT_PAINT":
|
|
817
|
+
rig = ob.find_armature()
|
|
818
|
+
if rig is not None and rig.mode == "POSE":
|
|
819
|
+
bone = rig.data.bones.active
|
|
820
|
+
if bone is not None and _bone_shown(bone):
|
|
821
|
+
parts.append(" : " + bone.name)
|
|
822
|
+
key = ob.active_shape_key if getattr(ob.data, "shape_keys", None) is not None else None
|
|
823
|
+
if key is not None:
|
|
824
|
+
parts.append(" : " + key.name)
|
|
825
|
+
if ob.show_only_shape_key:
|
|
826
|
+
parts.append(" (Soloed)")
|
|
827
|
+
return {
|
|
828
|
+
"frame": int(scene.frame_current),
|
|
829
|
+
"body": "".join(parts),
|
|
830
|
+
"markers": [[int(m.frame), m.name] for m in scene.timeline_markers],
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _bone_shown(bone):
|
|
835
|
+
"""`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
|
|
836
|
+
collections = getattr(bone, "collections", None)
|
|
837
|
+
return not collections or any(c.is_visible for c in collections)
|
|
838
|
+
|
|
839
|
+
|
|
735
840
|
class _GraphRefusal(Exception):
|
|
736
841
|
pass
|
|
737
842
|
|
|
@@ -1231,9 +1336,13 @@ class Session:
|
|
|
1231
1336
|
"""
|
|
1232
1337
|
scene = bpy.context.scene
|
|
1233
1338
|
graphs = material_graphs(scene)
|
|
1339
|
+
# An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
|
|
1340
|
+
# (`overlay_empty.hh` `image_sync`).
|
|
1341
|
+
empty_images = {obj.data.name for obj in scene.objects
|
|
1342
|
+
if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
|
|
1234
1343
|
options = {"session": self.session, "evaluate": True, "known": self._known,
|
|
1235
1344
|
"graph_materials": sorted(graphs),
|
|
1236
|
-
"graph_images": sorted({i for g in graphs.values() for i in g["images"]}),
|
|
1345
|
+
"graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
|
|
1237
1346
|
"graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
|
|
1238
1347
|
# THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
|
|
1239
1348
|
# an ordered stream of filesystem patches that is the whole
|
|
@@ -1288,14 +1397,55 @@ class Session:
|
|
|
1288
1397
|
default_color = getattr(getattr(data, "color_attributes", None), "default_color_name", "")
|
|
1289
1398
|
if default_color:
|
|
1290
1399
|
row["default_color"] = default_color
|
|
1400
|
+
# THE VIEWPORT DISPLAY of each material -- what Blender's Solid shading colours a surface
|
|
1401
|
+
# by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
|
|
1402
|
+
# `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
|
|
1403
|
+
for entry in frame["materials"].values():
|
|
1404
|
+
material = bpy.data.materials.get(entry.get("name", ""))
|
|
1405
|
+
if material is not None:
|
|
1406
|
+
entry["viewport"] = {
|
|
1407
|
+
"color": [float(v) for v in material.diffuse_color],
|
|
1408
|
+
"roughness": float(material.roughness),
|
|
1409
|
+
"metallic": float(material.metallic),
|
|
1410
|
+
}
|
|
1291
1411
|
frame["world"] = draw_world(scene)
|
|
1292
1412
|
frame["cameras"] = {
|
|
1293
1413
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
1294
1414
|
}
|
|
1295
1415
|
frame["volumes"] = {}
|
|
1416
|
+
# THE CAMERA A CAMERA VIEW LOOKS THROUGH (`view3d.view_camera`: the scene's, else a
|
|
1417
|
+
# camera that is active, else the view layer's first) and the frame it draws, the
|
|
1418
|
+
# render's shape with its pixel aspect.
|
|
1419
|
+
render = scene.render
|
|
1420
|
+
frame["camera_view"] = {
|
|
1421
|
+
"scene_camera": scene.camera.name if scene.camera is not None else None,
|
|
1422
|
+
"view_layer_cameras": [o.name for o in bpy.context.view_layer.objects if o.type == "CAMERA"],
|
|
1423
|
+
"aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
|
|
1424
|
+
}
|
|
1425
|
+
# THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
|
|
1426
|
+
# operators place and snap, drawn by the tab as Blender's overlay does
|
|
1427
|
+
# (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
|
|
1428
|
+
# turn with the cursor's rotation in whichever `rotation_mode` it has.
|
|
1429
|
+
frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
|
|
1296
1430
|
# THE OVERLAYS, after the door's frame stands: `_weights` reads the
|
|
1297
1431
|
# mesh's own revision out of it (see there), so it cannot run before.
|
|
1298
1432
|
view_layer = bpy.context.view_layer
|
|
1433
|
+
# THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
|
|
1434
|
+
# both from the scene, so they come from here (`_subject_line`).
|
|
1435
|
+
frame["subject"] = _subject_line(scene, view_layer)
|
|
1436
|
+
# A LIGHT'S DISTANCES, for the overlay's direction line (`overlay_light.hh`: from
|
|
1437
|
+
# `clipsta` to `att_dist` down the light's -Z, for a spot or an area light).
|
|
1438
|
+
frame["light_distances"] = {
|
|
1439
|
+
light.name: [float(light.shadow_buffer_clip_start), float(light.cutoff_distance)]
|
|
1440
|
+
for light in bpy.data.lights
|
|
1441
|
+
}
|
|
1442
|
+
# WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
|
|
1443
|
+
frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
|
|
1444
|
+
frame["view"] = _saved_view()
|
|
1445
|
+
frame["units"] = {
|
|
1446
|
+
"system": scene.unit_settings.system,
|
|
1447
|
+
"scale_length": float(scene.unit_settings.scale_length),
|
|
1448
|
+
}
|
|
1299
1449
|
frame["armatures"] = _armatures(view_layer)
|
|
1300
1450
|
frame["weights"] = _weights(scene, view_layer, frame, self._known)
|
|
1301
1451
|
warnings = list(frame.get("warnings", ()))
|
|
@@ -1343,16 +1493,15 @@ class Session:
|
|
|
1343
1493
|
# its value also depends on native undo initialization/checkpoints.
|
|
1344
1494
|
"dirty": bool(bpy.data.is_dirty),
|
|
1345
1495
|
}
|
|
1346
|
-
# A PRESENT
|
|
1347
|
-
#
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1352
|
-
#
|
|
1353
|
-
#
|
|
1354
|
-
|
|
1355
|
-
self.save_due = True
|
|
1496
|
+
# A PRESENT DOES NOT MAKE THE DOCUMENT STALE; a request that can change
|
|
1497
|
+
# the model does (`mark_changed`, from `dispatch`). The predicate was the
|
|
1498
|
+
# present itself, and a present also runs with no change behind it --
|
|
1499
|
+
# opening the document, rebinding its view. MEASURED 2026-09-24 on
|
|
1500
|
+
# `arena`: opening a Model document and doing nothing rewrote its
|
|
1501
|
+
# `.blend` with 556 bytes changed, so a file a person only looked at read
|
|
1502
|
+
# as modified. `columns`/`images` above stay the wrong question for the
|
|
1503
|
+
# same reason as before: a MOVE ships zero columns and a DELETION ships
|
|
1504
|
+
# nothing, and both come from a request `mark_changed` sees.
|
|
1356
1505
|
for key, mesh in frame["meshes"].items():
|
|
1357
1506
|
self._known["mesh:" + key] = mesh["revision"]
|
|
1358
1507
|
for name, image in frame["images"].items():
|
|
@@ -1540,6 +1689,12 @@ class Session:
|
|
|
1540
1689
|
"objects": len(bpy.data.objects),
|
|
1541
1690
|
"size": os.path.getsize(self.document)}
|
|
1542
1691
|
|
|
1692
|
+
def mark_changed(self):
|
|
1693
|
+
"""A request that can change the model ran: the next present carries
|
|
1694
|
+
`saveDue`, and the tab saves after an idle second."""
|
|
1695
|
+
if self.document is not None:
|
|
1696
|
+
self.save_due = True
|
|
1697
|
+
|
|
1543
1698
|
def save_document(self):
|
|
1544
1699
|
"""Write the document -- Blender's own format, by Blender's own operator.
|
|
1545
1700
|
|
|
@@ -1625,11 +1780,11 @@ class Session:
|
|
|
1625
1780
|
#
|
|
1626
1781
|
# It was briefly used to skip a redundant write, and it froze the
|
|
1627
1782
|
# document after its first save -- a reopened session could model all
|
|
1628
|
-
# day and never write again. The predicate is `
|
|
1629
|
-
#
|
|
1630
|
-
# script rewrites the file once), bounded by the tab's
|
|
1631
|
-
# debounce, and it cannot miss a change. The reading still
|
|
1632
|
-
# answer, as an instrument.
|
|
1783
|
+
# day and never write again. The predicate is `mark_changed`'s instead:
|
|
1784
|
+
# a request that can change the model ran. Coarser than a diff (a
|
|
1785
|
+
# read-only script rewrites the file once), bounded by the tab's
|
|
1786
|
+
# idle-second debounce, and it cannot miss a change. The reading still
|
|
1787
|
+
# rides in the answer, as an instrument.
|
|
1633
1788
|
dirty = bool(bpy.data.is_dirty)
|
|
1634
1789
|
existed = os.path.exists(self.document)
|
|
1635
1790
|
directory = os.path.dirname(self.document)
|
|
@@ -4620,7 +4775,32 @@ def outliner_set(path, column, value):
|
|
|
4620
4775
|
% (column, target.bl_rna.identifier, path))
|
|
4621
4776
|
|
|
4622
4777
|
|
|
4778
|
+
# REQUESTS THAT CAN REMOVE OBJECTS, watched: a removal is reported with its cause, because an
|
|
4779
|
+
# object that disappears without a named act is the loss this session must never hide. A scene
|
|
4780
|
+
# was once found emptied and saved over its file by a burst of three edits at boot, with nothing
|
|
4781
|
+
# recording which requests they were.
|
|
4782
|
+
_WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step")
|
|
4783
|
+
|
|
4784
|
+
|
|
4623
4785
|
def dispatch(request):
|
|
4786
|
+
op = request.get("op")
|
|
4787
|
+
if op not in _WATCHED_OPS:
|
|
4788
|
+
return _dispatch_request(request)
|
|
4789
|
+
before = {o.name for o in bpy.data.objects}
|
|
4790
|
+
try:
|
|
4791
|
+
return _dispatch_request(request)
|
|
4792
|
+
finally:
|
|
4793
|
+
removed = before - {o.name for o in bpy.data.objects}
|
|
4794
|
+
if removed:
|
|
4795
|
+
cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
|
|
4796
|
+
if k in request}
|
|
4797
|
+
if op == "execute":
|
|
4798
|
+
cause["code"] = str(request.get("code", ""))[:160]
|
|
4799
|
+
_say("@@VGAI-WARN %s removed objects %s (%s); %d remain" % (
|
|
4800
|
+
op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
|
|
4801
|
+
|
|
4802
|
+
|
|
4803
|
+
def _dispatch_request(request):
|
|
4624
4804
|
op = request.get("op")
|
|
4625
4805
|
if op == "history-begin":
|
|
4626
4806
|
HISTORY.begin()
|
|
@@ -4640,6 +4820,7 @@ def dispatch(request):
|
|
|
4640
4820
|
if op == "history-step":
|
|
4641
4821
|
if request["direction"] not in ("undo", "redo"):
|
|
4642
4822
|
raise ValueError("Unknown history direction")
|
|
4823
|
+
SESSION.mark_changed()
|
|
4643
4824
|
return HISTORY.move(request["token"], request["direction"])
|
|
4644
4825
|
mutation = op in ("execute", "rna-set", "outliner-set") and request.get("history", True)
|
|
4645
4826
|
if not mutation:
|
|
@@ -4675,6 +4856,7 @@ def _dispatch(request):
|
|
|
4675
4856
|
# Arbitrary Python can partially mutate before throwing. Once execution
|
|
4676
4857
|
# starts it needs a checkpoint; capability refusals above do not.
|
|
4677
4858
|
HISTORY.changed()
|
|
4859
|
+
SESSION.mark_changed()
|
|
4678
4860
|
answer = execute(request["code"])
|
|
4679
4861
|
# Every mutation is presented, the rule: the Model
|
|
4680
4862
|
# document is what the agent is looking at.
|
|
@@ -4723,6 +4905,7 @@ def _dispatch(request):
|
|
|
4723
4905
|
if op == "outliner":
|
|
4724
4906
|
return rna_outliner(request.get("selected"))
|
|
4725
4907
|
if op == "outliner-set":
|
|
4908
|
+
SESSION.mark_changed()
|
|
4726
4909
|
answer = outliner_set(request["path"], request["column"], request["value"])
|
|
4727
4910
|
# A COLUMN IS A WRITE, and a write presents -- the same rule `rna-set`
|
|
4728
4911
|
# follows: hiding an object in the Outliner must change what the
|
|
@@ -4743,6 +4926,7 @@ def _dispatch(request):
|
|
|
4743
4926
|
"showing the state before it: " + repr(thrown))
|
|
4744
4927
|
return answer
|
|
4745
4928
|
if op == "rna-set":
|
|
4929
|
+
SESSION.mark_changed()
|
|
4746
4930
|
answer = rna_set(request["path"], request["property"], request["value"],
|
|
4747
4931
|
request.get("index"))
|
|
4748
4932
|
# A WRITE IS PRESENTED, exactly as `execute` presents: the Model
|
|
@@ -143,6 +143,11 @@ export function graphProgramKey(material: THREE.MeshPhysicalMaterial): string {
|
|
|
143
143
|
return `${binding.compiled.key}|${JSON.stringify(binding.channels)}`;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** The UV channels the material's graph reads on the current draw. */
|
|
147
|
+
export function graphUvChannels(material: THREE.MeshPhysicalMaterial): number[] {
|
|
148
|
+
return Object.values(bindings.get(material)?.channels ?? {});
|
|
149
|
+
}
|
|
150
|
+
|
|
146
151
|
/** The channel each named UV layer is in on `geometry` (a layer the mesh
|
|
147
152
|
* lacks reads zeros from channel 9, Blender's missing attribute). */
|
|
148
153
|
function channelsFor(compiled: CompiledGraph, geometry: THREE.BufferGeometry): Record<string, number> {
|
|
@@ -305,7 +310,6 @@ export function applyGraphShader(material: THREE.MeshPhysicalMaterial, shader: T
|
|
|
305
310
|
const {compiled, channels} = binding;
|
|
306
311
|
Object.assign(shader.uniforms, binding.uniforms, {blenderViewport: {value: viewport}});
|
|
307
312
|
const attribute = (channel: number) => (channel === 0 ? 'uv' : `uv${channel}`);
|
|
308
|
-
const guards = [1, 2, 3].map(i => `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif`).join('\n');
|
|
309
313
|
const varyings = `varying vec3 vBlenderObjectPosition;
|
|
310
314
|
varying vec3 vBlenderObjectNormal;
|
|
311
315
|
varying vec3 vBlenderWorldPosition;
|
|
@@ -314,7 +318,7 @@ varying vec3 vBlenderOrco;
|
|
|
314
318
|
${compiled.attributes.map(n => `varying vec4 ${attributeVarying(n)};`).join('\n')}
|
|
315
319
|
${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
|
|
316
320
|
const attributes = compiled.attributes.map(n => `attribute vec4 ${graphAttributeName(n)};`).join('\n');
|
|
317
|
-
shader.vertexShader =
|
|
321
|
+
shader.vertexShader = `attribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
|
|
318
322
|
'#include <project_vertex>',
|
|
319
323
|
`vBlenderObjectPosition = transformed;
|
|
320
324
|
vBlenderObjectNormal = objectNormal;
|
|
@@ -823,15 +823,24 @@ class Compiler {
|
|
|
823
823
|
if (extension === 'CLIP')
|
|
824
824
|
body.push(`if (any(lessThan(${co}.xy, vec2(0.0))) || any(greaterThan(${co}.xy, vec2(1.0)))) { ${color} = vec4(0.0); ${alpha} = 0.0; }`);
|
|
825
825
|
}
|
|
826
|
-
// node_shader_gpu_tex_image's Color output
|
|
827
|
-
//
|
|
828
|
-
//
|
|
826
|
+
// node_shader_gpu_tex_image's Color output over the file's own texels (straight, or
|
|
827
|
+
// premultiplied for a PREMUL image, as Blender's GPU texture holds them): alpha cleared for
|
|
828
|
+
// ignored, packed or data alpha; otherwise straight when Alpha is used, so a factor does
|
|
829
|
+
// not multiply it in twice, and premultiplied when it is not. Measured in Blender 5.2's
|
|
830
|
+
// EEVEE: a straight red texel at alpha 0.5 emits 0.502 with Alpha unused, 1.0 with it used.
|
|
831
|
+
// A PREMUL sRGB image differs: the sampler decodes the premultiplied bytes, `decode(r·a)`,
|
|
832
|
+
// where Blender's texture holds `linear(r)·a`, so its partly transparent texels read darker.
|
|
829
833
|
const mode = prop<string>(node, 'image_alpha_mode');
|
|
830
834
|
const alphaUsed = this.uses(key, 1);
|
|
831
|
-
|
|
832
|
-
|
|
835
|
+
const c = outs([0]);
|
|
836
|
+
if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data'))
|
|
837
|
+
body.push(`${c}.a = 1.0;`);
|
|
838
|
+
else if (mode === 'PREMUL')
|
|
839
|
+
body.push(alphaUsed
|
|
840
|
+
? `if (${c}.a != 0.0 && ${c}.a != 1.0) ${c}.rgb /= ${c}.a;`
|
|
841
|
+
: `${c}.a = 1.0;`);
|
|
833
842
|
else
|
|
834
|
-
body.push(`${
|
|
843
|
+
body.push(alphaUsed ? `${c}.a = 1.0;` : `${c}.rgb *= ${c}.a;`);
|
|
835
844
|
break;
|
|
836
845
|
}
|
|
837
846
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* that MeshPhysicalMaterial does not expose as properties. */
|
|
5
5
|
import * as THREE from 'three';
|
|
6
6
|
import {z} from 'zod';
|
|
7
|
-
import {applyGraphShader, bindGraphDraw, graphProgramKey, setGraphViewport} from './blender-graph-material';
|
|
7
|
+
import {applyGraphShader, bindGraphDraw, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
|
|
8
8
|
import {bindNamedUvChannels} from './blender-texture-samplers';
|
|
9
9
|
|
|
10
10
|
const scalar = z.number().finite();
|
|
@@ -85,9 +85,16 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
85
85
|
setGraphViewport(renderer);
|
|
86
86
|
};
|
|
87
87
|
material.onBeforeCompile = shader => {
|
|
88
|
-
// Three declares channels
|
|
89
|
-
//
|
|
90
|
-
|
|
88
|
+
// Three declares channels 1..3 only for its own maps, and none past 3.
|
|
89
|
+
// Blender has eight named UV maps beside the default channel (9 is the
|
|
90
|
+
// missing layer's zeros). Declare only the channels this draw reads: a
|
|
91
|
+
// driver may count every declared attribute against its sixteen, and
|
|
92
|
+
// six idle ones leave a graph with a few attributes no room.
|
|
93
|
+
const read = new Set([material.map, material.roughnessMap, material.normalMap]
|
|
94
|
+
.flatMap(texture => (texture ? [texture.channel] : [])).concat(graphUvChannels(material)));
|
|
95
|
+
shader.vertexShader = [...read].filter(i => i > 0).sort((a, b) => a - b)
|
|
96
|
+
.map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
|
|
97
|
+
.join('\n') + '\n' + shader.vertexShader;
|
|
91
98
|
Object.assign(shader.uniforms, held);
|
|
92
99
|
shader.fragmentShader = `uniform float blenderCoatIor;
|
|
93
100
|
uniform vec3 blenderWorldExtinction;
|