@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,1026 @@
1
+ /**
2
+ * A MATERIAL'S NODE GRAPH, COMPILED THE WAY EEVEE COMPILES IT.
3
+ *
4
+ * The session ships the graph that drives a Principled BSDF's or an Emission's
5
+ * linked inputs (`session.py`'s `material_graph`): Blender's own nodes, every
6
+ * input socket in declaration order with its GPU type, every output's GPU
7
+ * type, the node's RNA properties. This file turns that into GLSL the way
8
+ * Blender's GPU codegen does (`gpu_codegen.cc`, each node's
9
+ * `node_shader_gpu_*`): one call per node into Blender's own node library
10
+ * (`blender-node-glsl.generated.ts`), arguments in stack order -- the inputs,
11
+ * the node's extra constants, the outputs -- with Blender's implicit socket
12
+ * conversions (`<to>_from_<from>`) between them.
13
+ *
14
+ * What differs from EEVEE is only what EEVEE reads from its own engine: the
15
+ * texture coordinates (`COORDINATES` below, transcribed from EEVEE's
16
+ * `coordinate_*` and `attr_load_orco`), image sampling through three's
17
+ * textures, and the colour ramp's 257-texel band, which WebGL2 holds as a 2D
18
+ * texture instead of a `sampler1DArray`.
19
+ *
20
+ * Unlinked input values are UNIFORMS, never literals, so an edited constant
21
+ * updates the program instead of compiling a new one: `key` names the
22
+ * program's structure, `uniforms` its values.
23
+ */
24
+ import * as THREE from 'three';
25
+ import {z} from 'zod';
26
+ import {BLENDER_NODE_GLSL, BLENDER_NODE_GLSL_PRELUDE} from './blender-node-glsl.generated';
27
+ import {graphAttributeName} from './blender-runtime-geometry';
28
+
29
+ const scalar = z.number().finite();
30
+ const gpuType = z.enum(['float', 'vec2', 'vec3', 'vec4']);
31
+ type GpuType = z.infer<typeof gpuType>;
32
+ const value = z.union([scalar, z.array(scalar)]);
33
+ const link = z.tuple([z.string(), z.number().int().nonnegative()]);
34
+ const sourceSchema = z.union([
35
+ z.object({value}).strict(),
36
+ z.object({link}).strict(),
37
+ ]);
38
+
39
+ /** The node types `session.py`'s `_GRAPH_NODES` ships. A type the session
40
+ * ships and this file does not name is a schema rejection, never a guess. */
41
+ export const GRAPH_NODE_TYPES = [
42
+ 'ShaderNodeTexCoord', 'ShaderNodeUVMap', 'ShaderNodeValue', 'ShaderNodeRGB',
43
+ 'ShaderNodeTexImage', 'ShaderNodeMapping', 'ShaderNodeMath', 'ShaderNodeVectorMath',
44
+ 'ShaderNodeMix', 'ShaderNodeMixRGB', 'ShaderNodeValToRGB', 'ShaderNodeInvert',
45
+ 'ShaderNodeSeparateXYZ', 'ShaderNodeCombineXYZ', 'ShaderNodeTexNoise',
46
+ 'ShaderNodeTexVoronoi', 'ShaderNodeTexChecker', 'ShaderNodeTexWhiteNoise',
47
+ 'ShaderNodeTexWave', 'ShaderNodeTexGradient', 'ShaderNodeTexMagic', 'ShaderNodeTexBrick',
48
+ 'ShaderNodeMapRange', 'ShaderNodeClamp', 'ShaderNodeHueSaturation', 'ShaderNodeBrightContrast',
49
+ 'ShaderNodeGamma', 'ShaderNodeRGBToBW', 'ShaderNodeSeparateColor', 'ShaderNodeCombineColor',
50
+ 'ShaderNodeVectorRotate', 'ShaderNodeFresnel', 'ShaderNodeLayerWeight', 'ShaderNodeBump',
51
+ 'ShaderNodeNormalMap', 'ShaderNodeNewGeometry', 'ShaderNodeVertexColor', 'ShaderNodeAttribute',
52
+ 'ShaderNodeRGBCurve', 'ShaderNodeVectorCurve', 'ShaderNodeFloatCurve',
53
+ ] as const;
54
+
55
+ const nodeSchema = z.object({
56
+ type: z.enum(GRAPH_NODE_TYPES),
57
+ props: z.record(z.string(), z.unknown()),
58
+ inputs: z.array(z.union([
59
+ z.object({value, id: z.string(), gpu: gpuType, enabled: z.boolean()}).strict(),
60
+ z.object({link, id: z.string(), gpu: gpuType, enabled: z.boolean()}).strict(),
61
+ ])),
62
+ outputs: z.array(z.object({id: z.string(), gpu: gpuType.or(z.literal('closure')).nullable()}).strict()),
63
+ }).strict();
64
+
65
+ /** A shader mix as `session.py`'s `_closure` ships it. */
66
+ export type Closure =
67
+ | {principled: true}
68
+ | {emission: [Source, Source]}
69
+ | {transparent: number}
70
+ | {none: true}
71
+ | {mix: [Source, Closure, Closure]}
72
+ | {add: [Closure, Closure]};
73
+ const closureSchema: z.ZodType<Closure> = z.lazy(() => z.union([
74
+ z.object({principled: z.literal(true)}).strict(),
75
+ z.object({emission: z.tuple([sourceSchema, sourceSchema])}).strict(),
76
+ z.object({transparent: z.number()}).strict(),
77
+ z.object({none: z.literal(true)}).strict(),
78
+ z.object({mix: z.tuple([sourceSchema, closureSchema, closureSchema])}).strict(),
79
+ z.object({add: z.tuple([closureSchema, closureSchema])}).strict(),
80
+ ]));
81
+
82
+ export const materialGraphSchema = z.object({
83
+ /** `closure` is a shader mix with no Principled BSDF in it. */
84
+ surface: z.enum(['ShaderNodeBsdfPrincipled', 'ShaderNodeEmission', 'closure']),
85
+ inputs: z.record(z.string(), sourceSchema),
86
+ closure: closureSchema.optional(),
87
+ nodes: z.record(z.string(), nodeSchema),
88
+ }).strict();
89
+
90
+ /** Whether a graph's surface can let the background through where its
91
+ * constants cannot say: a linked Alpha, or a Transparent BSDF in its mix. */
92
+ export function graphSeeThrough(graph: MaterialGraph): boolean {
93
+ const alpha = graph.inputs['Alpha'];
94
+ const transparent = (c: Closure): boolean =>
95
+ 'transparent' in c || ('mix' in c && (transparent(c.mix[1]) || transparent(c.mix[2])))
96
+ || ('add' in c && (transparent(c.add[0]) || transparent(c.add[1])));
97
+ return (alpha !== undefined && 'link' in alpha) || (graph.closure !== undefined && transparent(graph.closure));
98
+ }
99
+ export type MaterialGraph = z.infer<typeof materialGraphSchema>;
100
+ type GraphNode = z.infer<typeof nodeSchema>;
101
+ type Source = z.infer<typeof sourceSchema>;
102
+
103
+ /** Scene-linear Rec.709's luminance, which Blender's default configuration
104
+ * answers to `IMB_colormanagement_get_luminance_coefficients` (the Y row of
105
+ * its scene-linear to XYZ matrix). Codegen passes it to `float_from_float4`. */
106
+ const LUMINANCE = 'vec3(0.2126729, 0.7151522, 0.0721750)';
107
+
108
+ export interface GraphImage {
109
+ readonly uniform: string;
110
+ readonly node: string;
111
+ readonly name: string;
112
+ readonly revision: number;
113
+ readonly extension: 'REPEAT' | 'EXTEND' | 'MIRROR' | 'CLIP';
114
+ readonly closest: boolean;
115
+ /** False for Sphere and Tube projections, which EEVEE samples without
116
+ * mipmaps to hide their derivative seam. */
117
+ readonly mipmap: boolean;
118
+ /** A UDIM image sampled by tile: `uniform` is its tiles' array texture and
119
+ * `${uniform}Map` the tile map (`blender_tile_lookup`). */
120
+ readonly tiled: boolean;
121
+ }
122
+ export interface GraphRamp {
123
+ readonly uniform: string;
124
+ readonly table: readonly (readonly number[])[];
125
+ }
126
+ export interface CompiledGraph {
127
+ /** The program's structure: equal keys share one program. */
128
+ readonly key: string;
129
+ /** Declarations: the prelude, the library files in dependency order, the
130
+ * uniforms, the graph function. Goes before `main`. */
131
+ readonly declarations: string;
132
+ /** Uniform name to value; refreshed on every frame without recompiling. */
133
+ readonly uniforms: ReadonlyMap<string, number | readonly number[]>;
134
+ readonly images: readonly GraphImage[];
135
+ readonly ramps: readonly GraphRamp[];
136
+ /** UV layer names the graph reads ('' is the render UV). */
137
+ readonly uvs: readonly string[];
138
+ /** The mesh attributes the graph reads, by Blender name; '' is the mesh's
139
+ * default colour attribute (a Color Attribute node with no layer). */
140
+ readonly attributes: readonly string[];
141
+ /** Which surface inputs the graph drives, each a GLSL global of `type`. */
142
+ readonly outputs: Readonly<Record<string, {readonly global: string; readonly type: GpuType}>>;
143
+ readonly surface: MaterialGraph['surface'];
144
+ /** A shader mix's composition (`bgClosureP/E/T`), or false. */
145
+ readonly closure: boolean;
146
+ }
147
+
148
+ const width = (type: GpuType): number => (type === 'float' ? 1 : Number(type[3]));
149
+
150
+ function literal(type: GpuType, v: number | readonly number[]): readonly number[] {
151
+ const values = typeof v === 'number' ? [v] : v;
152
+ const n = width(type);
153
+ return Array.from({length: n}, (_, i) => values[i] ?? (i === 3 ? 1 : 0));
154
+ }
155
+
156
+ /** Blender's `<to>_from_<from>` socket conversion, as the codegen library
157
+ * spells it; `float_from_float4` takes the luminance coefficients. */
158
+ function convert(expression: string, from: GpuType, to: GpuType): string {
159
+ if (from === to) return expression;
160
+ const name = (t: GpuType) => (t === 'float' ? 'float' : `float${t[3]}`);
161
+ const extra = from === 'vec4' && to === 'float' ? `, ${LUMINANCE}` : '';
162
+ return `${name(to)}_from_${name(from)}(${expression}${extra})`;
163
+ }
164
+
165
+ /** Math and Vector Math: the operation's GLSL name (`FloatMathOperationInfo`
166
+ * and `gpu_shader_get_name` in node_shader_vector_math.cc). */
167
+ const MATH_NAMES: Record<string, string> = {
168
+ INVERSE_SQRT: 'math_inversesqrt', FRACT: 'math_fraction',
169
+ SMOOTH_MIN: 'math_smoothmin', SMOOTH_MAX: 'math_smoothmax',
170
+ };
171
+ const VECTOR_MATH_NAMES: Record<string, string> = {
172
+ CROSS_PRODUCT: 'vector_math_cross', DOT_PRODUCT: 'vector_math_dot',
173
+ };
174
+ /** `MA_RAMP_*` by RNA identifier to the mix function's suffix. */
175
+ const BLEND_NAMES: Record<string, string> = {
176
+ MIX: 'blend', ADD: 'add', MULTIPLY: 'mult', SUBTRACT: 'sub', SCREEN: 'screen',
177
+ DIVIDE: 'div_fallback', DIFFERENCE: 'diff', EXCLUSION: 'exclusion', DARKEN: 'dark',
178
+ LIGHTEN: 'light', OVERLAY: 'overlay', DODGE: 'dodge', BURN: 'burn', HUE: 'hue',
179
+ SATURATION: 'sat', VALUE: 'val', COLOR: 'color', SOFT_LIGHT: 'soft', LINEAR_LIGHT: 'linear',
180
+ };
181
+ const NOISE_NAMES: Record<string, string> = {
182
+ MULTIFRACTAL: 'multi_fractal', FBM: 'fbm', HYBRID_MULTIFRACTAL: 'hybrid_multi_fractal',
183
+ RIDGED_MULTIFRACTAL: 'ridged_multi_fractal', HETERO_TERRAIN: 'hetero_terrain',
184
+ };
185
+ const VORONOI_NAMES: Record<string, string> = {
186
+ F1: 'f1', F2: 'f2', SMOOTH_F1: 'smooth_f1', DISTANCE_TO_EDGE: 'distance_to_edge',
187
+ N_SPHERE_RADIUS: 'n_sphere_radius',
188
+ };
189
+ /** `SHD_VORONOI_*` distance metrics, passed as a float constant. */
190
+ const VORONOI_METRICS: Record<string, number> = {EUCLIDEAN: 0, MANHATTAN: 1, CHEBYCHEV: 2, MINKOWSKI: 3};
191
+
192
+ /** Every library file that defines `fn`, so a call pulls in its file. */
193
+ const DEFINED_IN = (() => {
194
+ const map = new Map<string, string>();
195
+ for (const [file, entry] of Object.entries(BLENDER_NODE_GLSL))
196
+ for (const m of entry.code.matchAll(/^(?:void|float\d?)\s+([A-Za-z_]\w*)\s*\(/gm))
197
+ if (!map.has(m[1]!)) map.set(m[1]!, file);
198
+ return map;
199
+ })();
200
+
201
+ function prop<T>(node: GraphNode, name: string): T {
202
+ if (!(name in node.props)) throw new Error(`${node.type} arrived without its '${name}' property`);
203
+ return node.props[name] as T;
204
+ }
205
+
206
+ /** What EEVEE's node library reads from EEVEE itself, transcribed: the
207
+ * shading globals `g_data` (`init_globals`: N faces the viewer, Ng the true
208
+ * normal), the object/view matrix accessors `transform_utils` calls, the
209
+ * object's negative-scale flag, `coordinate_incoming` (toward the eye; the
210
+ * view axis in orthographic), `derivative_scale_get` (1 at full resolution)
211
+ * and codegen's precise `dF_impl`, which the bump sub-function evaluates
212
+ * with its derivative flag set (`gpu_shader_codegen_lib.glsl`).
213
+ * BLENDER'S WORLD IS Z-UP: the presenter's model root maps it into three's
214
+ * Y-up world by the exact permutation (x, y, z) -> (x, z, -y)
215
+ * (`blender-runtime-view.ts`), so every world-space quantity handed to the
216
+ * library goes back through `blender_from_three`, and a normal the graph
217
+ * returns comes out through its transpose. */
218
+ const EEVEE_GLOBALS = `
219
+ #define GPU_FRAGMENT_SHADER
220
+ #define FrontFacing gl_FrontFacing
221
+ uniform mat4 modelMatrix;
222
+ const mat3 blender_from_three = mat3(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -1.0, 0.0);
223
+ const mat4 blender_from_three4 = mat4(blender_from_three);
224
+ struct BlenderGlobalData { vec3 P; vec3 N; vec3 Ng; vec3 Ni; bool is_strand; vec3 curve_T; vec2 barycentric_coords; };
225
+ BlenderGlobalData g_data;
226
+ struct ObjectMatrices { mat4 model; mat4 model_inverse; };
227
+ ObjectMatrices object_matrices_get() {
228
+ mat4 model = blender_from_three4 * modelMatrix;
229
+ return ObjectMatrices(model, inverse(model));
230
+ }
231
+ struct ViewMatrices { mat4 viewmat; mat4 viewinv; };
232
+ ViewMatrices view_matrices_get() {
233
+ mat4 viewinv = blender_from_three4 * inverse(viewMatrix);
234
+ return ViewMatrices(inverse(viewinv), viewinv);
235
+ }
236
+ #define OBJECT_NEGATIVE_SCALE 1u
237
+ struct ObjectInfos { uint flag; };
238
+ ObjectInfos object_infos_get() { return ObjectInfos(determinant(mat3(modelMatrix)) < 0.0 ? 1u : 0u); }
239
+ vec3 coordinate_incoming(vec3 P) {
240
+ return isOrthographic ? normalize(blender_from_three * vec3(inverse(viewMatrix)[2]))
241
+ : normalize(blender_from_three * cameraPosition - P);
242
+ }
243
+ float derivative_scale_get() { return 1.0; }
244
+ // EEVEE sharpens image LODs by 1/1.5 because its TAA blurs them back
245
+ // (eevee_film.cc, #122941); the presenter runs no TAA, so the unsharpened LOD.
246
+ float texture_lod_bias_get() { return 1.0; }
247
+ float g_derivative_filter_width = 0.0;
248
+ int g_derivative_flag = 0;
249
+ #define BLENDER_DF_IMPL(T) T dF_impl(T v) { \\
250
+ if (g_derivative_flag > 0) return dFdx(v) * g_derivative_filter_width; \\
251
+ else if (g_derivative_flag < 0) return dFdy(v) * g_derivative_filter_width; \\
252
+ return T(0.0); }
253
+ BLENDER_DF_IMPL(float)
254
+ BLENDER_DF_IMPL(vec2)
255
+ BLENDER_DF_IMPL(vec3)
256
+ BLENDER_DF_IMPL(vec4)
257
+ // A surface's attribute loads (eevee_surf_lib): the value as loaded.
258
+ vec4 attr_load_color_post(vec4 a) { return a; }
259
+ float attr_load_temperature_post(float t) { return t; }
260
+ vec4 attr_load_uniform(vec4 a, uint hash) { return a; }
261
+ void blender_init_globals() {
262
+ // EEVEE's init_globals: the shading normal faces the viewer.
263
+ vec3 N = blender_from_three * normalize(vBlenderWorldNormal);
264
+ g_data.P = blender_from_three * vBlenderWorldPosition;
265
+ g_data.N = gl_FrontFacing ? N : -N;
266
+ g_data.Ni = g_data.N;
267
+ vec3 Ng = normalize(cross(dFdx(g_data.P), dFdy(g_data.P)));
268
+ g_data.Ng = gl_FrontFacing ? Ng : -Ng;
269
+ g_data.is_strand = false;
270
+ g_data.curve_T = vec3(0.0);
271
+ g_data.barycentric_coords = vec2(0.0);
272
+ }
273
+ `;
274
+
275
+ /** The GLSL the graph's coordinate nodes read, from three's own varyings and
276
+ * EEVEE's definitions of each Texture Coordinate output. */
277
+ const COORDINATES = `
278
+ uniform vec2 blenderViewport;
279
+ vec3 blender_orco() {
280
+ // attr_load_orco: the orco attribute (\`orcoAttribute\`), interpolated.
281
+ return vBlenderOrco;
282
+ }
283
+ vec3 blender_object_normal() {
284
+ // normal_transform_world_to_object, of the interpolated normal.
285
+ return normalize(vBlenderObjectNormal);
286
+ }
287
+ vec3 blender_camera() {
288
+ // coordinate_camera: view-space position with z pointing away.
289
+ vec3 v = (viewMatrix * vec4(vBlenderWorldPosition, 1.0)).xyz;
290
+ return vec3(v.xy, -v.z);
291
+ }
292
+ vec3 blender_window() {
293
+ // coordinate_screen, without a camera border.
294
+ return vec3(gl_FragCoord.xy / blenderViewport, 0.0);
295
+ }
296
+ // UDIM: tiled_image_lookup and node_tex_tile_linear/cubic
297
+ // (gpu_shader_tiled_image_lookup_lib.glsl, gpu_shader_material_tex_image.glsl),
298
+ // with the tile map a two-row sampler2D where Blender's is a sampler1DArray,
299
+ // which WebGL2 does not have.
300
+ bool blender_tile_lookup(inout vec3 co, highp sampler2D map) {
301
+ vec2 tile_pos = floor(co.xy);
302
+ if (tile_pos.x < 0.0 || tile_pos.y < 0.0 || tile_pos.x >= 10.0) return false;
303
+ float tile = 10.0 * tile_pos.y + tile_pos.x;
304
+ if (tile >= float(textureSize(map, 0).x)) return false;
305
+ float tile_layer = texelFetch(map, ivec2(int(tile), 0), 0).x;
306
+ if (tile_layer < 0.0) return false;
307
+ vec4 tile_info = texelFetch(map, ivec2(int(tile), 1), 0);
308
+ co = vec3((co.xy - tile_pos) * tile_info.zw + tile_info.xy, tile_layer);
309
+ return true;
310
+ }
311
+ void blender_tex_tile_linear(vec3 co, highp sampler2DArray ima, highp sampler2D map, out vec4 color, out float alpha) {
312
+ color = blender_tile_lookup(co, map) ? texture(ima, co) : vec4(1.0, 0.0, 1.0, 1.0);
313
+ alpha = color.a;
314
+ }
315
+ void blender_tex_tile_cubic(vec3 co, highp sampler2DArray ima, highp sampler2D map, out vec4 color, out float alpha) {
316
+ if (blender_tile_lookup(co, map)) {
317
+ vec2 tex_size = vec2(textureSize(ima, 0).xy);
318
+ co.xy *= tex_size;
319
+ vec2 tc = floor(co.xy - 0.5) + 0.5;
320
+ vec2 w0, w1, w2, w3;
321
+ cubic_bspline_coefficients(co.xy - tc, w0, w1, w2, w3);
322
+ vec2 s0 = w0 + w1;
323
+ vec2 s1 = w2 + w3;
324
+ vec2 f0 = w1 / (w0 + w1);
325
+ vec2 f1 = w3 / (w2 + w3);
326
+ vec4 final_co;
327
+ final_co.xy = tc - 1.0 + f0;
328
+ final_co.zw = tc + 1.0 + f1;
329
+ final_co /= tex_size.xyxy;
330
+ color = textureLod(ima, vec3(final_co.xy, co.z), 0.0) * s0.x * s0.y;
331
+ color += textureLod(ima, vec3(final_co.zy, co.z), 0.0) * s1.x * s0.y;
332
+ color += textureLod(ima, vec3(final_co.xw, co.z), 0.0) * s0.x * s1.y;
333
+ color += textureLod(ima, vec3(final_co.zw, co.z), 0.0) * s1.x * s1.y;
334
+ } else {
335
+ color = vec4(1.0, 0.0, 1.0, 1.0);
336
+ }
337
+ alpha = color.a;
338
+ }
339
+ vec4 blender_uv_tangent(vec2 uv) {
340
+ // A tangent from the UV layer's screen derivatives (three's
341
+ // perturbNormal2Arb), where EEVEE reads the mesh's MikkTSpace tangent.
342
+ vec3 N = g_data.N;
343
+ vec3 dp1 = dFdx(g_data.P), dp2 = dFdy(g_data.P);
344
+ vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
345
+ vec3 dp2perp = cross(dp2, N), dp1perp = cross(N, dp1);
346
+ vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
347
+ vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
348
+ float m = max(dot(T, T), dot(B, B));
349
+ if (m == 0.0) return vec4(0.0, 0.0, 0.0, 1.0);
350
+ T *= inversesqrt(m); B *= inversesqrt(m);
351
+ return vec4(normalize(T), dot(cross(N, T), B) < 0.0 ? -1.0 : 1.0);
352
+ }
353
+ vec3 blender_reflection() {
354
+ // coordinate_reflect: -reflect(world incident, N), incident toward the eye.
355
+ vec3 incident = isOrthographic
356
+ ? normalize(vec3(inverse(viewMatrix)[2]))
357
+ : normalize(cameraPosition - vBlenderWorldPosition);
358
+ return blender_from_three * -reflect(incident, normalize(vBlenderWorldNormal));
359
+ }
360
+ `;
361
+
362
+ /** What every compiler of one material shares: its program's library files,
363
+ * uniforms, textures, UV layers, structure key and sub-functions. */
364
+ interface Shared {
365
+ readonly files: Set<string>;
366
+ readonly uniforms: Map<string, number | readonly number[]>;
367
+ readonly uniformTypes: Map<string, GpuType>;
368
+ readonly images: GraphImage[];
369
+ readonly ramps: GraphRamp[];
370
+ readonly uvs: Set<string>;
371
+ readonly attributes: Set<string>;
372
+ readonly structure: unknown[];
373
+ /** Sub-functions (a Bump's height), defined before `blenderGraph`. */
374
+ readonly functions: string[];
375
+ }
376
+
377
+ class Compiler {
378
+ readonly lines: string[] = [];
379
+ private readonly emitted = new Map<string, {name: string; type: GpuType}[]>();
380
+ private readonly s: Shared;
381
+
382
+ /** `derivative` is Blender's `node_shader_gpu_bump_tex_coord`: in a bump's
383
+ * height sub-function every coordinate read carries `dF_impl`, so the
384
+ * sub-function evaluated with the derivative flag set samples the offset
385
+ * position. */
386
+ constructor(private readonly graph: MaterialGraph, shared?: Shared,
387
+ private readonly derivative = false, private readonly prefix = 'bgN') {
388
+ this.s = shared ?? {files: new Set(), uniforms: new Map(), uniformTypes: new Map(), images: [], ramps: [],
389
+ uvs: new Set(), attributes: new Set(), structure: [], functions: []};
390
+ }
391
+
392
+ get uniforms() { return this.s.uniforms; }
393
+ get images() { return this.s.images; }
394
+ get ramps() { return this.s.ramps; }
395
+
396
+ /** A coordinate as the height sub-function reads it (see `derivative`). */
397
+ private d(expr: string): string {
398
+ return this.derivative ? `(${expr} + dF_impl(${expr}))` : expr;
399
+ }
400
+
401
+ private uniform(type: GpuType, v: number | readonly number[]): string {
402
+ const name = `bgU${this.s.uniforms.size}`;
403
+ const values = literal(type, v);
404
+ this.s.uniforms.set(name, type === 'float' ? values[0]! : values);
405
+ this.s.uniformTypes.set(name, type);
406
+ return name;
407
+ }
408
+
409
+ private use(fn: string): string {
410
+ const file = DEFINED_IN.get(fn);
411
+ if (!file) throw new Error(`Blender's node library has no ${fn}`);
412
+ this.s.files.add(file);
413
+ return fn;
414
+ }
415
+
416
+ /** An input as a GLSL expression of `to`. */
417
+ source(source: Source, to: GpuType): string {
418
+ if ('value' in source) {
419
+ this.s.structure.push('v');
420
+ return this.uniform(to, source.value);
421
+ }
422
+ const [key, index] = source.link;
423
+ const node = this.graph.nodes[key];
424
+ if (!node) throw new Error(`The graph links to ${key}, which it does not carry`);
425
+ const outputs = this.node(key, node);
426
+ const output = outputs[index];
427
+ if (!output) throw new Error(`${key} has no output ${index}`);
428
+ this.s.structure.push(['l', key, index]);
429
+ return convert(output.name, output.type, to);
430
+ }
431
+
432
+ private input(node: GraphNode, id: string, to?: GpuType): string {
433
+ const input = node.inputs.find(i => i.id === id) ?? node.inputs.find(i => i.id.replace(/_/g, ' ') === id);
434
+ if (!input) throw new Error(`${node.type} arrived without its '${id}' input`);
435
+ return this.source(input, to ?? input.gpu);
436
+ }
437
+
438
+ /** A texture node's Vector: its link, or the coordinate Blender defaults to
439
+ * (`node_shader_gpu_default_tex_coord`: Generated; UV for an image). */
440
+ private coordinate(node: GraphNode, fallback: 'orco' | 'uv'): string {
441
+ const input = node.inputs[0]!;
442
+ if ('link' in input) return this.source(input, 'vec3');
443
+ this.s.structure.push(['default', fallback]);
444
+ if (fallback === 'orco') return this.d('blender_orco()');
445
+ this.s.uvs.add('');
446
+ return this.d(`vec3(${uvVarying('')}, 0.0)`);
447
+ }
448
+
449
+ /** Each node's outputs, declared once, as `{name, type}` by socket index. */
450
+ private node(key: string, node: GraphNode): {name: string; type: GpuType}[] {
451
+ const held = this.emitted.get(key);
452
+ if (held) return held;
453
+ const id = `${this.prefix}${this.emitted.size}`;
454
+ // A closure output is not a value any graph input reads; it is declared as
455
+ // a float so socket indices stay Blender's.
456
+ const outputs = node.outputs.map((o, i) => ({name: `${id}_${i}`, type: (o.gpu === 'closure' || o.gpu === null ? 'float' : o.gpu) as GpuType}));
457
+ this.emitted.set(key, outputs);
458
+ const declare = (i: number) => `${outputs[i]!.type} ${outputs[i]!.name} = ${outputs[i]!.type}(0.0);`;
459
+ const all = outputs.map((_, i) => declare(i)).join(' ');
460
+ const outs = (indices: number[]) => indices.map(i => outputs[i]!.name).join(', ');
461
+ const stack = (extras: string[] = []) => {
462
+ // GPU_stack_link: every input with a GPU type, the extras, every output.
463
+ const args = node.inputs.map(i => this.source(i, i.gpu));
464
+ return [...args, ...extras, ...outputs.map(o => o.name)].join(', ');
465
+ };
466
+ this.s.structure.push(['n', node.type, node.props['operation'], node.props['blend_type'], node.props['data_type'],
467
+ node.props['factor_mode'], node.props['clamp_factor'], node.props['clamp_result'], node.props['use_clamp'],
468
+ node.props['noise_dimensions'], node.props['noise_type'], node.props['voronoi_dimensions'], node.props['feature'],
469
+ node.props['distance'], node.props['vector_type'], node.props['uv_map'], node.props['extension'],
470
+ node.props['interpolation'], node.props['image_alpha_mode'], node.props['image_is_data'],
471
+ (node.props['color_ramp'] as {elements?: unknown[]; interpolation?: string; color_mode?: string} | undefined)?.elements?.length,
472
+ (node.props['color_ramp'] as {interpolation?: string} | undefined)?.interpolation]);
473
+ const body: string[] = [all];
474
+ switch (node.type) {
475
+ case 'ShaderNodeValue':
476
+ case 'ShaderNodeRGB': {
477
+ const type = outputs[0]!.type;
478
+ body.push(`${outputs[0]!.name} = ${this.uniform(type, prop<number | number[]>(node, 'value'))};`);
479
+ break;
480
+ }
481
+ case 'ShaderNodeTexCoord': {
482
+ // node_tex_coord's outputs, in socket order.
483
+ this.s.uvs.add('');
484
+ const values = ['blender_orco()', 'blender_object_normal()', `vec3(${uvVarying('')}, 0.0)`,
485
+ 'vBlenderObjectPosition', 'blender_camera()', 'blender_window()', 'blender_reflection()'];
486
+ values.forEach((v, i) => { if (outputs[i]) body.push(`${outputs[i]!.name} = ${this.d(v)};`); });
487
+ break;
488
+ }
489
+ case 'ShaderNodeVertexColor':
490
+ case 'ShaderNodeAttribute': {
491
+ // node_shader_gpu_vertex_color / node_shader_gpu_attribute (GEOMETRY):
492
+ // the named layer as a float4, bump-offset like any coordinate.
493
+ const name = prop<string>(node, node.type === 'ShaderNodeAttribute' ? 'attribute_name' : 'layer_name');
494
+ this.s.attributes.add(name);
495
+ this.s.structure.push(['a', name]);
496
+ const fn = node.type === 'ShaderNodeAttribute' ? 'node_attribute' : 'node_vertex_color';
497
+ body.push(`${this.use(fn)}(${attributeVarying(name)}, ${outs(outputs.map((_, i) => i))});`);
498
+ for (const o of outputs) body.push(`${o.name} = ${this.d(o.name)};`);
499
+ break;
500
+ }
501
+ case 'ShaderNodeUVMap': {
502
+ const name = prop<string>(node, 'uv_map');
503
+ this.s.uvs.add(name);
504
+ body.push(`${outputs[0]!.name} = ${this.d(`vec3(${uvVarying(name)}, 0.0)`)};`);
505
+ break;
506
+ }
507
+ case 'ShaderNodeMath': {
508
+ const op = prop<string>(node, 'operation');
509
+ const fn = this.use(MATH_NAMES[op] ?? `math_${op.toLowerCase()}`);
510
+ body.push(`${fn}(${stack()});`);
511
+ if (prop<boolean>(node, 'use_clamp'))
512
+ body.push(`${this.use('clamp_value')}(${outs([0])}, 0.0, 1.0, ${outs([0])});`);
513
+ break;
514
+ }
515
+ case 'ShaderNodeVectorMath': {
516
+ const op = prop<string>(node, 'operation');
517
+ body.push(`${this.use(VECTOR_MATH_NAMES[op] ?? `vector_math_${op.toLowerCase()}`)}(${stack()});`);
518
+ break;
519
+ }
520
+ case 'ShaderNodeMapping':
521
+ body.push(`${this.use(`mapping_${prop<string>(node, 'vector_type').toLowerCase()}`)}(${stack()});`);
522
+ break;
523
+ case 'ShaderNodeInvert':
524
+ body.push(`${this.use('invert')}(${stack()});`);
525
+ break;
526
+ case 'ShaderNodeSeparateXYZ':
527
+ body.push(`${this.use('separate_xyz')}(${stack()});`);
528
+ break;
529
+ case 'ShaderNodeCombineXYZ':
530
+ body.push(`${this.use('combine_xyz')}(${stack()});`);
531
+ break;
532
+ case 'ShaderNodeMix': {
533
+ // gpu_shader_mix: named sockets only, factor clamp before, colour clamp after.
534
+ const dataType = prop<string>(node, 'data_type');
535
+ const nonUniform = prop<string>(node, 'factor_mode') === 'NON_UNIFORM';
536
+ const suffix = {FLOAT: 'Float', VECTOR: 'Vector', RGBA: 'Color', ROTATION: 'Rotation'}[dataType];
537
+ if (!suffix) throw new Error(`Mix has data type ${dataType}`);
538
+ const vectorFactor = dataType === 'VECTOR' && nonUniform;
539
+ let factor = this.input(node, vectorFactor ? 'Factor_Vector' : 'Factor_Float');
540
+ if (prop<boolean>(node, 'clamp_factor')) {
541
+ const clamped = `${id}_fac`;
542
+ body.push(vectorFactor
543
+ ? `vec3 ${clamped}; ${this.use('node_mix_clamp_vector')}(${factor}, vec3(0.0), vec3(1.0), ${clamped});`
544
+ : `float ${clamped}; ${this.use('node_mix_clamp_value')}(${factor}, 0.0, 1.0, ${clamped});`);
545
+ factor = clamped;
546
+ }
547
+ const name = dataType === 'FLOAT' ? 'node_mix_float'
548
+ : dataType === 'VECTOR' ? (nonUniform ? 'node_mix_vector_non_uniform' : 'node_mix_vector')
549
+ : dataType === 'RGBA' ? `node_mix_${BLEND_NAMES[prop<string>(node, 'blend_type')]}`
550
+ : 'node_mix_rotation';
551
+ const result = node.outputs.findIndex(o => o.id === `Result_${suffix}`);
552
+ body.push(`${this.use(name)}(${factor}, ${this.input(node, `A_${suffix}`)}, ${this.input(node, `B_${suffix}`)}, ${outs([result])});`);
553
+ if (dataType === 'RGBA' && prop<boolean>(node, 'clamp_result'))
554
+ body.push(`${this.use('node_mix_clamp_color')}(${outs([result])}, vec4(0.0), vec4(1.0), ${outs([result])});`);
555
+ break;
556
+ }
557
+ case 'ShaderNodeMixRGB': {
558
+ // gpu_shader_mix_rgb: the factor is always clamped; the result when asked.
559
+ const factor = `${id}_fac`;
560
+ body.push(`float ${factor}; ${this.use('clamp_value')}(${this.input(node, 'Fac')}, 0.0, 1.0, ${factor});`);
561
+ const rest = node.inputs.slice(1).map(i => this.source(i, i.gpu));
562
+ body.push(`${this.use(`mix_${BLEND_NAMES[prop<string>(node, 'blend_type')]}`)}(${[factor, ...rest, outs([0])].join(', ')});`);
563
+ if (prop<boolean>(node, 'use_clamp'))
564
+ body.push(`${this.use('clamp_color')}(${outs([0])}, vec4(0.0), vec4(1.0), ${outs([0])});`);
565
+ break;
566
+ }
567
+ case 'ShaderNodeValToRGB': {
568
+ // gpu_shader_valtorgb: one stop is a constant, two RGB stops the
569
+ // "opti" forms, anything else the 257-texel band.
570
+ const ramp = prop<{interpolation: string; color_mode: string; elements: number[][]; table: number[][]}>(node, 'color_ramp');
571
+ const fac = this.input(node, 'Fac');
572
+ const [e0, e1] = ramp.elements as [number[], number[] | undefined];
573
+ const color = (e: number[]) => this.uniform('vec4', e.slice(1, 5));
574
+ if (ramp.elements.length === 1) {
575
+ body.push(`${outs([0])} = ${color(e0)}; ${outs([1])} = ${outs([0])}.a;`);
576
+ } else if (ramp.elements.length === 2 && ramp.color_mode === 'RGB' && e1 && ramp.interpolation !== 'B_SPLINE' && ramp.interpolation !== 'CARDINAL') {
577
+ if (ramp.interpolation === 'CONSTANT') {
578
+ body.push(`${this.use('valtorgb_opti_constant')}(${fac}, ${this.uniform('float', Math.max(e0[0]!, e1[0]!))}, ${color(e0)}, ${color(e1)}, ${outs([0, 1])});`);
579
+ } else {
580
+ const mul = 1 / (e1[0]! - e0[0]!);
581
+ const fn = ramp.interpolation === 'EASE' ? 'valtorgb_opti_ease' : 'valtorgb_opti_linear';
582
+ body.push(`${this.use(fn)}(${fac}, ${this.uniform('vec2', [mul, -mul * e0[0]!])}, ${color(e0)}, ${color(e1)}, ${outs([0, 1])});`);
583
+ }
584
+ } else {
585
+ const uniform = `bgRamp${this.s.ramps.length}`;
586
+ this.s.ramps.push({uniform, table: ramp.table});
587
+ this.s.structure.push(['ramp', ramp.interpolation === 'CONSTANT']);
588
+ // valtorgb / valtorgb_nearest over a 257x1 texture.
589
+ body.push(ramp.interpolation === 'CONSTANT'
590
+ ? `${outs([0])} = texelFetch(${uniform}, ivec2(int(clamp(${fac}, 0.0, 1.0) * 256.0), 0), 0);`
591
+ : `${outs([0])} = texture(${uniform}, vec2(clamp(${fac}, 0.0, 1.0) * (256.0 / 257.0) + 0.5 / 257.0, 0.5));`);
592
+ body.push(`${outs([1])} = ${outs([0])}.a;`);
593
+ }
594
+ break;
595
+ }
596
+ case 'ShaderNodeRGBCurve':
597
+ case 'ShaderNodeVectorCurve':
598
+ case 'ShaderNodeFloatCurve': {
599
+ // gpu_shader_curve_rgb / _vec / _float: Blender's own curves_* over the
600
+ // band texture (layer 0.5 of a 257x1 sampler2D, see the generator), the
601
+ // table ranges and slopes as uniforms. RGB always takes
602
+ // curves_combined_rgb: `BKE_curvemapping_is_map_identity` reads the
603
+ // second point as (0, 0), so its combined-only branch is reached only by
604
+ // a curve whose points both sit at the origin, which draws the same.
605
+ const mapping = prop<{table: number[][]; minimums: number[]; dividers: number[]; starts: number[]; ends: number[]}>(node, 'curve_mapping');
606
+ const uniform = `bgRamp${this.s.ramps.length}`;
607
+ this.s.ramps.push({uniform, table: mapping.table});
608
+ this.s.structure.push(['curve']);
609
+ // The Float Curve names its factor `Factor`; the other two `Fac`.
610
+ const fac = this.input(node, node.inputs.some(i => i.id === 'Factor') ? 'Factor' : 'Fac', 'float');
611
+ if (node.type === 'ShaderNodeRGBCurve') {
612
+ body.push(`${this.use('curves_combined_rgb')}(${fac}, ${this.input(node, 'Color', 'vec4')}, vec4(0.0, 0.0, 0.0, 1.0), vec4(1.0), ` +
613
+ `${uniform}, 0.5, ${this.uniform('vec4', mapping.minimums)}, ${this.uniform('vec4', mapping.dividers)}, ` +
614
+ `${this.uniform('vec4', mapping.starts)}, ${this.uniform('vec4', mapping.ends)}, ${outs([0])});`);
615
+ } else if (node.type === 'ShaderNodeVectorCurve') {
616
+ body.push(`${this.use('curves_vector_mixed')}(${fac}, ${this.input(node, 'Vector', 'vec3')}, ${uniform}, 0.5, ` +
617
+ `${this.uniform('vec3', mapping.minimums.slice(0, 3))}, ${this.uniform('vec3', mapping.dividers.slice(0, 3))}, ` +
618
+ `${this.uniform('vec3', mapping.starts.slice(0, 3))}, ${this.uniform('vec3', mapping.ends.slice(0, 3))}, ${outs([0])});`);
619
+ } else {
620
+ body.push(`${this.use('curves_float_mixed')}(${fac}, ${this.input(node, 'Value', 'float')}, ${uniform}, 0.5, ` +
621
+ `${this.uniform('float', mapping.minimums[0]!)}, ${this.uniform('float', mapping.dividers[0]!)}, ` +
622
+ `${this.uniform('float', mapping.starts[0]!)}, ${this.uniform('float', mapping.ends[0]!)}, ${outs([0])});`);
623
+ }
624
+ break;
625
+ }
626
+ case 'ShaderNodeTexNoise': {
627
+ const dims = Number(prop<string>(node, 'noise_dimensions')[0]);
628
+ const fn = this.use(`node_noise_tex_${NOISE_NAMES[prop<string>(node, 'noise_type')]}_${dims}d`);
629
+ const co = this.coordinate(node, 'orco');
630
+ const rest = node.inputs.slice(1).map(i => this.source(i, i.gpu));
631
+ const normalize = prop<boolean>(node, 'normalize') ? '1.0' : '0.0';
632
+ body.push(`${fn}(${[co, ...rest, normalize, '1.0', outs(outputs.map((_, i) => i))].join(', ')});`);
633
+ break;
634
+ }
635
+ case 'ShaderNodeTexVoronoi': {
636
+ const dims = Number(prop<string>(node, 'voronoi_dimensions')[0]);
637
+ const fn = this.use(`node_tex_voronoi_${VORONOI_NAMES[prop<string>(node, 'feature')]}_${dims}d`);
638
+ const co = this.coordinate(node, 'orco');
639
+ const rest = node.inputs.slice(1).map(i => this.source(i, i.gpu));
640
+ const metric = `${VORONOI_METRICS[prop<string>(node, 'distance')]!.toFixed(1)}`;
641
+ const normalize = prop<boolean>(node, 'normalize') ? '1.0' : '0.0';
642
+ body.push(`${fn}(${[co, ...rest, metric, normalize, outs(outputs.map((_, i) => i))].join(', ')});`);
643
+ break;
644
+ }
645
+ case 'ShaderNodeTexChecker': {
646
+ const co = this.coordinate(node, 'orco');
647
+ const rest = node.inputs.slice(1).map(i => this.source(i, i.gpu));
648
+ body.push(`${this.use('node_tex_checker')}(${[co, ...rest, outs(outputs.map((_, i) => i))].join(', ')});`);
649
+ break;
650
+ }
651
+ case 'ShaderNodeTexWhiteNoise': {
652
+ const dims = Number(prop<string>(node, 'noise_dimensions')[0]);
653
+ body.push(`${this.use(`node_white_noise_${dims}d`)}(${stack()});`);
654
+ break;
655
+ }
656
+ case 'ShaderNodeTexWave':
657
+ case 'ShaderNodeTexGradient':
658
+ case 'ShaderNodeTexMagic':
659
+ case 'ShaderNodeTexBrick': {
660
+ // Procedural textures with node constants (`GPU_constant`/`GPU_uniform`
661
+ // after the inputs), their Vector defaulting to Generated.
662
+ const co = this.coordinate(node, 'orco');
663
+ const rest = node.inputs.slice(1).map(i => this.source(i, i.gpu));
664
+ const e = (name: string) => prop<number>(node, `${name}#value`).toFixed(1);
665
+ const extras = node.type === 'ShaderNodeTexWave'
666
+ ? [e('wave_type'), e('bands_direction'), e('rings_direction'), e('wave_profile')]
667
+ : node.type === 'ShaderNodeTexGradient' ? [e('gradient_type')]
668
+ : node.type === 'ShaderNodeTexMagic' ? [prop<number>(node, 'turbulence_depth').toFixed(1)]
669
+ : [this.uniform('float', prop<number>(node, 'offset')), prop<number>(node, 'offset_frequency').toFixed(1),
670
+ this.uniform('float', prop<number>(node, 'squash')), prop<number>(node, 'squash_frequency').toFixed(1)];
671
+ const fn = {ShaderNodeTexWave: 'node_tex_wave', ShaderNodeTexGradient: 'node_tex_gradient',
672
+ ShaderNodeTexMagic: 'node_tex_magic', ShaderNodeTexBrick: 'node_tex_brick'}[node.type];
673
+ body.push(`${this.use(fn)}(${[co, ...rest, ...extras, outs(outputs.map((_, i) => i))].join(', ')});`);
674
+ break;
675
+ }
676
+ case 'ShaderNodeMapRange': {
677
+ // gpu_shader_map_range: every socket, the clamp flag; a clamped
678
+ // linear/stepped float result then goes through clamp_range(To Min, To Max).
679
+ const vector = prop<string>(node, 'data_type') === 'FLOAT_VECTOR';
680
+ const mode = prop<string>(node, 'interpolation_type').toLowerCase();
681
+ const clamp = prop<boolean>(node, 'clamp');
682
+ body.push(`${this.use(`${vector ? 'vector_' : ''}map_range_${mode}`)}(${stack([clamp ? '1.0' : '0.0'])});`);
683
+ if (clamp && !vector && (mode === 'linear' || mode === 'stepped'))
684
+ body.push(`${this.use('clamp_range')}(${outs([0])}, ${this.input(node, 'To Min', 'float')}, ${this.input(node, 'To Max', 'float')}, ${outs([0])});`);
685
+ break;
686
+ }
687
+ case 'ShaderNodeClamp':
688
+ body.push(`${this.use(prop<string>(node, 'clamp_type') === 'MINMAX' ? 'clamp_minmax' : 'clamp_range')}(${stack()});`);
689
+ break;
690
+ case 'ShaderNodeHueSaturation':
691
+ body.push(`${this.use('hue_sat')}(${stack()});`);
692
+ break;
693
+ case 'ShaderNodeBrightContrast':
694
+ body.push(`${this.use('brightness_contrast')}(${stack()});`);
695
+ break;
696
+ case 'ShaderNodeGamma':
697
+ body.push(`${this.use('node_gamma')}(${stack()});`);
698
+ break;
699
+ case 'ShaderNodeRGBToBW':
700
+ body.push(`${this.use('rgbtobw')}(${stack([LUMINANCE])});`);
701
+ break;
702
+ case 'ShaderNodeSeparateColor':
703
+ body.push(`${this.use(`separate_color_${prop<string>(node, 'mode').toLowerCase()}`)}(${stack()});`);
704
+ break;
705
+ case 'ShaderNodeCombineColor':
706
+ body.push(`${this.use(`combine_color_${prop<string>(node, 'mode').toLowerCase()}`)}(${stack()});`);
707
+ break;
708
+ case 'ShaderNodeVectorRotate': {
709
+ const name = {AXIS_ANGLE: 'axis_angle', X_AXIS: 'axis_x', Y_AXIS: 'axis_y', Z_AXIS: 'axis_z',
710
+ EULER_XYZ: 'euler_xyz'}[prop<string>(node, 'rotation_type')];
711
+ body.push(`${this.use(`node_vector_rotate_${name}`)}(${stack([prop<boolean>(node, 'invert') ? '-1.0' : '1.0'])});`);
712
+ break;
713
+ }
714
+ case 'ShaderNodeFresnel':
715
+ case 'ShaderNodeLayerWeight': {
716
+ // An unlinked Normal is the shading normal (`world_normals_get`).
717
+ const n = node.inputs[1]!;
718
+ const normal = 'link' in n ? this.source(n, 'vec3') : 'g_data.N';
719
+ if (!('link' in n)) this.s.structure.push('world-normal');
720
+ const first = this.source(node.inputs[0]!, 'float');
721
+ const fn = node.type === 'ShaderNodeFresnel' ? 'node_fresnel' : 'node_layer_weight';
722
+ body.push(`${this.use(fn)}(${[first, normal, outs(outputs.map((_, i) => i))].join(', ')});`);
723
+ break;
724
+ }
725
+ case 'ShaderNodeBump': {
726
+ // gpu_shader_bump: no Height is a no-op (the Normal, or the shading
727
+ // normal); otherwise the height sub-function evaluated at the shading
728
+ // point and at the dF offsets, into node_bump.
729
+ const height = node.inputs.find(i => i.id === 'Height');
730
+ const normalIn = node.inputs.find(i => i.id === 'Normal');
731
+ const normal = normalIn && 'link' in normalIn ? this.source(normalIn, 'vec3') : 'g_data.N';
732
+ if (!height || !('link' in height)) {
733
+ body.push(`${outs([0])} = ${normal};`);
734
+ break;
735
+ }
736
+ const fn = `bgHeight${this.s.functions.length}`;
737
+ this.s.functions.push('');
738
+ const child = new Compiler(this.graph, this.s, true, `${fn}_`);
739
+ const expr = child.source(height, 'float');
740
+ this.s.functions[Number(fn.slice('bgHeight'.length))] =
741
+ `float ${fn}() {\n ${child.lines.join('\n ')}\n return ${expr};\n}`;
742
+ const width = this.input(node, 'Filter Width', 'float');
743
+ body.push(`float ${id}_h = ${fn}();`,
744
+ `g_derivative_filter_width = ${width} * derivative_scale_get();`,
745
+ `g_derivative_flag = 1; float ${id}_hx = ${fn}();`,
746
+ `g_derivative_flag = -1; float ${id}_hy = ${fn}();`,
747
+ `g_derivative_flag = 0;`);
748
+ const invert = prop<boolean>(node, 'invert') ? '-1.0' : '1.0';
749
+ body.push(`${this.use('node_bump')}(${this.input(node, 'Strength', 'float')}, ${this.input(node, 'Distance', 'float')}, ` +
750
+ `${width}, ${id}_h, ${normal}, vec2(${id}_hx, ${id}_hy), ${invert}, ${outs([0])});`);
751
+ break;
752
+ }
753
+ case 'ShaderNodeNormalMap': {
754
+ // gpu_shader_normal_map.
755
+ const space = prop<string>(node, 'space');
756
+ const strength = this.input(node, 'Strength', 'float');
757
+ const color = this.input(node, 'Color', 'vec3');
758
+ const n = `${id}_n`;
759
+ body.push(`vec3 ${n}; ${this.use(space.startsWith('BLENDER_') ? 'color_to_blender_normal_new_shading' : 'color_to_normal_new_shading')}(${color}, ${n});`);
760
+ if (node.props['convention'] === 'DIRECTX') body.push(`${this.use('color_invert_green_channel')}(${n}, ${n});`);
761
+ if (space === 'TANGENT') {
762
+ const uv = prop<string>(node, 'uv_map');
763
+ this.s.uvs.add(uv);
764
+ body.push(`${this.use('node_normal_map')}(blender_uv_tangent(${uvVarying(uv)}), ${strength}, ${n}, g_data.Ni, ${outs([0])});`);
765
+ } else {
766
+ if (space === 'OBJECT' || space === 'BLENDER_OBJECT')
767
+ body.push(`${this.use('normal_transform_object_to_world')}(${n}, ${n});`);
768
+ body.push(`${this.use('node_normal_map_mix')}(${strength}, ${n}, ${outs([0])});`);
769
+ }
770
+ break;
771
+ }
772
+ case 'ShaderNodeNewGeometry': {
773
+ // node_shader_gpu_geometry: every output a coordinate, 1/2/4 normalized.
774
+ body.push(`${this.use('node_geometry')}(${['blender_orco()', outs(outputs.map((_, i) => i))].join(', ')});`);
775
+ // Only Position is a bump coordinate (node_shader_gpu_geometry).
776
+ if (outputs[0]) body.push(`${outputs[0].name} = ${this.d(outputs[0].name)};`);
777
+ for (const i of [1, 2, 4]) if (outputs[i]) body.push(`${outputs[i]!.name} = normalize(${outputs[i]!.name});`);
778
+ break;
779
+ }
780
+ case 'ShaderNodeTexImage': {
781
+ const image = prop<{name: string; revision: number} | null>(node, 'image');
782
+ if (image === null) {
783
+ // node_tex_image_empty.
784
+ body.push(`${outs([0])} = vec4(0.0); ${outs([1])} = 0.0;`);
785
+ break;
786
+ }
787
+ // node_shader_gpu_tex_image.
788
+ const projection = prop<string>(node, 'projection');
789
+ const interpolation = prop<string>(node, 'interpolation');
790
+ const cubic = interpolation === 'Cubic' || interpolation === 'Smart';
791
+ const extension = prop<GraphImage['extension']>(node, 'extension');
792
+ const uniform = `bgImage${this.s.images.length}`;
793
+ this.s.images.push({
794
+ uniform, node: key, name: image.name, revision: image.revision, extension,
795
+ closest: interpolation === 'Closest',
796
+ mipmap: projection !== 'SPHERE' && projection !== 'TUBE',
797
+ // EEVEE samples tiles only through a flat projection; any other
798
+ // treats the first tile as the image (#141776).
799
+ tiled: projection === 'FLAT' && node.props['image_tiled'] === true,
800
+ });
801
+ const tiled = this.s.images.at(-1)!.tiled;
802
+ this.s.structure.push(['img', projection, cubic, tiled]);
803
+ let co = this.coordinate(node, 'uv');
804
+ const color = outs([0]), alpha = outs([1]);
805
+ if (projection === 'BOX') {
806
+ const n = `${id}_n`;
807
+ body.push(`vec3 ${n}; ${this.use('world_normals_get')}(${n}); ${this.use('normal_transform_world_to_object')}(${n}, ${n});`,
808
+ `vec4 ${id}_c1, ${id}_c2, ${id}_c3;`,
809
+ `${this.use(cubic ? 'tex_box_sample_cubic' : 'tex_box_sample_linear')}(${co}, ${n}, ${uniform}, ${id}_c1, ${id}_c2, ${id}_c3);`,
810
+ `${this.use('tex_box_blend')}(${n}, ${id}_c1, ${id}_c2, ${id}_c3, ${this.uniform('float', prop<number>(node, 'projection_blend'))}, ${color}, ${alpha});`);
811
+ } else if (tiled) {
812
+ if (cubic) this.use('cubic_bspline_coefficients');
813
+ body.push(`${cubic ? 'blender_tex_tile_cubic' : 'blender_tex_tile_linear'}(${co}, ${uniform}, ${uniform}Map, ${color}, ${alpha});`);
814
+ } else {
815
+ if (projection === 'SPHERE' || projection === 'TUBE') {
816
+ const mapped = `${id}_co`;
817
+ body.push(`vec3 ${mapped}; ${this.use('point_texco_remap_square')}(${co}, ${mapped});`,
818
+ `${this.use(projection === 'SPHERE' ? 'point_map_to_sphere' : 'point_map_to_tube')}(${mapped}, ${mapped});`);
819
+ co = mapped;
820
+ }
821
+ body.push(`${this.use(cubic ? 'node_tex_image_cubic' : 'node_tex_image_linear')}(${co}, ${uniform}, ${color}, ${alpha});`);
822
+ // CLAMP_TO_BORDER, whose border is transparent black.
823
+ if (extension === 'CLIP')
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
+ }
826
+ // node_shader_gpu_tex_image's Color output, for three's straight-alpha
827
+ // upload: cleared for ignored/packed/data alpha; premultiplied (and
828
+ // cleared) when Alpha is used; straight otherwise.
829
+ const mode = prop<string>(node, 'image_alpha_mode');
830
+ const alphaUsed = this.uses(key, 1);
831
+ if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data') || !alphaUsed)
832
+ body.push(`${outs([0])}.a = 1.0;`);
833
+ else
834
+ body.push(`${outs([0])} = vec4(${outs([0])}.rgb * ${outs([0])}.a, 1.0);`);
835
+ break;
836
+ }
837
+ }
838
+ this.lines.push(...body);
839
+ return outputs;
840
+ }
841
+
842
+ /** Whether anything in the graph reads `key`'s output `index` (Blender's
843
+ * `hasoutput`). */
844
+ private uses(key: string, index: number): boolean {
845
+ const reads = (s: Source) => 'link' in s && s.link[0] === key && s.link[1] === index;
846
+ const closure = (c: Closure | undefined): boolean => c !== undefined && (
847
+ 'emission' in c ? c.emission.some(reads)
848
+ : 'mix' in c ? reads(c.mix[0]) || closure(c.mix[1]) || closure(c.mix[2])
849
+ : 'add' in c ? closure(c.add[0]) || closure(c.add[1]) : false);
850
+ return Object.values(this.graph.inputs).some(reads)
851
+ || Object.values(this.graph.nodes).some(n => n.inputs.some(reads))
852
+ || closure(this.graph.closure);
853
+ }
854
+
855
+ compile(): CompiledGraph {
856
+ const surfaceInputs = this.graph.surface === 'ShaderNodeEmission'
857
+ ? {Color: 'vec4', Strength: 'float'} as const
858
+ : {'Base Color': 'vec4', Metallic: 'float', Roughness: 'float', Alpha: 'float',
859
+ 'Emission Color': 'vec4', 'Emission Strength': 'float', Normal: 'vec3'} as const;
860
+ const outputs: Record<string, {global: string; type: GpuType}> = {};
861
+ const assignments: string[] = [];
862
+ let index = 0;
863
+ for (const [name, type] of Object.entries(surfaceInputs)) {
864
+ const source = this.graph.inputs[name];
865
+ if (!source) continue;
866
+ // An unlinked Normal is the geometry's own, which three already shades.
867
+ if (name === 'Normal' && !('link' in source)) continue;
868
+ const global = `bgOut${index++}`;
869
+ outputs[name] = {global, type};
870
+ this.s.structure.push(['out', name]);
871
+ assignments.push(`${global} = ${this.source(source, type)};`);
872
+ }
873
+ // A SHADER MIX, as EEVEE composes closures: each leaf weighted by the
874
+ // factors above it (node_mix_shader: saturated factor to the second
875
+ // input, the rest to the first; node_add_shader: both whole). The one
876
+ // Principled BSDF's radiance is three's lighting, scaled by its weight
877
+ // (`bgClosureP`); Emission adds its radiance (`bgClosureE`); a gray
878
+ // Transparent adds its transmittance (`bgClosureT`).
879
+ const closure = this.graph.closure;
880
+ if (closure) {
881
+ let factors = 0;
882
+ const walk = (c: Closure, w: string): void => {
883
+ if ('principled' in c) {
884
+ this.s.structure.push('cP');
885
+ assignments.push(`bgClosureP += ${w};`);
886
+ } else if ('emission' in c) {
887
+ this.s.structure.push('cE');
888
+ assignments.push(`bgClosureE += ${w} * ${this.source(c.emission[0], 'vec4')}.rgb * ${this.source(c.emission[1], 'float')};`);
889
+ } else if ('transparent' in c) {
890
+ this.s.structure.push('cT');
891
+ assignments.push(`bgClosureT += ${w} * ${this.source({value: c.transparent}, 'float')};`);
892
+ } else if ('mix' in c) {
893
+ this.s.structure.push('cM');
894
+ const f = `bgClosureF${factors++}`;
895
+ assignments.push(`float ${f} = clamp(${this.source(c.mix[0], 'float')}, 0.0, 1.0);`);
896
+ walk(c.mix[1], `${w} * (1.0 - ${f})`);
897
+ walk(c.mix[2], `${w} * ${f}`);
898
+ } else if ('add' in c) {
899
+ this.s.structure.push('cA');
900
+ walk(c.add[0], w);
901
+ walk(c.add[1], w);
902
+ } else {
903
+ this.s.structure.push('c0');
904
+ }
905
+ };
906
+ assignments.push('bgClosureP = 0.0;', 'bgClosureE = vec3(0.0);', 'bgClosureT = 0.0;');
907
+ walk(closure, '1.0');
908
+ }
909
+ const ordered: string[] = [];
910
+ const seen = new Set<string>();
911
+ const visit = (file: string) => {
912
+ if (seen.has(file)) return;
913
+ seen.add(file);
914
+ for (const dep of BLENDER_NODE_GLSL[file]!.deps) visit(dep);
915
+ ordered.push(file);
916
+ };
917
+ for (const file of this.s.files) visit(file);
918
+ const graphBody = `void blenderGraph() {\n blender_init_globals();\n ${[...this.lines, ...assignments].join('\n ')}\n}`;
919
+ const declarations = [
920
+ BLENDER_NODE_GLSL_PRELUDE,
921
+ shakeLibrary(EEVEE_GLOBALS + ordered.map(f => BLENDER_NODE_GLSL[f]!.code).join('\n') + '\n' + COORDINATES,
922
+ [...this.s.functions, graphBody].join('\n')),
923
+ ...[...this.s.uniformTypes].map(([n, t]) => `uniform ${t} ${n};`),
924
+ ...this.images.map(i => i.tiled
925
+ ? `uniform highp sampler2DArray ${i.uniform};\nuniform highp sampler2D ${i.uniform}Map;`
926
+ : `uniform sampler2D ${i.uniform};`),
927
+ ...this.ramps.map(r => `uniform sampler2D ${r.uniform};`),
928
+ ...Object.values(outputs).map(o => `${o.type} ${o.global};`),
929
+ ...(closure ? ['float bgClosureP;', 'vec3 bgClosureE;', 'float bgClosureT;'] : []),
930
+ ...this.s.functions,
931
+ graphBody,
932
+ ].join('\n');
933
+ return {
934
+ key: JSON.stringify([this.graph.surface, this.s.structure, [...this.s.uvs].sort()]),
935
+ declarations,
936
+ uniforms: this.uniforms,
937
+ images: this.images,
938
+ ramps: this.ramps,
939
+ uvs: [...this.s.uvs],
940
+ attributes: [...this.s.attributes],
941
+ outputs,
942
+ surface: this.graph.surface,
943
+ closure: closure !== undefined,
944
+ };
945
+ }
946
+ }
947
+
948
+ /**
949
+ * ONLY THE FUNCTIONS THE GRAPH REACHES, as Blender's codegen links only the
950
+ * functions a material calls. A node's file holds every variant of it (each
951
+ * Noise type and dimension, each Voronoi feature), and a program carrying all
952
+ * of them measured ~155 KB of GLSL for a 30-line graph -- compile time the
953
+ * viewport spent blocked. Everything that is not a plain function definition
954
+ * (macros, which may generate or call functions; structs; constants) is kept,
955
+ * and a function is kept when its name appears in kept text, to a fixpoint.
956
+ * Overloads of a kept name are all kept.
957
+ */
958
+ function shakeLibrary(library: string, root: string): string {
959
+ type Chunk = {text: string; name: string | null};
960
+ const chunks: Chunk[] = [];
961
+ const header = /^[A-Za-z_][\w ]*?\b([A-Za-z_]\w*)\s*\(/gm;
962
+ let from = 0;
963
+ for (let m; (m = header.exec(library)); ) {
964
+ if (m.index < from) continue;
965
+ let i = m.index + m[0].length;
966
+ for (let depth = 1; i < library.length && depth > 0; i++) depth += library[i] === '(' ? 1 : library[i] === ')' ? -1 : 0;
967
+ const after = library.slice(i).match(/^\s*(\{|;)/);
968
+ // Only a definition or prototype at top level is a function chunk; a
969
+ // macro invocation line (`NOISE_FBM(float)`) is kept as other text.
970
+ if (!after || /^\s*#/.test(library.slice(library.lastIndexOf('\n', m.index) + 1, m.index + 1))) continue;
971
+ const prev = library.slice(Math.max(0, m.index - 2), m.index);
972
+ if (prev.endsWith('\\\n')) continue;
973
+ let end = i + after[0].length;
974
+ if (after[1] === '{') {
975
+ for (let depth = 1; end < library.length && depth > 0; end++)
976
+ depth += library[end] === '{' ? 1 : library[end] === '}' ? -1 : 0;
977
+ }
978
+ chunks.push({text: library.slice(from, m.index), name: null});
979
+ chunks.push({text: library.slice(m.index, end), name: m[1]!});
980
+ from = end;
981
+ header.lastIndex = end;
982
+ }
983
+ chunks.push({text: library.slice(from), name: null});
984
+ const keep = new Set<Chunk>(chunks.filter(c => c.name === null));
985
+ const referenced = new Set<string>();
986
+ const scan = (text: string) => { for (const w of text.matchAll(/[A-Za-z_]\w*/g)) referenced.add(w[0]); };
987
+ scan(root);
988
+ for (const c of keep) scan(c.text);
989
+ for (let grew = true; grew; ) {
990
+ grew = false;
991
+ for (const c of chunks) {
992
+ if (keep.has(c) || c.name === null || !referenced.has(c.name)) continue;
993
+ keep.add(c);
994
+ scan(c.text);
995
+ grew = true;
996
+ }
997
+ }
998
+ return chunks.filter(c => keep.has(c)).map(c => c.text).join('');
999
+ }
1000
+
1001
+ /** The varying a UV layer arrives in; `''` is the render UV. */
1002
+ /** The varying a graph reads Blender attribute `name` through. */
1003
+ export function attributeVarying(name: string): string {
1004
+ return `v${graphAttributeName(name)}`;
1005
+ }
1006
+
1007
+ export function uvVarying(name: string): string {
1008
+ return name === '' ? 'vBlenderUvRender' : `vBlenderUv_${[...name].map(c => c.charCodeAt(0).toString(16)).join('')}`;
1009
+ }
1010
+
1011
+ export function compileMaterialGraph(graph: MaterialGraph): CompiledGraph {
1012
+ return new Compiler(graph).compile();
1013
+ }
1014
+
1015
+ /** A ramp's 257 samples as the texture its uniform reads. */
1016
+ export function rampTexture(table: readonly (readonly number[])[]): THREE.DataTexture {
1017
+ // Half floats: linearly filterable on every WebGL2 device, which 32-bit
1018
+ // floats are not (OES_texture_float_linear).
1019
+ const data = new Uint16Array(table.length * 4);
1020
+ table.forEach((c, i) => data.set([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0, c[3] ?? 1].map(v => THREE.DataUtils.toHalfFloat(v)), i * 4));
1021
+ const texture = new THREE.DataTexture(data, table.length, 1, THREE.RGBAFormat, THREE.HalfFloatType);
1022
+ texture.minFilter = texture.magFilter = THREE.LinearFilter;
1023
+ texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping;
1024
+ texture.needsUpdate = true;
1025
+ return texture;
1026
+ }