reze-engine 0.17.0 → 0.18.0

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.
Files changed (66) hide show
  1. package/README.md +252 -250
  2. package/dist/engine.d.ts +36 -1
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +258 -15
  5. package/dist/gpu-profile.d.ts +19 -0
  6. package/dist/gpu-profile.d.ts.map +1 -0
  7. package/dist/gpu-profile.js +120 -0
  8. package/dist/graph/compile.d.ts +34 -0
  9. package/dist/graph/compile.d.ts.map +1 -0
  10. package/dist/graph/compile.js +299 -0
  11. package/dist/graph/presets/body.d.ts +3 -0
  12. package/dist/graph/presets/body.d.ts.map +1 -0
  13. package/dist/graph/presets/body.js +100 -0
  14. package/dist/graph/presets/cloth_rough.d.ts +3 -0
  15. package/dist/graph/presets/cloth_rough.d.ts.map +1 -0
  16. package/dist/graph/presets/cloth_rough.js +61 -0
  17. package/dist/graph/presets/cloth_smooth.d.ts +3 -0
  18. package/dist/graph/presets/cloth_smooth.d.ts.map +1 -0
  19. package/dist/graph/presets/cloth_smooth.js +53 -0
  20. package/dist/graph/presets/default.d.ts +3 -0
  21. package/dist/graph/presets/default.d.ts.map +1 -0
  22. package/dist/graph/presets/default.js +20 -0
  23. package/dist/graph/presets/hair.d.ts +3 -0
  24. package/dist/graph/presets/hair.d.ts.map +1 -0
  25. package/dist/graph/presets/hair.js +79 -0
  26. package/dist/graph/presets/metal.d.ts +3 -0
  27. package/dist/graph/presets/metal.d.ts.map +1 -0
  28. package/dist/graph/presets/metal.js +58 -0
  29. package/dist/graph/presets/stockings.d.ts +3 -0
  30. package/dist/graph/presets/stockings.d.ts.map +1 -0
  31. package/dist/graph/presets/stockings.js +54 -0
  32. package/dist/graph/registry.d.ts +28 -0
  33. package/dist/graph/registry.d.ts.map +1 -0
  34. package/dist/graph/registry.js +322 -0
  35. package/dist/graph/schema.d.ts +66 -0
  36. package/dist/graph/schema.d.ts.map +1 -0
  37. package/dist/graph/schema.js +6 -0
  38. package/dist/graph/slots.d.ts +14 -0
  39. package/dist/graph/slots.d.ts.map +1 -0
  40. package/dist/graph/slots.js +123 -0
  41. package/dist/index.d.ts +11 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +9 -0
  44. package/dist/physics/profile.d.ts +18 -0
  45. package/dist/physics/profile.d.ts.map +1 -0
  46. package/dist/physics/profile.js +44 -0
  47. package/package.json +43 -42
  48. package/src/engine.ts +301 -20
  49. package/src/graph/compile.ts +342 -0
  50. package/src/graph/presets/body.ts +103 -0
  51. package/src/graph/presets/cloth_rough.ts +64 -0
  52. package/src/graph/presets/cloth_smooth.ts +56 -0
  53. package/src/graph/presets/default.ts +23 -0
  54. package/src/graph/presets/hair.ts +82 -0
  55. package/src/graph/presets/metal.ts +61 -0
  56. package/src/graph/presets/stockings.ts +57 -0
  57. package/src/graph/registry.ts +351 -0
  58. package/src/graph/schema.ts +60 -0
  59. package/src/graph/slots.ts +145 -0
  60. package/src/index.ts +54 -28
  61. package/dist/physics-debug.d.ts +0 -30
  62. package/dist/physics-debug.d.ts.map +0 -1
  63. package/dist/physics-debug.js +0 -526
  64. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  65. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  66. package/dist/shaders/passes/physics-debug.js +0 -69
