@volter/blender-engine 0.1.8 → 0.1.10
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 +4 -4
- package/browser/blender-engine.mts +5 -5
- package/browser/blender-wali-engine.mts +7 -7
- package/browser/rna.ts +2 -2
- package/browser/runtime.ts +2 -2
- package/browser/session.py +209 -34
- package/browser/three/attach-presenter.ts +1 -1
- package/browser/three/blender-node-graph.ts +15 -6
- package/browser/three/blender-runtime-armature.ts +1 -1
- 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 +20 -5
- package/browser/three/blender-runtime-view.ts +454 -7
- package/browser/three/blender-workbench-material.ts +115 -0
- package/browser/three/release.ts +1 -1
- package/browser/{vgai_three.py → volter_three.py} +19 -19
- package/browser/worker.ts +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLENDER'S SOLID SHADING, as Blender computes it: `get_world_lighting` from
|
|
3
|
+
* `draw/engines/workbench/shaders/workbench_world_light_lib.glsl`, transcribed line for line,
|
|
4
|
+
* over the four studio lights of the factory `Default` (`SOLID_LIGHTS`, read back from Blender
|
|
5
|
+
* 5.2's preferences with their specular colours) and no ambient (`light_ambient` is zero).
|
|
6
|
+
*
|
|
7
|
+
* Per light: a wrapped diffuse term, `(N·L + wrap) / (1 + wrap)²`; a normalized Blinn specular
|
|
8
|
+
* whose gloss the wrap softens, blended toward a wrapped "environment" term by `wrap²`; a
|
|
9
|
+
* Fresnel lift of the specular colour (`brdf_approx`); and energy conservation that takes the
|
|
10
|
+
* specular's grey level off the diffuse. The surface is the material's VIEWPORT DISPLAY
|
|
11
|
+
* (`Material.diffuse_color`, `roughness`, `metallic`), which is what Solid's MATERIAL colour type
|
|
12
|
+
* reads — never its node graph.
|
|
13
|
+
*
|
|
14
|
+
* WHY A FUNCTION AND NOT A LIGHT RIG: three's lambert and BRDF cannot express the wrap, the
|
|
15
|
+
* coupled specular or the per-light specular colour, so the four lights plus one fitted gain
|
|
16
|
+
* could match the default view's three faces only by absorbing the difference, and missed a
|
|
17
|
+
* face square to the view by 18 levels (143 against Blender's 161). This function, evaluated
|
|
18
|
+
* offline for that face, gives 161.
|
|
19
|
+
*
|
|
20
|
+
* Built on `MeshLambertMaterial` so skinning, morphs and the view-space normal come with it;
|
|
21
|
+
* only the final colour is replaced. Blender uses a fast reciprocal (`fast_rcp`); this uses the
|
|
22
|
+
* exact one.
|
|
23
|
+
*/
|
|
24
|
+
import * as THREE from 'three';
|
|
25
|
+
import { SOLID_LIGHTS } from './blender-runtime-lighting';
|
|
26
|
+
|
|
27
|
+
/** What Blender's Solid reads off a material. */
|
|
28
|
+
export interface ViewportDisplay {
|
|
29
|
+
readonly color: readonly [number, number, number, number];
|
|
30
|
+
readonly roughness: number;
|
|
31
|
+
readonly metallic: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** For a surface with no material: the factory cube's material's display, read back from Blender
|
|
35
|
+
* 5.2 (grey 0.8, roughness 0.5, metallic 0). Blender's own default surface was not measured. */
|
|
36
|
+
export const DEFAULT_VIEWPORT_DISPLAY: ViewportDisplay = { color: [0.8, 0.8, 0.8, 1], roughness: 0.5, metallic: 0 };
|
|
37
|
+
|
|
38
|
+
const WORKBENCH_GLSL = /* glsl */ `
|
|
39
|
+
uniform vec3 wbDir[4];
|
|
40
|
+
uniform vec3 wbDiffuse[4];
|
|
41
|
+
uniform vec3 wbSpecular[4];
|
|
42
|
+
uniform vec4 wbWrap;
|
|
43
|
+
uniform float wbRoughness;
|
|
44
|
+
uniform float wbMetallic;
|
|
45
|
+
|
|
46
|
+
vec4 wbWrapped(vec4 NL, vec4 w) {
|
|
47
|
+
vec4 w1 = w + 1.0;
|
|
48
|
+
return clamp((NL + w) / (w1 * w1), 0.0, 1.0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
vec3 volterWorkbenchLighting(vec3 baseColor, vec3 N, vec3 I) {
|
|
52
|
+
vec3 diffuseColor = mix(baseColor, vec3(0.0), wbMetallic);
|
|
53
|
+
vec3 specularColor = mix(vec3(0.05), baseColor, wbMetallic);
|
|
54
|
+
vec3 R = -reflect(I, N);
|
|
55
|
+
vec4 wrapNL = vec4(dot(wbDir[0], R), dot(wbDir[1], R), dot(wbDir[2], R), dot(wbDir[3], R));
|
|
56
|
+
vec4 specAngle = clamp(vec4(
|
|
57
|
+
dot(normalize(wbDir[0] + I), N), dot(normalize(wbDir[1] + I), N),
|
|
58
|
+
dot(normalize(wbDir[2] + I), N), dot(normalize(wbDir[3] + I), N)), 0.0, 1.0);
|
|
59
|
+
vec4 specNL = clamp(vec4(dot(wbDir[0], N), dot(wbDir[1], N), dot(wbDir[2], N), dot(wbDir[3], N)), 0.0, 1.0);
|
|
60
|
+
vec4 gloss = vec4(1.0 - wbRoughness) * (1.0 - wbWrap);
|
|
61
|
+
vec4 shininess = exp2(10.0 * gloss + 1.0);
|
|
62
|
+
vec4 specLight = pow(specAngle, shininess) * specNL * (shininess * 0.125 + 1.0);
|
|
63
|
+
vec4 envW = mix(wbWrap, vec4(1.0), wbRoughness);
|
|
64
|
+
vec4 specEnv = wbWrapped(wrapNL, envW);
|
|
65
|
+
specLight = mix(specLight, specEnv, wbWrap * wbWrap);
|
|
66
|
+
vec3 specularLight = specLight.x * wbSpecular[0] + specLight.y * wbSpecular[1] +
|
|
67
|
+
specLight.z * wbSpecular[2] + specLight.w * wbSpecular[3];
|
|
68
|
+
float NV = clamp(dot(N, I), 0.0, 1.0);
|
|
69
|
+
float fresnel = exp2(-8.35 * NV) * (1.0 - wbRoughness);
|
|
70
|
+
specularColor = mix(specularColor, vec3(1.0), fresnel);
|
|
71
|
+
specularLight *= specularColor;
|
|
72
|
+
vec4 diffNL = vec4(dot(wbDir[0], N), dot(wbDir[1], N), dot(wbDir[2], N), dot(wbDir[3], N));
|
|
73
|
+
vec4 diffLight = wbWrapped(diffNL, wbWrap);
|
|
74
|
+
vec3 diffuseLight = diffLight.x * wbDiffuse[0] + diffLight.y * wbDiffuse[1] +
|
|
75
|
+
diffLight.z * wbDiffuse[2] + diffLight.w * wbDiffuse[3];
|
|
76
|
+
float specEnergy = dot(specularColor, vec3(0.33333));
|
|
77
|
+
diffuseLight *= diffuseColor * (1.0 - specEnergy);
|
|
78
|
+
return diffuseLight + specularLight;
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const vec3s = (pick: (light: (typeof SOLID_LIGHTS)[number]) => readonly number[]) =>
|
|
83
|
+
SOLID_LIGHTS.map((light) => {
|
|
84
|
+
const [x = 0, y = 0, z = 0] = pick(light);
|
|
85
|
+
return new THREE.Vector3(x, y, z);
|
|
86
|
+
});
|
|
87
|
+
const LIGHT_DIRECTIONS = vec3s((light) => light.direction).map((v) => v.normalize());
|
|
88
|
+
const LIGHT_DIFFUSE = vec3s((light) => light.diffuse);
|
|
89
|
+
const LIGHT_SPECULAR = vec3s((light) => light.specular);
|
|
90
|
+
const LIGHT_WRAP = new THREE.Vector4(...SOLID_LIGHTS.map((light) => light.smooth));
|
|
91
|
+
|
|
92
|
+
/** One Solid-shaded surface: Blender's function over this viewport display. */
|
|
93
|
+
export function workbenchMaterial(display: ViewportDisplay, side: THREE.Side): THREE.MeshLambertMaterial {
|
|
94
|
+
const [r, g, b] = display.color;
|
|
95
|
+
const material = new THREE.MeshLambertMaterial({ side });
|
|
96
|
+
material.color.setRGB(r, g, b, THREE.LinearSRGBColorSpace);
|
|
97
|
+
material.name = 'volter:blender-solid';
|
|
98
|
+
material.onBeforeCompile = (shader) => {
|
|
99
|
+
shader.uniforms['wbDir'] = { value: LIGHT_DIRECTIONS };
|
|
100
|
+
shader.uniforms['wbDiffuse'] = { value: LIGHT_DIFFUSE };
|
|
101
|
+
shader.uniforms['wbSpecular'] = { value: LIGHT_SPECULAR };
|
|
102
|
+
shader.uniforms['wbWrap'] = { value: LIGHT_WRAP };
|
|
103
|
+
shader.uniforms['wbRoughness'] = { value: display.roughness };
|
|
104
|
+
shader.uniforms['wbMetallic'] = { value: display.metallic };
|
|
105
|
+
shader.fragmentShader = shader.fragmentShader
|
|
106
|
+
.replace('void main() {', `${WORKBENCH_GLSL}\nvoid main() {`)
|
|
107
|
+
.replace(
|
|
108
|
+
'#include <opaque_fragment>',
|
|
109
|
+
// The view direction toward the eye, as Blender's `I`: constant in an orthographic view.
|
|
110
|
+
'outgoingLight = volterWorkbenchLighting(diffuseColor.rgb, normal, isOrthographic ? vec3(0.0, 0.0, 1.0) : normalize(vViewPosition));\n#include <opaque_fragment>',
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
material.customProgramCacheKey = () => 'volter-blender-workbench';
|
|
114
|
+
return material;
|
|
115
|
+
}
|
package/browser/three/release.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* to the Blender it started:
|
|
10
10
|
*
|
|
11
11
|
* const { createPresenter, attachPresenter } = await import(url);
|
|
12
|
-
* const detach = attachPresenter(filesystem, "/tmp/
|
|
12
|
+
* const detach = attachPresenter(filesystem, "/tmp/volter-presenter", createPresenter());
|
|
13
13
|
*
|
|
14
14
|
* NO HOST BUNDLES THIS. The module is GPL because it is part of the Blender
|
|
15
15
|
* release; a host loads it the way it loads the wasm -- by URL, under a pinned
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
THE RENDER OVERRIDE IS NOT THE EDITOR'S. `session.py` is the editor tab's
|
|
4
4
|
long-lived session and carries this same override for the session it owns;
|
|
5
5
|
this file is the other delivery of it -- a Blender STARTUP module that ships in
|
|
6
|
-
the pack's resource tree (`scripts/startup/
|
|
6
|
+
the pack's resource tree (`scripts/startup/volter_three.py`), so a bare
|
|
7
7
|
`blender -b ... -a` on ANY surface renders with three.js too. Blender imports
|
|
8
8
|
every module in `scripts/startup` and calls its `register()`.
|
|
9
9
|
|
|
@@ -56,14 +56,14 @@ import bpy
|
|
|
56
56
|
# `{offset, length, dtype, count, stride}` in a small JSON frame.
|
|
57
57
|
import _blender_web
|
|
58
58
|
|
|
59
|
-
ROOT = os.environ.get("
|
|
59
|
+
ROOT = os.environ.get("VOLTER_PRESENTER_ROOT", "/tmp/volter-presenter")
|
|
60
60
|
ASK = os.path.join(ROOT, "ask")
|
|
61
61
|
REPLY = os.path.join(ROOT, "reply")
|
|
62
62
|
# WHERE THE EXPORT DOOR LEAVES THE ARENA. The host cannot reach this program's
|
|
63
63
|
# linear memory, so the arena crosses as a file -- the door writes it when
|
|
64
64
|
# `buffer_path` is set, and the host reads it beside the ask.
|
|
65
65
|
EXPORT_BUFFER_PATH = os.environ.get(
|
|
66
|
-
"
|
|
66
|
+
"VOLTER_EXPORT_BUFFER_PATH", os.path.join(ROOT, "arena.bin")
|
|
67
67
|
)
|
|
68
68
|
# THE FRAME'S IDENTITY IS A PAIR, `(session, revision)`, and the presenter
|
|
69
69
|
# refuses a revision that went backwards under a name it already holds. One
|
|
@@ -78,7 +78,7 @@ EXPORT_BUFFER_PATH = os.environ.get(
|
|
|
78
78
|
# 2026-09-20 in the substrate tab; the first run on a freshly loaded page
|
|
79
79
|
# always worked, which is what hid it). The start time is what makes the name
|
|
80
80
|
# this process's own.
|
|
81
|
-
SESSION = "
|
|
81
|
+
SESSION = "volter-three:%d-%d" % (os.getpid(), time.time_ns())
|
|
82
82
|
HELLO_TIMEOUT_SECONDS = 2.0
|
|
83
83
|
POLL_SECONDS = 0.002
|
|
84
84
|
|
|
@@ -115,14 +115,14 @@ def warn(what):
|
|
|
115
115
|
return
|
|
116
116
|
_WARNED.add(what)
|
|
117
117
|
_WARNINGS.append(what)
|
|
118
|
-
_say("@@
|
|
118
|
+
_say("@@VOLTER-WARN " + what)
|
|
119
119
|
|
|
120
120
|
|
|
121
121
|
def _drop(path):
|
|
122
122
|
try:
|
|
123
123
|
os.unlink(path)
|
|
124
124
|
except OSError as error:
|
|
125
|
-
_say("@@
|
|
125
|
+
_say("@@VOLTER-WARN volter_three could not remove its own %s: %r" % (path, error))
|
|
126
126
|
|
|
127
127
|
|
|
128
128
|
def _prepare_directories():
|
|
@@ -130,7 +130,7 @@ def _prepare_directories():
|
|
|
130
130
|
try:
|
|
131
131
|
os.makedirs(directory, exist_ok=True)
|
|
132
132
|
except OSError as error:
|
|
133
|
-
_say("@@
|
|
133
|
+
_say("@@VOLTER-ERROR volter_three cannot create %s: %r" % (directory, error))
|
|
134
134
|
try:
|
|
135
135
|
os.chmod(directory, 0o777)
|
|
136
136
|
except OSError:
|
|
@@ -371,7 +371,7 @@ def _assert_photographed_from(reported, position, target, up):
|
|
|
371
371
|
)
|
|
372
372
|
|
|
373
373
|
|
|
374
|
-
class
|
|
374
|
+
class VolterRenderEngine(bpy.types.RenderEngine):
|
|
375
375
|
"""The scene's renderer, so `write_still`, `save_render` and Render Result
|
|
376
376
|
behave as Blender's own.
|
|
377
377
|
|
|
@@ -381,7 +381,7 @@ class VgaiRenderEngine(bpy.types.RenderEngine):
|
|
|
381
381
|
starts a path trace nobody asked for.
|
|
382
382
|
"""
|
|
383
383
|
|
|
384
|
-
bl_idname = "
|
|
384
|
+
bl_idname = "VOLTER_THREE"
|
|
385
385
|
bl_label = "three.js"
|
|
386
386
|
bl_use_preview = False
|
|
387
387
|
|
|
@@ -471,7 +471,7 @@ def _register_engine():
|
|
|
471
471
|
("BLENDER_WORKBENCH", "Workbench"),
|
|
472
472
|
):
|
|
473
473
|
existing = _engine_class(identifier)
|
|
474
|
-
if existing is not None and issubclass(existing,
|
|
474
|
+
if existing is not None and issubclass(existing, VolterRenderEngine):
|
|
475
475
|
made.append(identifier)
|
|
476
476
|
continue
|
|
477
477
|
if existing is not None:
|
|
@@ -482,8 +482,8 @@ def _register_engine():
|
|
|
482
482
|
else:
|
|
483
483
|
_release_from_owning_addon(existing)
|
|
484
484
|
engine = type(
|
|
485
|
-
"
|
|
486
|
-
(
|
|
485
|
+
"Volter" + identifier.title().replace("_", ""),
|
|
486
|
+
(VolterRenderEngine,),
|
|
487
487
|
{"bl_idname": identifier, "bl_label": label, "bl_use_preview": False},
|
|
488
488
|
)
|
|
489
489
|
try:
|
|
@@ -496,17 +496,17 @@ def _register_engine():
|
|
|
496
496
|
# keeps `BLENDER_EEVEE` and `BLENDER_WORKBENCH`: a fact about the
|
|
497
497
|
# build, not a warning on every boot.
|
|
498
498
|
unavailable.append(identifier)
|
|
499
|
-
# `
|
|
499
|
+
# `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
|
|
500
500
|
# again after every file load (the ids have to be retaken), and Blender
|
|
501
501
|
# refuses a class it already holds -- which is not a capability anyone
|
|
502
502
|
# lost, so it is not a warning.
|
|
503
|
-
if _engine_class(
|
|
503
|
+
if _engine_class(VolterRenderEngine.bl_idname) is None:
|
|
504
504
|
try:
|
|
505
|
-
bpy.utils.register_class(
|
|
505
|
+
bpy.utils.register_class(VolterRenderEngine)
|
|
506
506
|
except Exception as error: # noqa: BLE001
|
|
507
507
|
warn("could not register the three.js engine: %s" % error)
|
|
508
|
-
if _engine_class(
|
|
509
|
-
made.append(
|
|
508
|
+
if _engine_class(VolterRenderEngine.bl_idname) is not None:
|
|
509
|
+
made.append(VolterRenderEngine.bl_idname)
|
|
510
510
|
return made, unavailable
|
|
511
511
|
|
|
512
512
|
|
|
@@ -518,7 +518,7 @@ def _adopt():
|
|
|
518
518
|
_register_engine()
|
|
519
519
|
for scene in bpy.data.scenes:
|
|
520
520
|
try:
|
|
521
|
-
scene.render.engine = "
|
|
521
|
+
scene.render.engine = "VOLTER_THREE"
|
|
522
522
|
except (TypeError, AttributeError) as error:
|
|
523
523
|
warn("scene %s kept its own render engine: %s" % (scene.name, error))
|
|
524
524
|
|
|
@@ -536,7 +536,7 @@ def register():
|
|
|
536
536
|
_prepare_directories()
|
|
537
537
|
if ask({"op": "hello"}, timeout=HELLO_TIMEOUT_SECONDS) is None:
|
|
538
538
|
_say(
|
|
539
|
-
"
|
|
539
|
+
"volter: rendering with Cycles — no presenter answered at %s within 2 s"
|
|
540
540
|
% ROOT
|
|
541
541
|
)
|
|
542
542
|
return
|
package/browser/worker.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* picks up BETWEEN calls, never mid-call), and carries the bytes to the
|
|
28
28
|
* project through `/__editor/blender-document` BEFORE acknowledging the edit.
|
|
29
29
|
*
|
|
30
|
-
* WHY THE CARRY IS NOT THE MIRROR'S JOB (`
|
|
30
|
+
* WHY THE CARRY IS NOT THE MIRROR'S JOB (`volter blender-mcp`, class Mirror):
|
|
31
31
|
* the Mirror is pull-based and runs only after an `execute_blender_code`, so
|
|
32
32
|
* a document saved after the LAST call of a modeling session
|
|
33
33
|
* would never leave the worker — which is exactly the state this closes
|
|
@@ -172,7 +172,7 @@ async function saveDocument(): Promise<void> {
|
|
|
172
172
|
throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
|
|
173
173
|
}
|
|
174
174
|
setDocumentDirty(false);
|
|
175
|
-
log('log', `@@
|
|
175
|
+
log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
async function startBlender(project: string, document?: string): Promise<unknown> {
|
|
@@ -286,7 +286,7 @@ async function start(project: string, document?: string): Promise<unknown> {
|
|
|
286
286
|
throw new Error(
|
|
287
287
|
'Headless Blender is not served by this editor, so there is no modeling engine: ' +
|
|
288
288
|
`${served.missing.join('; ')}. The engine is Blender compiled to WebAssembly ` +
|
|
289
|
-
'(packages/blender-engine/wasm, or the directory
|
|
289
|
+
'(packages/blender-engine/wasm, or the directory VOLTER_BLENDER_WASM_DIR names); nothing stands in for it.',
|
|
290
290
|
);
|
|
291
291
|
projectRoot = project;
|
|
292
292
|
return startBlender(project, document);
|