@volter/blender-engine 0.1.6 → 0.1.7

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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * A COMPILED MATERIAL GRAPH ON THE PRESENTER'S STANDARD MATERIAL.
3
+ *
4
+ * The graph (`blender-node-graph.ts`) drives the surface inputs it carries;
5
+ * everything else -- lighting, the physical inputs, the door's normal map --
6
+ * stays three's MeshPhysicalMaterial with the door's constants. The graph's
7
+ * program is spliced into that material's own shader at the chunk each input
8
+ * is read in, so an input the graph drives is read from the graph and nothing
9
+ * else about the material changes.
10
+ *
11
+ * Per draw, the geometry decides which attribute channel each named UV layer
12
+ * is (`blenderUvChannels`, as the door's textures read it) and carries the
13
+ * Generated coordinates as an `blenderOrco` attribute -- Blender's own orco,
14
+ * built from the mesh's texture space (`orcoAttribute`).
15
+ */
16
+ import * as THREE from 'three';
17
+ import {presenterChanged} from './blender-presenter-change';
18
+ import {attributeVarying, type CompiledGraph, rampTexture, uvVarying} from './blender-node-graph';
19
+ import {graphAttributeName} from './blender-runtime-geometry';
20
+
21
+ interface Binding {
22
+ compiled: CompiledGraph;
23
+ uniforms: Record<string, THREE.IUniform>;
24
+ ramps: THREE.DataTexture[];
25
+ /** Named UV layer to the channel the current draw's geometry holds it in. */
26
+ channels: Record<string, number>;
27
+ }
28
+ const bindings = new WeakMap<THREE.MeshPhysicalMaterial, Binding>();
29
+ /** Graph structures whose program the GPU refused. A material whose graph is
30
+ * one of these is drawn from the door's constants instead -- a compiler
31
+ * defect must never make geometry disappear. */
32
+ const failed = new Set<string>();
33
+
34
+ export function materialGraph(material: THREE.MeshPhysicalMaterial): CompiledGraph | null {
35
+ return bindings.get(material)?.compiled ?? null;
36
+ }
37
+
38
+ type ImageTexture = THREE.Texture | {tiles: THREE.Texture; map: THREE.Texture} | null;
39
+
40
+ /**
41
+ * A NEW PROGRAM COMPILES OFF THE DRAW, as EEVEE's do: until it is ready the
42
+ * material draws what it drew before (its previous graph, or the door's
43
+ * constants), and the swap happens on the draw after `compileAsync` reports
44
+ * the program linked (`bindGraphDraw`). A structural edit therefore never
45
+ * blocks a frame on the GPU's compiler. A PHOTOGRAPH (`immediate`) cannot
46
+ * wait for a later frame and switches at once.
47
+ */
48
+ interface Pending {
49
+ binding: Binding;
50
+ started: boolean;
51
+ }
52
+ const pendings = new WeakMap<THREE.MeshPhysicalMaterial, Pending>();
53
+ /** The proxy material a swapped-in program was compiled through, disposed
54
+ * once the material itself holds that program (its second draw after). */
55
+ const retiring = new WeakMap<THREE.MeshPhysicalMaterial, {shadow: THREE.Material; draws: number}>();
56
+
57
+ function dropPending(material: THREE.MeshPhysicalMaterial): void {
58
+ const waiting = pendings.get(material);
59
+ if (!waiting) return;
60
+ pendings.delete(material);
61
+ for (const ramp of waiting.binding.ramps) ramp.dispose();
62
+ }
63
+
64
+ /** Point `material` at `compiled` (or at nothing). `texture` answers each
65
+ * image the graph samples; a new structure recompiles, new values do not. */
66
+ export function setMaterialGraph(
67
+ material: THREE.MeshPhysicalMaterial,
68
+ compiled: CompiledGraph | null,
69
+ texture: (image: CompiledGraph['images'][number]) => ImageTexture,
70
+ immediate = false,
71
+ ): void {
72
+ const held = bindings.get(material);
73
+ if (compiled && failed.has(compiled.key)) compiled = null;
74
+ if (!compiled) {
75
+ dropPending(material);
76
+ if (held) {
77
+ for (const ramp of held.ramps) ramp.dispose();
78
+ bindings.delete(material);
79
+ material.needsUpdate = true;
80
+ }
81
+ return;
82
+ }
83
+ if (held && held.compiled.key === compiled.key) {
84
+ dropPending(material);
85
+ fill(held, compiled, texture);
86
+ return;
87
+ }
88
+ if (immediate) {
89
+ dropPending(material);
90
+ for (const ramp of held?.ramps ?? []) ramp.dispose();
91
+ const binding: Binding = {compiled, uniforms: {}, ramps: [], channels: held?.channels ?? {}};
92
+ fill(binding, compiled, texture);
93
+ bindings.set(material, binding);
94
+ material.needsUpdate = true;
95
+ return;
96
+ }
97
+ const waiting = pendings.get(material);
98
+ if (waiting && waiting.binding.compiled.key === compiled.key) {
99
+ fill(waiting.binding, compiled, texture);
100
+ return;
101
+ }
102
+ dropPending(material);
103
+ const binding: Binding = {compiled, uniforms: {}, ramps: [], channels: held?.channels ?? {}};
104
+ fill(binding, compiled, texture);
105
+ pendings.set(material, {binding, started: false});
106
+ }
107
+
108
+ /** A binding's values: uniforms, ramp tables and textures. */
109
+ function fill(binding: Binding, compiled: CompiledGraph, texture: (image: CompiledGraph['images'][number]) => ImageTexture): void {
110
+ binding.compiled = compiled;
111
+ for (const [name, value] of compiled.uniforms) {
112
+ const next = typeof value === 'number' ? value
113
+ : value.length === 2 ? new THREE.Vector2(...(value as [number, number]))
114
+ : value.length === 3 ? new THREE.Vector3(...(value as [number, number, number]))
115
+ : new THREE.Vector4(...(value as [number, number, number, number]));
116
+ const uniform = binding.uniforms[name];
117
+ if (uniform) uniform.value = next;
118
+ else binding.uniforms[name] = {value: next};
119
+ }
120
+ for (const ramp of binding.ramps) ramp.dispose();
121
+ binding.ramps = compiled.ramps.map(r => {
122
+ const tex = rampTexture(r.table);
123
+ (binding.uniforms[r.uniform] ??= {value: null}).value = tex;
124
+ return tex;
125
+ });
126
+ for (const image of compiled.images) {
127
+ const value = texture(image);
128
+ if (image.tiled) {
129
+ const tiled = value !== null && 'tiles' in value ? value : null;
130
+ (binding.uniforms[image.uniform] ??= {value: null}).value = tiled?.tiles ?? null;
131
+ (binding.uniforms[`${image.uniform}Map`] ??= {value: null}).value = tiled?.map ?? null;
132
+ } else {
133
+ (binding.uniforms[image.uniform] ??= {value: null}).value = value !== null && 'tiles' in value ? null : value;
134
+ }
135
+ }
136
+ }
137
+
138
+ /** Part of the material's program cache key: the graph's structure and the
139
+ * UV channels the current geometry puts its layers in. */
140
+ export function graphProgramKey(material: THREE.MeshPhysicalMaterial): string {
141
+ const binding = bindings.get(material);
142
+ if (!binding) return '';
143
+ return `${binding.compiled.key}|${JSON.stringify(binding.channels)}`;
144
+ }
145
+
146
+ /** The channel each named UV layer is in on `geometry` (a layer the mesh
147
+ * lacks reads zeros from channel 9, Blender's missing attribute). */
148
+ function channelsFor(compiled: CompiledGraph, geometry: THREE.BufferGeometry): Record<string, number> {
149
+ const named = geometry.userData['blenderUvChannels'] as Record<string, number> | undefined;
150
+ const count = geometry.getAttribute('position')?.count ?? 0;
151
+ const channels: Record<string, number> = {};
152
+ for (const name of compiled.uvs) {
153
+ let channel = name === '' ? 0 : named?.[name];
154
+ if (channel === undefined) {
155
+ channel = 9;
156
+ if (geometry.getAttribute('uv9')?.count !== count)
157
+ geometry.setAttribute('uv9', new THREE.BufferAttribute(new Float32Array(count * 2), 2));
158
+ }
159
+ channels[name] = channel;
160
+ }
161
+ return channels;
162
+ }
163
+
164
+ /** Per draw: a pending program started compiling (see `Pending`), a compiled
165
+ * one swapped in, a refused one dropped, and the channel of each named layer
166
+ * on this geometry. `shadowOf` makes the proxy a program compiles through:
167
+ * the same material, hooks and all, bound to the pending graph. */
168
+ export function bindGraphDraw(material: THREE.MeshPhysicalMaterial, geometry: THREE.BufferGeometry,
169
+ renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, object: THREE.Object3D,
170
+ shadowOf: (material: THREE.MeshPhysicalMaterial) => THREE.MeshPhysicalMaterial): void {
171
+ const retired = retiring.get(material);
172
+ if (retired && ++retired.draws > 1) {
173
+ retired.shadow.dispose();
174
+ retiring.delete(material);
175
+ }
176
+ const waiting = pendings.get(material);
177
+ if (waiting && !waiting.started) {
178
+ waiting.started = true;
179
+ waiting.binding.channels = channelsFor(waiting.binding.compiled, geometry);
180
+ const shadow = shadowOf(material);
181
+ bindings.set(shadow, waiting.binding);
182
+ const proxy = new THREE.Mesh(geometry, shadow);
183
+ proxy.castShadow = object.castShadow;
184
+ proxy.receiveShadow = object.receiveShadow;
185
+ const swap = () => {
186
+ if (pendings.get(material) !== waiting) {
187
+ shadow.dispose();
188
+ return;
189
+ }
190
+ pendings.delete(material);
191
+ for (const ramp of bindings.get(material)?.ramps ?? []) ramp.dispose();
192
+ bindings.set(material, waiting.binding);
193
+ material.needsUpdate = true;
194
+ retiring.get(material)?.shadow.dispose();
195
+ retiring.set(material, {shadow, draws: 0});
196
+ presenterChanged();
197
+ };
198
+ // A program the GPU refuses is found on the material's own first draw
199
+ // with it (below), exactly as an immediate one is.
200
+ renderer.compileAsync(proxy, camera, scene).then(swap, swap);
201
+ }
202
+ const binding = bindings.get(material);
203
+ if (!binding) return;
204
+ // THE PROGRAM THE LAST DRAW USED, if the GPU refused it: fall back to the
205
+ // constants from the next draw on, and say so once. Three has already
206
+ // logged the compiler's own errors.
207
+ const program = (renderer.properties.get(material) as {currentProgram?: unknown}).currentProgram as
208
+ {cacheKey?: string; diagnostics?: {runnable: boolean; fragmentShader: {log: string}}} | undefined;
209
+ if (program?.diagnostics?.runnable === false && program.cacheKey?.includes(binding.compiled.key)) {
210
+ failed.add(binding.compiled.key);
211
+ bindings.delete(material);
212
+ material.needsUpdate = true;
213
+ const first = program.diagnostics.fragmentShader.log.split('\n').find(line => line.startsWith('ERROR')) ?? '';
214
+ console.warn(`Blender material "${material.name}": its node graph did not compile for WebGL, ` +
215
+ `so it is drawn with its constant values. ${first}`);
216
+ return;
217
+ }
218
+ const channels = channelsFor(binding.compiled, geometry);
219
+ if (JSON.stringify(channels) !== JSON.stringify(binding.channels)) {
220
+ binding.channels = channels;
221
+ material.needsUpdate = true;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * THE ORCO A GRAPH-DRAWN MESH CARRIES, set when the view gives a mesh its
227
+ * materials -- never from a draw hook. Three uploads a geometry's attributes
228
+ * before any draw hook runs, so an attribute first added there has no GPU
229
+ * buffer on that draw and the shader reads zeros: invisible in a viewport
230
+ * that draws again next frame, and a constant pattern in a render, which
231
+ * draws once.
232
+ */
233
+ export function prepareGraphGeometry(mesh: THREE.Mesh): void {
234
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
235
+ if (!materials.some(m => m instanceof THREE.MeshPhysicalMaterial && bindings.has(m))) return;
236
+ const geometry = mesh.geometry;
237
+ const texspace = (mesh.userData['blenderTexspace'] ?? null) as [number[], number[]] | null;
238
+ const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
239
+ const stamp = JSON.stringify([texspace, position?.version ?? 0, position?.count ?? 0]);
240
+ // A deformed mesh's orco came with its columns (`DrawArrays.orco`).
241
+ if (!geometry.userData['blenderOrcoFromDoor'] && geometry.userData['blenderOrcoStamp'] !== stamp) {
242
+ geometry.setAttribute('blenderOrco', orcoAttribute(geometry, texspace));
243
+ geometry.userData['blenderOrcoStamp'] = stamp;
244
+ }
245
+ // THE ATTRIBUTES THE GRAPHS READ. The draw carries every readable layer
246
+ // (`graphAttributeLayers`); '' is the mesh's default colour attribute
247
+ // (`GPU_attribute_default_color`), and a layer the mesh lacks reads zeros,
248
+ // as a missing attribute does in EEVEE.
249
+ const count = position?.count ?? 0;
250
+ const defaultColor = mesh.userData['blenderDefaultColor'] as string | undefined;
251
+ for (const material of materials) {
252
+ const binding = material instanceof THREE.MeshPhysicalMaterial ? bindings.get(material) : undefined;
253
+ for (const name of binding?.compiled.attributes ?? []) {
254
+ const target = graphAttributeName(name);
255
+ const source = name === '' ? (defaultColor === undefined ? undefined : geometry.getAttribute(graphAttributeName(defaultColor)))
256
+ : geometry.getAttribute(target);
257
+ if (source && source.count === count) {
258
+ if (geometry.getAttribute(target) !== source) geometry.setAttribute(target, source);
259
+ } else if (geometry.getAttribute(target)?.count !== count) {
260
+ geometry.setAttribute(target, new THREE.BufferAttribute(new Float32Array(count * 4), 4));
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * GENERATED COORDINATES, as EEVEE's `attr_load_orco` reads them for a mesh
268
+ * without deformation: the position through the texture space,
269
+ * `(co - location) / size * 0.5 + 0.5`. `texspace` is the evaluated mesh's
270
+ * (`session.py` puts it on the object row); without one, the bounds are used,
271
+ * which is what Blender's automatic texture space is.
272
+ */
273
+ export function orcoAttribute(
274
+ geometry: THREE.BufferGeometry,
275
+ texspace: readonly [readonly number[], readonly number[]] | null,
276
+ ): THREE.BufferAttribute {
277
+ const position = geometry.getAttribute('position');
278
+ let location: number[];
279
+ let size: number[];
280
+ if (texspace) {
281
+ location = [...texspace[0]];
282
+ size = [...texspace[1]];
283
+ } else {
284
+ geometry.computeBoundingBox();
285
+ const box = geometry.boundingBox!;
286
+ location = [(box.min.x + box.max.x) / 2, (box.min.y + box.max.y) / 2, (box.min.z + box.max.z) / 2];
287
+ size = [(box.max.x - box.min.x) / 2, (box.max.y - box.min.y) / 2, (box.max.z - box.min.z) / 2];
288
+ }
289
+ // BKE_mesh_texspace_calc: a zero extent becomes one.
290
+ size = size.map(s => (s === 0 ? 1 : s));
291
+ const orco = new Float32Array(position.count * 3);
292
+ for (let i = 0; i < position.count; i++) {
293
+ orco[i * 3] = (position.getX(i) - location[0]!) / size[0]! * 0.5 + 0.5;
294
+ orco[i * 3 + 1] = (position.getY(i) - location[1]!) / size[1]! * 0.5 + 0.5;
295
+ orco[i * 3 + 2] = (position.getZ(i) - location[2]!) / size[2]! * 0.5 + 0.5;
296
+ }
297
+ return new THREE.BufferAttribute(orco, 3);
298
+ }
299
+
300
+ /** Splice the graph into three's physical shader. Called from the material's
301
+ * own `onBeforeCompile` after its physical adjustments. */
302
+ export function applyGraphShader(material: THREE.MeshPhysicalMaterial, shader: THREE.WebGLProgramParametersWithUniforms): void {
303
+ const binding = bindings.get(material);
304
+ if (!binding) return;
305
+ const {compiled, channels} = binding;
306
+ Object.assign(shader.uniforms, binding.uniforms, {blenderViewport: {value: viewport}});
307
+ 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
+ const varyings = `varying vec3 vBlenderObjectPosition;
310
+ varying vec3 vBlenderObjectNormal;
311
+ varying vec3 vBlenderWorldPosition;
312
+ varying vec3 vBlenderWorldNormal;
313
+ varying vec3 vBlenderOrco;
314
+ ${compiled.attributes.map(n => `varying vec4 ${attributeVarying(n)};`).join('\n')}
315
+ ${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
316
+ const attributes = compiled.attributes.map(n => `attribute vec4 ${graphAttributeName(n)};`).join('\n');
317
+ shader.vertexShader = `${guards}\nattribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
318
+ '#include <project_vertex>',
319
+ `vBlenderObjectPosition = transformed;
320
+ vBlenderObjectNormal = objectNormal;
321
+ vBlenderWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
322
+ vBlenderWorldNormal = normalize(transpose(inverse(mat3(modelMatrix))) * objectNormal);
323
+ vBlenderOrco = blenderOrco;
324
+ ${compiled.attributes.map(n => `${attributeVarying(n)} = ${graphAttributeName(n)};`).join('\n')}
325
+ ${compiled.uvs.map(n => `${uvVarying(n)} = ${attribute(channels[n] ?? 0)};`).join('\n')}
326
+ #include <project_vertex>`,
327
+ );
328
+ const out = (name: string) => compiled.outputs[name]?.global;
329
+ // [chunk, line, whether the line goes BEFORE the chunk]
330
+ const fragment: [string, string, boolean][] = [];
331
+ if (compiled.surface === 'ShaderNodeEmission') {
332
+ fragment.push(['#include <color_fragment>', 'diffuseColor = vec4(0.0, 0.0, 0.0, 1.0);', false]);
333
+ fragment.push(['#include <emissivemap_fragment>', `totalEmissiveRadiance = ${out('Color')}.rgb * ${out('Strength')};`, false]);
334
+ // An Emission surface reflects nothing: what leaves it is its emission.
335
+ fragment.push(['#include <opaque_fragment>', 'outgoingLight = totalEmissiveRadiance;', true]);
336
+ } else if (compiled.surface === 'closure') {
337
+ // A mix with no lit shader: nothing reflects, the composition below is all.
338
+ fragment.push(['#include <color_fragment>', 'diffuseColor = vec4(0.0, 0.0, 0.0, 1.0);', false]);
339
+ fragment.push(['#include <emissivemap_fragment>', 'totalEmissiveRadiance = vec3(0.0);', false]);
340
+ } else {
341
+ fragment.push(['#include <color_fragment>', `diffuseColor.rgb = ${out('Base Color')}.rgb;`, false]);
342
+ fragment.push(['#include <alphamap_fragment>', `diffuseColor.a = ${out('Alpha')};`, false]);
343
+ // The physical material rewrites roughnessmap_fragment in place; the
344
+ // chunk three reads next is metalnessmap_fragment.
345
+ fragment.push(['#include <metalnessmap_fragment>', `roughnessFactor = ${out('Roughness')};`, true]);
346
+ fragment.push(['#include <metalnessmap_fragment>', `metalnessFactor = ${out('Metallic')};`, false]);
347
+ fragment.push(['#include <emissivemap_fragment>',
348
+ `totalEmissiveRadiance = ${out('Emission Color')}.rgb * ${out('Emission Strength')};`, false]);
349
+ // A linked Normal replaces three's shading normal (view space), after its
350
+ // own normal map and before the clearcoat normal is derived from it.
351
+ if (out('Normal'))
352
+ fragment.push(['#include <clearcoat_normal_fragment_begin>',
353
+ `normal = normalize((viewMatrix * vec4(transpose(blender_from_three) * ${out('Normal')}, 0.0)).xyz);`, true]);
354
+ }
355
+ if (compiled.closure) {
356
+ // THE SHADER MIX'S COMPOSITION (`bgClosure*`), before three writes the
357
+ // pixel: radiance is the lit shader's (its own alpha already a mix with
358
+ // transparency) plus emission; transmittance is the transparent weight
359
+ // plus what the lit shader's alpha lets through. Three blends straight
360
+ // alpha, so the radiance goes out divided by the coverage.
361
+ fragment.push(['#include <opaque_fragment>', `{
362
+ float blenderCoverage = diffuseColor.a;
363
+ vec3 blenderRadiance = bgClosureP * blenderCoverage * outgoingLight + bgClosureE;
364
+ float blenderAlpha = 1.0 - min(bgClosureT + bgClosureP * (1.0 - blenderCoverage), 1.0);
365
+ #ifdef OPAQUE
366
+ outgoingLight = blenderRadiance;
367
+ #else
368
+ outgoingLight = blenderRadiance / max(blenderAlpha, 1e-4);
369
+ #endif
370
+ diffuseColor.a = blenderAlpha;
371
+ }`, true]);
372
+ }
373
+ // MACROS BOTH SIDES DEFINE (three's `saturate` and Blender's, say): each
374
+ // side's code is compiled under its own definition -- Blender's library
375
+ // between an #undef and a restore of three's line.
376
+ // The shader still names its chunks (`#include <common>`) at this point;
377
+ // three expands them after this hook, so the scan expands them itself.
378
+ const expand = (text: string): string => text.replace(/^[ \t]*#include <(\w+)>/gm,
379
+ (_, name: string) => expand((THREE.ShaderChunk as Record<string, string>)[name] ?? ''));
380
+ const head = expand(shader.fragmentShader.slice(0, shader.fragmentShader.indexOf('void main() {')));
381
+ const theirs = new Map([...head.matchAll(/^[ \t]*#define[ \t]+(\w+).*$/gm)].map(m => [m[1]!, m[0]!.trim()]));
382
+ const ours = new Set([...compiled.declarations.matchAll(/^[ \t]*#define[ \t]+(\w+)/gm)].map(m => m[1]!));
383
+ const shared = [...ours].filter(name => theirs.has(name));
384
+ const library = [
385
+ ...shared.map(name => `#undef ${name}`),
386
+ compiled.declarations,
387
+ ...shared.flatMap(name => [`#undef ${name}`, theirs.get(name)!]),
388
+ ].join('\n');
389
+ let text = shader.fragmentShader.replace('void main() {', `${varyings}\n${library}\nvoid main() {`)
390
+ .replace('#include <clipping_planes_fragment>', '#include <clipping_planes_fragment>\n\tblenderGraph();');
391
+ for (const [chunk, line, before] of fragment) {
392
+ if (!text.includes(chunk)) throw new Error(`three's physical shader has no ${chunk}`);
393
+ text = text.replace(chunk, before ? `${line}\n${chunk}` : `${chunk}\n${line}`);
394
+ }
395
+ shader.fragmentShader = text;
396
+ }
397
+
398
+ /** The drawing buffer size `coordinate_screen` divides by, refreshed per frame. */
399
+ const viewport = new THREE.Vector2(1, 1);
400
+ export function setGraphViewport(renderer: THREE.WebGLRenderer): void {
401
+ renderer.getDrawingBufferSize(viewport);
402
+ }