@@ -0,0 +1,82 @@
1
+ // M_Hair — 仿深空之眼渲染预设v1.0_by_小绿毛猫, as a StyleGraph. Node ids mirror the
2
+ // Blender node names referenced in shaders/materials/hair.ts comments; compiled with
3
+ // { inlineParams: true } this graph must reproduce HAIR_SHADER_WGSL's fragment body
4
+ // (golden test #1 — see tests/graph.test.mjs).
5
+ //
6
+ // Inherited authoring decisions from the hand port: the noise→bump subtree on
7
+ // Principled.Normal is omitted (imperceptible at 0.2 mix weight), and Blender's bevel
8
+ // node is approximated by saturate(normal.y) — Blender Z-up ⇒ engine Y-up.
9
+ //
10
+ // The object is pure JSON (no functions/undefined) — JSON.stringify round-trips it,
11
+ // which is how reze.design will ship additional presets.
12
+
13
+ import type { StyleGraph } from "../schema"
14
+
15
+ export const HAIR_GRAPH: StyleGraph = {
16
+ version: 1,
17
+ name: "Hair",
18
+ slot: "hair",
19
+ nodes: [
20
+ { id: "tex", type: "texture" },
21
+ { id: "geo", type: "geometry" },
22
+ { id: "hs_shadow", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.2, value: 0.5, fac: 1.0 } },
23
+ { id: "hs_002", type: "hue_sat", inputs: { hue: 0.48, saturation: 1.2, value: 0.7, fac: 1.0 } },
24
+ { id: "hs_001", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.5, value: 1.0, fac: 1.0 } },
25
+ { id: "str", type: "shader_to_rgb_diffuse" },
26
+ { id: "ramp_008", type: "ramp_constant", inputs: { pos0: 0.0, color0: [0, 0, 0, 1], pos1: 0.2966, color1: [1, 1, 1, 1] } },
27
+ { id: "mix_004", type: "mix/blend" },
28
+ { id: "bc", type: "bright_contrast", inputs: { bright: 0.1, contrast: 0.2 } },
29
+ { id: "sep_n", type: "separate_xyz" },
30
+ { id: "bevel_clamp", type: "math/clamp01" },
31
+ { id: "mix_003", type: "mix/blend" },
32
+ { id: "fres", type: "fresnel", inputs: { ior: 1.45 } },
33
+ { id: "lw", type: "layer_weight/fresnel", inputs: { blend: 0.61 } },
34
+ { id: "rim_mul", type: "math/multiply" },
35
+ { id: "rim_pow", type: "math/power", inputs: { b: 0.6300000548362732 } },
36
+ {
37
+ id: "mix_shader_002",
38
+ type: "mix_shader",
39
+ inputs: { b: [0.1673291176557541, 0.1673291176557541, 0.1673291176557541] },
40
+ },
41
+ { id: "gate", type: "math/greater_than", inputs: { b: 0.15000000596046448 } },
42
+ { id: "gate_scale", type: "math/multiply", inputs: { b: 0.1 } },
43
+ { id: "npr_add", type: "mix/add_emit" },
44
+ {
45
+ id: "principled",
46
+ type: "principled",
47
+ inputs: { metallic: 0.0, specular: 1.0, roughness: 0.3, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
48
+ },
49
+ { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.2 } },
50
+ ],
51
+ links: [
52
+ { from: { node: "tex", socket: "color" }, to: { node: "hs_shadow", socket: "color" } },
53
+ { from: { node: "hs_shadow", socket: "color" }, to: { node: "hs_002", socket: "color" } },
54
+ { from: { node: "tex", socket: "color" }, to: { node: "hs_001", socket: "color" } },
55
+ { from: { node: "str", socket: "value" }, to: { node: "ramp_008", socket: "fac" } },
56
+ { from: { node: "ramp_008", socket: "fac_out" }, to: { node: "mix_004", socket: "fac" } },
57
+ { from: { node: "hs_002", socket: "color" }, to: { node: "mix_004", socket: "a" } },
58
+ { from: { node: "hs_001", socket: "color" }, to: { node: "mix_004", socket: "b" } },
59
+ { from: { node: "mix_004", socket: "color" }, to: { node: "bc", socket: "color" } },
60
+ { from: { node: "geo", socket: "normal" }, to: { node: "sep_n", socket: "vector" } },
61
+ { from: { node: "sep_n", socket: "y" }, to: { node: "bevel_clamp", socket: "a" } },
62
+ { from: { node: "bevel_clamp", socket: "value" }, to: { node: "mix_003", socket: "fac" } },
63
+ { from: { node: "bc", socket: "color" }, to: { node: "mix_003", socket: "a" } },
64
+ { from: { node: "hs_002", socket: "color" }, to: { node: "mix_003", socket: "b" } },
65
+ { from: { node: "fres", socket: "value" }, to: { node: "rim_mul", socket: "a" } },
66
+ { from: { node: "lw", socket: "value" }, to: { node: "rim_mul", socket: "b" } },
67
+ { from: { node: "rim_mul", socket: "value" }, to: { node: "rim_pow", socket: "a" } },
68
+ { from: { node: "mix_003", socket: "color" }, to: { node: "mix_shader_002", socket: "a" } },
69
+ { from: { node: "rim_pow", socket: "value" }, to: { node: "mix_shader_002", socket: "fac" } },
70
+ { from: { node: "tex", socket: "color" }, to: { node: "gate", socket: "a" } },
71
+ { from: { node: "gate", socket: "value" }, to: { node: "gate_scale", socket: "a" } },
72
+ { from: { node: "mix_shader_002", socket: "color" }, to: { node: "npr_add", socket: "a" } },
73
+ { from: { node: "gate_scale", socket: "value" }, to: { node: "npr_add", socket: "b" } },
74
+ { from: { node: "bc", socket: "color" }, to: { node: "principled", socket: "base" } },
75
+ { from: { node: "npr_add", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
76
+ { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
77
+ ],
78
+ output: { node: "mix_shader_001", socket: "color" },
79
+ // Adjust-tier params deliberately absent: which sockets deserve sliders (and what
80
+ // they're called) is preset-author curation done in reze.design, not engine data.
81
+ // The exposed-param mechanism itself is covered by tests/graph.test.mjs.
82
+ }
@@ -0,0 +1,61 @@
1
+ // M_Metal as a StyleGraph — port of shaders/materials/metal.ts.
2
+ // Metallic Principled (Metallic=1, Specular=1, Roughness=0.3) with a
3
+ // reflection-coord voronoi driving base color (metallic sparkle), plus an NPR
4
+ // toon/overlay emission stack mixed at Fac=0.6967. The voronoi Color→ramp Fac
5
+ // link goes through Blender's implicit BT.601 conversion (color_to_value).
6
+
7
+ import type { StyleGraph } from "../schema"
8
+
9
+ export const METAL_GRAPH: StyleGraph = {
10
+ version: 1,
11
+ name: "Metal",
12
+ slot: "metal",
13
+ nodes: [
14
+ { id: "tex", type: "texture" },
15
+ { id: "geo", type: "geometry" },
16
+ { id: "tex_tint", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.800000011920929, fac: 1.0 } },
17
+ { id: "str", type: "shader_to_rgb_diffuse" },
18
+ { id: "ramp_008", type: "ramp_constant_aa", inputs: { edge: 0.2966 } },
19
+ { id: "mix04_fac", type: "math/multiply", inputs: { b: 0.5 } },
20
+ { id: "dark_tex", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.19999998807907104, fac: 1.0 } },
21
+ { id: "mix_004", type: "mix/blend" },
22
+ { id: "hue_004", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 2.0, fac: 1.0 } },
23
+ { id: "npr_overlay", type: "mix/overlay", inputs: { fac: 1.0 } },
24
+ { id: "npr_emit", type: "emission", inputs: { strength: 8.100000381469727 } },
25
+ { id: "voro_cross", type: "vect_cross", inputs: { b: [0, 1, 0] } },
26
+ { id: "voro", type: "tex_voronoi/color", inputs: { scale: 4.3 } },
27
+ { id: "voro_ramp", type: "ramp_linear", inputs: { pos0: 0.0, pos1: 1.0 } },
28
+ { id: "hue_006", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.5, value: 1.2999999523162842, fac: 1.0 } },
29
+ { id: "albedo", type: "mix/blend" },
30
+ {
31
+ id: "principled",
32
+ type: "principled",
33
+ inputs: { metallic: 1.0, specular: 1.0, roughness: 0.3, spec_clamp: 1e30, sheen: 0.0, sheen_tint: 0.0 },
34
+ },
35
+ { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.6967 } },
36
+ ],
37
+ links: [
38
+ { from: { node: "tex", socket: "color" }, to: { node: "tex_tint", socket: "color" } },
39
+ { from: { node: "str", socket: "value" }, to: { node: "ramp_008", socket: "fac" } },
40
+ { from: { node: "ramp_008", socket: "fac_out" }, to: { node: "mix04_fac", socket: "a" } },
41
+ { from: { node: "tex_tint", socket: "color" }, to: { node: "dark_tex", socket: "color" } },
42
+ { from: { node: "mix04_fac", socket: "value" }, to: { node: "mix_004", socket: "fac" } },
43
+ { from: { node: "dark_tex", socket: "color" }, to: { node: "mix_004", socket: "a" } },
44
+ { from: { node: "tex_tint", socket: "color" }, to: { node: "mix_004", socket: "b" } },
45
+ { from: { node: "mix_004", socket: "color" }, to: { node: "hue_004", socket: "color" } },
46
+ { from: { node: "mix_004", socket: "color" }, to: { node: "npr_overlay", socket: "a" } },
47
+ { from: { node: "hue_004", socket: "color" }, to: { node: "npr_overlay", socket: "b" } },
48
+ { from: { node: "npr_overlay", socket: "color" }, to: { node: "npr_emit", socket: "color" } },
49
+ { from: { node: "geo", socket: "reflection" }, to: { node: "voro_cross", socket: "a" } },
50
+ { from: { node: "voro_cross", socket: "vector" }, to: { node: "voro", socket: "vector" } },
51
+ { from: { node: "voro", socket: "color" }, to: { node: "voro_ramp", socket: "fac" } },
52
+ { from: { node: "tex_tint", socket: "color" }, to: { node: "hue_006", socket: "color" } },
53
+ { from: { node: "voro_ramp", socket: "fac_out" }, to: { node: "albedo", socket: "fac" } },
54
+ { from: { node: "voro_ramp", socket: "fac_out" }, to: { node: "albedo", socket: "a" } },
55
+ { from: { node: "hue_006", socket: "color" }, to: { node: "albedo", socket: "b" } },
56
+ { from: { node: "albedo", socket: "color" }, to: { node: "principled", socket: "base" } },
57
+ { from: { node: "npr_emit", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
58
+ { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
59
+ ],
60
+ output: { node: "mix_shader_001", socket: "color" },
61
+ }
@@ -0,0 +1,57 @@
1
+ // M_Stockings as a StyleGraph — port of shaders/materials/stockings.ts.
2
+ // A bbox-gradient × facing-rim mask drives a Mix Shader between an HSV-boosted
3
+ // emission (val 5×) and a sheen Principled. The hashed-alpha discard and the
4
+ // alpha=1 output are slot-owned (see STOCKINGS_TEMPLATE in slots.ts) — the graph
5
+ // computes only the radiance. Blender's Generated coord is approximated with UV,
6
+ // as in the hand port. The grayscale mask feeds Mix Shader Fac through Blender's
7
+ // implicit BT.601 color→float conversion (equal-component vector → same scalar).
8
+
9
+ import type { StyleGraph } from "../schema"
10
+
11
+ export const STOCKINGS_GRAPH: StyleGraph = {
12
+ version: 1,
13
+ name: "Stockings",
14
+ slot: "stockings",
15
+ nodes: [
16
+ { id: "tex", type: "texture" },
17
+ { id: "geo", type: "geometry" },
18
+ { id: "map", type: "mapping", inputs: { loc: [1.0, 1.0, 1.0], rot: [0.0, 1.5708, 1.5708] } },
19
+ { id: "grad", type: "tex_gradient" },
20
+ { id: "ramp_001", type: "ramp_tri" },
21
+ { id: "ramp_002", type: "ramp_cardinal", inputs: { pos0: 0.0, pos1: 0.9565 } },
22
+ { id: "facing", type: "layer_weight/facing", inputs: { blend: 0.4 } },
23
+ { id: "ramp_face", type: "ramp_cardinal", inputs: { pos0: 0.0, pos1: 0.5435 } },
24
+ { id: "mix_001", type: "mix/blend", inputs: { fac: 0.5, a: [1.0, 1.0, 1.0] } },
25
+ { id: "mask", type: "mix/lighten", inputs: { fac: 0.5 } },
26
+ { id: "emission_hs", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 5.0, fac: 1.0 } },
27
+ {
28
+ id: "principled",
29
+ type: "principled",
30
+ inputs: {
31
+ metallic: 0.1,
32
+ specular: 1.0,
33
+ roughness: 0.5,
34
+ spec_clamp: 1e30,
35
+ sheen: 0.7017999887466431,
36
+ sheen_tint: 0.5,
37
+ },
38
+ },
39
+ { id: "mix_shader_001", type: "mix_shader" },
40
+ ],
41
+ links: [
42
+ { from: { node: "geo", socket: "uv" }, to: { node: "map", socket: "vector" } },
43
+ { from: { node: "map", socket: "vector" }, to: { node: "grad", socket: "vector" } },
44
+ { from: { node: "grad", socket: "value" }, to: { node: "ramp_001", socket: "fac" } },
45
+ { from: { node: "ramp_001", socket: "value" }, to: { node: "ramp_002", socket: "fac" } },
46
+ { from: { node: "facing", socket: "value" }, to: { node: "ramp_face", socket: "fac" } },
47
+ { from: { node: "ramp_face", socket: "fac_out" }, to: { node: "mix_001", socket: "b" } },
48
+ { from: { node: "mix_001", socket: "color" }, to: { node: "mask", socket: "a" } },
49
+ { from: { node: "ramp_002", socket: "fac_out" }, to: { node: "mask", socket: "b" } },
50
+ { from: { node: "tex", socket: "color" }, to: { node: "emission_hs", socket: "color" } },
51
+ { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base" } },
52
+ { from: { node: "emission_hs", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
53
+ { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
54
+ { from: { node: "mask", socket: "color" }, to: { node: "mix_shader_001", socket: "fac" } },
55
+ ],
56
+ output: { node: "mix_shader_001", socket: "color" },
57
+ }
@@ -0,0 +1,351 @@
1
+ // Node registry: one entry per Blender-equivalent node backed by NODES_WGSL (nodes.ts).
2
+ // The registry adds no WGSL of its own — a node type exists here only if its function
3
+ // already exists (validated against EEVEE) in the shader library, or is a WGSL builtin.
4
+ // Semantics are frozen Blender 3.6 legacy-EEVEE; enum modes (math op, mix blend type,
5
+ // ramp interpolation) are part of the type string so they are unambiguously topology.
6
+
7
+ import type { SocketValue } from "./schema"
8
+
9
+ export type SockT = "float" | "color" | "vector" | "vec4"
10
+
11
+ export type InputSpec = {
12
+ type: SockT
13
+ default?: SocketValue
14
+ /** Socket is meaningless as a literal (e.g. the color being processed) — must be linked. */
15
+ requiresLink?: boolean
16
+ /** Unlinked fallback is a template local, not a literal (e.g. principled.normal → n). */
17
+ contextDefault?: string
18
+ }
19
+
20
+ export type NodeSpec = {
21
+ inputs: Record<string, InputSpec>
22
+ outputs: Record<string, SockT>
23
+ /** RHS expression for this node's `let`, from resolved arg expressions keyed by socket. */
24
+ emit?: (args: Record<string, string>) => string
25
+ /** Context nodes tap template locals directly — no `let` emitted. */
26
+ contextOutputs?: Record<string, string>
27
+ /** Swizzle applied to the node's variable per output socket (default: none). */
28
+ outputSelect?: Record<string, string>
29
+ }
30
+
31
+ // ─── Literal formatting ───────────────────────────────────────────────
32
+ // Deterministic: same graph JSON → byte-identical WGSL. String(x) is JS shortest
33
+ // round-trip, so full-precision Blender constants (0.15000000596046448) survive.
34
+
35
+ export function fmtFloat(x: number): string {
36
+ if (!Number.isFinite(x)) throw new Error(`non-finite literal: ${x}`)
37
+ const s = String(x)
38
+ return /[.e]/.test(s) ? s : s + ".0"
39
+ }
40
+
41
+ export function fmtValue(value: SocketValue, type: SockT): string {
42
+ if (typeof value === "number") {
43
+ if (type === "float") return fmtFloat(value)
44
+ if (type === "color" || type === "vector") return `vec3f(${fmtFloat(value)})`
45
+ return `vec4f(vec3f(${fmtFloat(value)}), 1.0)`
46
+ }
47
+ if (value.length === 3) {
48
+ const [x, y, z] = value
49
+ if (type === "vec4") return `vec4f(${fmtFloat(x)}, ${fmtFloat(y)}, ${fmtFloat(z)}, 1.0)`
50
+ if (type === "float") throw new Error(`vector literal on float socket`)
51
+ // All-equal shorthand matches the hand-written shaders (vec3f(0.167…)).
52
+ if (x === y && y === z) return `vec3f(${fmtFloat(x)})`
53
+ return `vec3f(${fmtFloat(x)}, ${fmtFloat(y)}, ${fmtFloat(z)})`
54
+ }
55
+ if (type !== "vec4") throw new Error(`vec4 literal on ${type} socket`)
56
+ return `vec4f(${value.map(fmtFloat).join(", ")})`
57
+ }
58
+
59
+ /** Does a literal's shape fit a socket type? (Scalar splats onto color/vector/vec4.) */
60
+ export function literalFits(value: SocketValue, type: SockT): boolean {
61
+ if (typeof value === "number") return true
62
+ if (value.length === 3) return type !== "float"
63
+ return type === "vec4"
64
+ }
65
+
66
+ // ─── Implicit socket conversions (Blender-faithful) ──────────────────
67
+ // vec4 appears only on ramp stop-color literals — never linkable, so conversions
68
+ // cover float/color/vector. vector→float is NOT implicit in Blender; rejected.
69
+
70
+ export function canConvert(from: SockT, to: SockT): boolean {
71
+ if (from === to) return true
72
+ if (from === "color" && to === "float") return true // BT.601 via color_to_value
73
+ if (from === "float" && (to === "color" || to === "vector")) return true
74
+ if ((from === "color" && to === "vector") || (from === "vector" && to === "color")) return true
75
+ return false
76
+ }
77
+
78
+ export function convert(from: SockT, to: SockT, expr: string): string {
79
+ if (from === to) return expr
80
+ if (from === "color" && to === "float") return `color_to_value(${expr})`
81
+ if (from === "float") return `vec3f(${expr})`
82
+ return expr // color ↔ vector: bit-identical vec3f pass-through
83
+ }
84
+
85
+ // ─── Registry ─────────────────────────────────────────────────────────
86
+
87
+ const F = (d: number, requiresLink = false): InputSpec => ({ type: "float", default: d, requiresLink })
88
+ const C = (d: [number, number, number] = [1, 1, 1], requiresLink = false): InputSpec => ({
89
+ type: "color",
90
+ default: d,
91
+ requiresLink,
92
+ })
93
+ const V = (d: [number, number, number] = [0, 0, 0], requiresLink = false): InputSpec => ({
94
+ type: "vector",
95
+ default: d,
96
+ requiresLink,
97
+ })
98
+ const V4 = (d: [number, number, number, number]): InputSpec => ({ type: "vec4", default: d })
99
+
100
+ const RAMP_INPUTS: Record<string, InputSpec> = {
101
+ fac: F(0.5, true),
102
+ pos0: F(0),
103
+ color0: V4([0, 0, 0, 1]),
104
+ pos1: F(1),
105
+ color1: V4([1, 1, 1, 1]),
106
+ }
107
+ const RAMP_OUTPUTS: Record<string, SockT> = { color: "color", alpha: "float", fac_out: "float" }
108
+ // fac_out (.r) matches how a grayscale ramp feeds a scalar consumer in the hand ports —
109
+ // routing through the BT.601 color→float conversion instead would change the value.
110
+ const RAMP_SELECT = { color: ".rgb", alpha: ".a", fac_out: ".r" }
111
+
112
+ export const NODE_REGISTRY: Record<string, NodeSpec> = {
113
+ // ── Context inputs (template locals; no emission) ──
114
+ texture: {
115
+ inputs: {},
116
+ outputs: { color: "color", alpha: "float" },
117
+ contextOutputs: { color: "tex_color", alpha: "tex_s.a" },
118
+ },
119
+ geometry: {
120
+ inputs: {},
121
+ outputs: {
122
+ normal: "vector",
123
+ view: "vector",
124
+ world_pos: "vector",
125
+ rest_pos: "vector",
126
+ uv: "vector",
127
+ // Blender Texture Coordinate → Reflection (view ray mirrored on the normal);
128
+ // drives env-tracking patterns like metal's voronoi sparkle.
129
+ reflection: "vector",
130
+ },
131
+ contextOutputs: {
132
+ normal: "n",
133
+ view: "v",
134
+ world_pos: "input.worldPos",
135
+ rest_pos: "input.restPos",
136
+ uv: "vec3f(input.uv, 0.0)",
137
+ reflection: "reflect(-v, n)",
138
+ },
139
+ },
140
+
141
+ // ── Literals as nodes (for editor ergonomics; inlined literals work too) ──
142
+ value: { inputs: { value: F(0) }, outputs: { value: "float" }, emit: (a) => a.value },
143
+ rgb: { inputs: { color: C() }, outputs: { color: "color" }, emit: (a) => a.color },
144
+
145
+ // ── Color ──
146
+ hue_sat: {
147
+ inputs: { hue: F(0.5), saturation: F(1), value: F(1), fac: F(1), color: C([1, 1, 1], true) },
148
+ outputs: { color: "color" },
149
+ emit: (a) => `hue_sat(${a.hue}, ${a.saturation}, ${a.value}, ${a.fac}, ${a.color})`,
150
+ },
151
+ bright_contrast: {
152
+ inputs: { color: C([1, 1, 1], true), bright: F(0), contrast: F(0) },
153
+ outputs: { color: "color" },
154
+ emit: (a) => `bright_contrast(${a.color}, ${a.bright}, ${a.contrast})`,
155
+ },
156
+ invert: {
157
+ inputs: { fac: F(1), color: C([1, 1, 1], true) },
158
+ outputs: { color: "color" },
159
+ emit: (a) => `invert(${a.fac}, ${a.color})`,
160
+ },
161
+ ramp_constant: {
162
+ inputs: RAMP_INPUTS,
163
+ outputs: RAMP_OUTPUTS,
164
+ outputSelect: RAMP_SELECT,
165
+ emit: (a) => `ramp_constant(${a.fac}, ${a.pos0}, ${a.color0}, ${a.pos1}, ${a.color1})`,
166
+ },
167
+ ramp_linear: {
168
+ inputs: RAMP_INPUTS,
169
+ outputs: RAMP_OUTPUTS,
170
+ outputSelect: RAMP_SELECT,
171
+ emit: (a) => `ramp_linear(${a.fac}, ${a.pos0}, ${a.color0}, ${a.pos1}, ${a.color1})`,
172
+ },
173
+ ramp_cardinal: {
174
+ inputs: RAMP_INPUTS,
175
+ outputs: RAMP_OUTPUTS,
176
+ outputSelect: RAMP_SELECT,
177
+ emit: (a) => `ramp_cardinal(${a.fac}, ${a.pos0}, ${a.color0}, ${a.pos1}, ${a.color1})`,
178
+ },
179
+ ramp_constant_aa: {
180
+ inputs: { fac: F(0.5, true), edge: F(0.5), color0: V4([0, 0, 0, 1]), color1: V4([1, 1, 1, 1]) },
181
+ outputs: RAMP_OUTPUTS,
182
+ outputSelect: RAMP_SELECT,
183
+ emit: (a) => `ramp_constant_edge_aa(${a.fac}, ${a.edge}, ${a.color0}, ${a.color1})`,
184
+ },
185
+ // Blender ColorRamp LINEAR with 3 stops black→white→black (triangular peak at 0.5).
186
+ // The only >2-stop ramp the presets use; folded to closed form like the hand port.
187
+ ramp_tri: {
188
+ inputs: { fac: F(0.5, true) },
189
+ outputs: { value: "float" },
190
+ emit: (a) => `1.0 - abs(2.0 * ${a.fac} - 1.0)`,
191
+ },
192
+
193
+ // ── Math (enum op in type string) ──
194
+ "math/add": { inputs: { a: F(0), b: F(0) }, outputs: { value: "float" }, emit: (a) => `math_add(${a.a}, ${a.b})` },
195
+ "math/multiply": {
196
+ inputs: { a: F(0), b: F(0) },
197
+ outputs: { value: "float" },
198
+ emit: (a) => `math_multiply(${a.a}, ${a.b})`,
199
+ },
200
+ "math/power": {
201
+ inputs: { a: F(0), b: F(1) },
202
+ outputs: { value: "float" },
203
+ emit: (a) => `math_power(${a.a}, ${a.b})`,
204
+ },
205
+ "math/greater_than": {
206
+ inputs: { a: F(0), b: F(0.5) },
207
+ outputs: { value: "float" },
208
+ emit: (a) => `math_greater_than(${a.a}, ${a.b})`,
209
+ },
210
+ "math/clamp01": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `saturate(${a.a})` },
211
+
212
+ // ── Mix (blend type in type string) ──
213
+ "mix/blend": {
214
+ inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() },
215
+ outputs: { color: "color" },
216
+ emit: (a) => `mix_blend(${a.fac}, ${a.a}, ${a.b})`,
217
+ },
218
+ "mix/overlay": {
219
+ inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() },
220
+ outputs: { color: "color" },
221
+ emit: (a) => `mix_overlay(${a.fac}, ${a.a}, ${a.b})`,
222
+ },
223
+ "mix/multiply": {
224
+ inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() },
225
+ outputs: { color: "color" },
226
+ emit: (a) => `mix_multiply(${a.fac}, ${a.a}, ${a.b})`,
227
+ },
228
+ "mix/lighten": {
229
+ inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() },
230
+ outputs: { color: "color" },
231
+ emit: (a) => `mix_lighten(${a.fac}, ${a.a}, ${a.b})`,
232
+ },
233
+ "mix/linear_light": {
234
+ inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() },
235
+ outputs: { color: "color" },
236
+ emit: (a) => `mix_linear_light(${a.fac}, ${a.a}, ${a.b})`,
237
+ },
238
+ // Emission-add: color + scalar-gated emission. Stand-in for Blender's
239
+ // Emission → Add Shader pair in the ShaderToRGB era (see hair's bright-tex gate).
240
+ "mix/add_emit": {
241
+ inputs: { a: C([0, 0, 0], true), b: F(0) },
242
+ outputs: { color: "color" },
243
+ emit: (a) => `${a.a} + vec3f(${a.b})`,
244
+ },
245
+ // Blender Emission shader: color × strength. In the ShaderToRGB-era ports the
246
+ // emission result feeds a Mix Shader directly as radiance. Color may be a literal
247
+ // (body's rim tints) or linked.
248
+ emission: {
249
+ inputs: { color: C([1, 1, 1]), strength: F(1) },
250
+ outputs: { color: "color" },
251
+ emit: (a) => `${a.color} * ${a.strength}`,
252
+ },
253
+ // Blender Add Shader — radiance sum of two evaluated shading results.
254
+ add_shader: {
255
+ inputs: { a: C([0, 0, 0], true), b: C([0, 0, 0], true) },
256
+ outputs: { color: "color" },
257
+ emit: (a) => `${a.a} + ${a.b}`,
258
+ },
259
+ // Mix Shader — plain lerp between two evaluated shading results.
260
+ mix_shader: {
261
+ inputs: { fac: F(0.5), a: C([0, 0, 0], true), b: C([0, 0, 0]) },
262
+ outputs: { color: "color" },
263
+ emit: (a) => `mix(${a.a}, ${a.b}, ${a.fac})`,
264
+ },
265
+
266
+ // ── View-dependent scalars ──
267
+ fresnel: { inputs: { ior: F(1.45) }, outputs: { value: "float" }, emit: (a) => `fresnel(${a.ior}, n, v)` },
268
+ "layer_weight/fresnel": {
269
+ inputs: { blend: F(0.5) },
270
+ outputs: { value: "float" },
271
+ emit: (a) => `layer_weight_fresnel(${a.blend}, n, v)`,
272
+ },
273
+ "layer_weight/facing": {
274
+ inputs: { blend: F(0.5) },
275
+ outputs: { value: "float" },
276
+ emit: (a) => `layer_weight_facing(${a.blend}, n, v)`,
277
+ },
278
+
279
+ // ── Lighting capture ──
280
+ shader_to_rgb_diffuse: {
281
+ inputs: {},
282
+ outputs: { value: "float" },
283
+ emit: () => `shader_to_rgb_diffuse(n, l, sun, amb, shadow)`,
284
+ },
285
+
286
+ // ── Vector ──
287
+ separate_xyz: {
288
+ inputs: { vector: V([0, 0, 0], true) },
289
+ outputs: { x: "float", y: "float", z: "float" },
290
+ outputSelect: { x: ".x", y: ".y", z: ".z" },
291
+ emit: (a) => a.vector,
292
+ },
293
+ vect_cross: {
294
+ // b may be a literal constant (metal crosses the reflection dir with (0,1,0)).
295
+ inputs: { a: V([0, 0, 0], true), b: V([0, 1, 0]) },
296
+ outputs: { vector: "vector" },
297
+ emit: (a) => `vect_math_cross(${a.a}, ${a.b})`,
298
+ },
299
+ mapping: {
300
+ inputs: { vector: V([0, 0, 0], true), loc: V([0, 0, 0]), rot: V([0, 0, 0]), scl: V([1, 1, 1]) },
301
+ outputs: { vector: "vector" },
302
+ emit: (a) => `mapping_point(${a.vector}, ${a.loc}, ${a.rot}, ${a.scl})`,
303
+ },
304
+ bump: {
305
+ // Screen-space bump; world position comes from context (matches bump_lh's port).
306
+ inputs: { strength: F(0.1), height: F(0, true), normal: V([0, 0, 0], true) },
307
+ outputs: { vector: "vector" },
308
+ emit: (a) => `bump_lh(${a.strength}, ${a.height}, ${a.normal}, input.worldPos)`,
309
+ },
310
+
311
+ // ── Procedural textures ──
312
+ tex_noise: {
313
+ inputs: { vector: V([0, 0, 0], true), scale: F(5), detail: F(2), roughness: F(0.5), distortion: F(0) },
314
+ outputs: { value: "float" },
315
+ emit: (a) => `tex_noise(${a.vector}, ${a.scale}, ${a.detail}, ${a.roughness}, ${a.distortion})`,
316
+ },
317
+ tex_gradient: {
318
+ inputs: { vector: V([0, 0, 0], true) },
319
+ outputs: { value: "float" },
320
+ emit: (a) => `tex_gradient_linear(${a.vector})`,
321
+ },
322
+ "tex_voronoi/f1": {
323
+ inputs: { vector: V([0, 0, 0], true), scale: F(5) },
324
+ outputs: { value: "float" },
325
+ emit: (a) => `tex_voronoi_f1(${a.vector}, ${a.scale})`,
326
+ },
327
+ "tex_voronoi/color": {
328
+ inputs: { vector: V([0, 0, 0], true), scale: F(5) },
329
+ outputs: { color: "color" },
330
+ emit: (a) => `tex_voronoi_color(${a.vector}, ${a.scale})`,
331
+ },
332
+
333
+ // ── Principled BSDF (frozen 3.6 legacy-EEVEE semantics: eval_principled port) ──
334
+ // normal defaults to the template's shading normal; link a bump/normal_map chain
335
+ // to perturb it (body/cloth_rough noise bump).
336
+ principled: {
337
+ inputs: {
338
+ base: C([0.8, 0.8, 0.8], true),
339
+ metallic: F(0),
340
+ specular: F(0.5),
341
+ roughness: F(0.5),
342
+ spec_clamp: F(1e30),
343
+ sheen: F(0),
344
+ sheen_tint: F(0),
345
+ normal: { type: "vector", contextDefault: "n" },
346
+ },
347
+ outputs: { color: "color" },
348
+ emit: (a) =>
349
+ `eval_principled(PrincipledIn(${a.base}, ${a.metallic}, ${a.specular}, ${a.roughness}, ${a.spec_clamp}, ${a.sheen}, ${a.sheen_tint}), ${a.normal}, l, v, sun, amb, shadow)`,
350
+ },
351
+ }
@@ -0,0 +1,60 @@
1
+ // Material style graph — JSON-serializable node graph compiled to WGSL by compile.ts.
2
+ // Engine-space (LH, Y-up): porting a Blender graph converts coordinates at authoring
3
+ // time (Blender Normal Z → engine Y), the compiler never sees Blender conventions.
4
+ // See docs/graph-compiler-spec.md.
5
+
6
+ import type { MaterialPreset } from "../engine"
7
+
8
+ export type SocketValue = number | [number, number, number] | [number, number, number, number]
9
+
10
+ export type GraphNode = {
11
+ /** Unique within the graph, /^[a-z0-9_]+$/ — becomes the WGSL variable suffix. */
12
+ id: string
13
+ /** Node registry key, e.g. "hue_sat", "math/power", "mix/blend". */
14
+ type: string
15
+ /** Literal defaults for unlinked input sockets. */
16
+ inputs?: Record<string, SocketValue>
17
+ /** Editor-only metadata (React Flow position). Ignored by the compiler, round-tripped
18
+ * by serialization so engine-side tooling never strips an editor's layout. */
19
+ ui?: { position?: { x: number; y: number } }
20
+ }
21
+
22
+ export type GraphLink = {
23
+ from: { node: string; socket: string }
24
+ to: { node: string; socket: string }
25
+ }
26
+
27
+ /** Adjust-tier slider: overrides one unlinked literal input with a StyleUniforms read. */
28
+ export type ExposedParam = {
29
+ /** Stable key — slot assignment and serialization identity. */
30
+ id: string
31
+ label: string
32
+ target: { node: string; socket: string }
33
+ kind: "float" | "color"
34
+ min?: number
35
+ max?: number
36
+ default: SocketValue
37
+ }
38
+
39
+ export type StyleGraph = {
40
+ version: 1
41
+ name: string
42
+ /** Which preset slot this style targets. The slot owns pass integration (stencil
43
+ * variants like hair-over-eyes, alpha semantics, draw order) — always on,
44
+ * graph-invisible. The graph only restyles the slot's shading. */
45
+ slot: MaterialPreset
46
+ nodes: GraphNode[]
47
+ links: GraphLink[]
48
+ /** Must resolve to a color (vec3f) or float (auto-splatted) socket. */
49
+ output: { node: string; socket: string }
50
+ params?: ExposedParam[]
51
+ }
52
+
53
+ export type Diagnostic = {
54
+ severity: "error" | "warning"
55
+ nodeId?: string
56
+ message: string
57
+ }
58
+
59
+ export const MAX_NODES = 64
60
+ export const MAX_PARAMS = 16