reze-engine 0.41.0 → 0.41.2

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 (48) hide show
  1. package/README.md +4 -2
  2. package/dist/engine.d.ts +31 -3
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +190 -18
  5. package/dist/graph/presets/body.js +2 -2
  6. package/dist/graph/presets/cloth_rough.js +2 -2
  7. package/dist/graph/presets/cloth_smooth.js +2 -2
  8. package/dist/graph/presets/default.js +2 -2
  9. package/dist/graph/presets/eye.js +2 -2
  10. package/dist/graph/presets/face.js +2 -2
  11. package/dist/graph/presets/hair.js +2 -2
  12. package/dist/graph/presets/metal.js +2 -2
  13. package/dist/graph/presets/stockings.js +3 -3
  14. package/dist/graph/registry.d.ts.map +1 -1
  15. package/dist/graph/registry.js +426 -10
  16. package/dist/graph/style-group.d.ts +46 -0
  17. package/dist/graph/style-group.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/shaders/agx-lut.d.ts +11 -0
  21. package/dist/shaders/agx-lut.d.ts.map +1 -0
  22. package/dist/shaders/agx-lut.js +29 -0
  23. package/dist/shaders/materials/common.d.ts +1 -1
  24. package/dist/shaders/materials/common.d.ts.map +1 -1
  25. package/dist/shaders/materials/common.js +10 -0
  26. package/dist/shaders/materials/nodes.d.ts +1 -1
  27. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  28. package/dist/shaders/materials/nodes.js +957 -576
  29. package/dist/shaders/passes/composite.d.ts.map +1 -1
  30. package/dist/shaders/passes/composite.js +76 -4
  31. package/package.json +1 -1
  32. package/src/engine.ts +211 -17
  33. package/src/graph/presets/body.ts +2 -2
  34. package/src/graph/presets/cloth_rough.ts +2 -2
  35. package/src/graph/presets/cloth_smooth.ts +2 -2
  36. package/src/graph/presets/default.ts +2 -2
  37. package/src/graph/presets/eye.ts +2 -2
  38. package/src/graph/presets/face.ts +2 -2
  39. package/src/graph/presets/hair.ts +2 -2
  40. package/src/graph/presets/metal.ts +2 -2
  41. package/src/graph/presets/stockings.ts +3 -3
  42. package/src/graph/registry.ts +439 -10
  43. package/src/graph/style-group.ts +44 -0
  44. package/src/index.ts +2 -0
  45. package/src/shaders/agx-lut.ts +30 -0
  46. package/src/shaders/materials/common.ts +10 -0
  47. package/src/shaders/materials/nodes.ts +962 -581
  48. package/src/shaders/passes/composite.ts +76 -4
@@ -73,7 +73,7 @@ export const FACE_GRAPH: ShaderGraph = {
73
73
  {
74
74
  id: "principled",
75
75
  type: "principled",
76
- inputs: { metallic: 0.0, specular: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen: 0.0, sheen_tint: 0.0 },
76
+ inputs: { metallic: 0.0, specular_ior_level: 0.5, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
77
77
  },
78
78
  { id: "p_sum", type: "add_shader" },
79
79
 
@@ -124,7 +124,7 @@ export const FACE_GRAPH: ShaderGraph = {
124
124
  { from: { node: "noise_ramp", socket: "fac_out" }, to: { node: "principled_base", socket: "fac" } },
125
125
  { from: { node: "bc", socket: "color" }, to: { node: "principled_base", socket: "a" } },
126
126
  { from: { node: "bc", socket: "color" }, to: { node: "p_emit", socket: "color" } },
127
- { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base" } },
127
+ { from: { node: "principled_base", socket: "color" }, to: { node: "principled", socket: "base_color" } },
128
128
  { from: { node: "bump", socket: "vector" }, to: { node: "principled", socket: "normal" } },
129
129
  { from: { node: "principled", socket: "color" }, to: { node: "p_sum", socket: "a" } },
130
130
  { from: { node: "p_emit", socket: "color" }, to: { node: "p_sum", socket: "b" } },
@@ -44,7 +44,7 @@ export const HAIR_GRAPH: ShaderGraph = {
44
44
  {
45
45
  id: "principled",
46
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 },
47
+ inputs: { metallic: 0.0, specular_ior_level: 1.0, roughness: 0.3, spec_clamp: 10.0, sheen_weight: 0.0, sheen_tint: 0.0 },
48
48
  },
49
49
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.2 } },
50
50
  ],
@@ -71,7 +71,7 @@ export const HAIR_GRAPH: ShaderGraph = {
71
71
  { from: { node: "gate", socket: "value" }, to: { node: "gate_scale", socket: "a" } },
72
72
  { from: { node: "mix_shader_002", socket: "color" }, to: { node: "npr_add", socket: "a" } },
73
73
  { from: { node: "gate_scale", socket: "value" }, to: { node: "npr_add", socket: "b" } },
74
- { from: { node: "bc", socket: "color" }, to: { node: "principled", socket: "base" } },
74
+ { from: { node: "bc", socket: "color" }, to: { node: "principled", socket: "base_color" } },
75
75
  { from: { node: "npr_add", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
76
76
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
77
77
  ],
@@ -30,7 +30,7 @@ export const METAL_GRAPH: ShaderGraph = {
30
30
  {
31
31
  id: "principled",
32
32
  type: "principled",
33
- inputs: { metallic: 1.0, specular: 1.0, roughness: 0.3, spec_clamp: 1e30, sheen: 0.0, sheen_tint: 0.0 },
33
+ inputs: { metallic: 1.0, specular_ior_level: 1.0, roughness: 0.3, spec_clamp: 1e30, sheen_weight: 0.0, sheen_tint: 0.0 },
34
34
  },
35
35
  { id: "mix_shader_001", type: "mix_shader", inputs: { fac: 0.6967 } },
36
36
  ],
@@ -53,7 +53,7 @@ export const METAL_GRAPH: ShaderGraph = {
53
53
  { from: { node: "voro_ramp", socket: "fac_out" }, to: { node: "albedo", socket: "fac" } },
54
54
  { from: { node: "voro_ramp", socket: "fac_out" }, to: { node: "albedo", socket: "a" } },
55
55
  { from: { node: "hue_006", socket: "color" }, to: { node: "albedo", socket: "b" } },
56
- { from: { node: "albedo", socket: "color" }, to: { node: "principled", socket: "base" } },
56
+ { from: { node: "albedo", socket: "color" }, to: { node: "principled", socket: "base_color" } },
57
57
  { from: { node: "npr_emit", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
58
58
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
59
59
  ],
@@ -29,10 +29,10 @@ export const STOCKINGS_GRAPH: ShaderGraph = {
29
29
  type: "principled",
30
30
  inputs: {
31
31
  metallic: 0.1,
32
- specular: 1.0,
32
+ specular_ior_level: 1.0,
33
33
  roughness: 0.5,
34
34
  spec_clamp: 1e30,
35
- sheen: 0.7017999887466431,
35
+ sheen_weight: 0.7017999887466431,
36
36
  sheen_tint: 0.5,
37
37
  },
38
38
  },
@@ -48,7 +48,7 @@ export const STOCKINGS_GRAPH: ShaderGraph = {
48
48
  { from: { node: "mix_001", socket: "color" }, to: { node: "mask", socket: "a" } },
49
49
  { from: { node: "ramp_002", socket: "fac_out" }, to: { node: "mask", socket: "b" } },
50
50
  { from: { node: "tex", socket: "color" }, to: { node: "emission_hs", socket: "color" } },
51
- { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base" } },
51
+ { from: { node: "tex", socket: "color" }, to: { node: "principled", socket: "base_color" } },
52
52
  { from: { node: "emission_hs", socket: "color" }, to: { node: "mix_shader_001", socket: "a" } },
53
53
  { from: { node: "principled", socket: "color" }, to: { node: "mix_shader_001", socket: "b" } },
54
54
  { from: { node: "mask", socket: "color" }, to: { node: "mix_shader_001", socket: "fac" } },
@@ -52,15 +52,19 @@ export function fmtValue(value: SocketValue, type: SockT): string {
52
52
  if (x === y && y === z) return `vec3f(${fmtFloat(x)})`
53
53
  return `vec3f(${fmtFloat(x)}, ${fmtFloat(y)}, ${fmtFloat(z)})`
54
54
  }
55
- if (type !== "vec4") throw new Error(`vec4 literal on ${type} socket`)
55
+ if (type === "float") throw new Error(`color literal on float socket`)
56
+ // Blender's colour sockets are RGBA, so a ported literal arrives with four
57
+ // components whatever it feeds. Only a stop colour keeps the alpha; every other
58
+ // socket here is a vec3 and drops it, which is what Blender does downstream.
59
+ if (type !== "vec4") return fmtValue([value[0], value[1], value[2]], type)
56
60
  return `vec4f(${value.map(fmtFloat).join(", ")})`
57
61
  }
58
62
 
59
63
  /** Does a literal's shape fit a socket type? (Scalar splats onto color/vector/vec4.) */
60
64
  export function literalFits(value: SocketValue, type: SockT): boolean {
61
65
  if (typeof value === "number") return true
62
- if (value.length === 3) return type !== "float"
63
- return type === "vec4"
66
+ // 3 and 4 components both fit anything but a float — see fmtValue on RGBA.
67
+ return type !== "float"
64
68
  }
65
69
 
66
70
  // ─── Implicit socket conversions (Blender-faithful) ──────────────────
@@ -109,6 +113,18 @@ const RAMP_OUTPUTS: Record<string, SockT> = { color: "color", alpha: "float", fa
109
113
  // routing through the BT.601 color→float conversion instead would change the value.
110
114
  const RAMP_SELECT = { color: ".rgb", alpha: ".a", fac_out: ".r" }
111
115
 
116
+ /** Principled v2 reflectance, evaluated at compile time when it can be. */
117
+ function foldSpecular(ior: string, level: string): string {
118
+ const i = Number(ior)
119
+ const l = Number(level)
120
+ if (!Number.isFinite(i) || !Number.isFinite(l)) return `principled_specular(${ior}, ${level})`
121
+ const r = (i - 1) / Math.max(i + 1, 1e-6)
122
+ // Snapped to 9 significant digits: the arithmetic leaves double noise
123
+ // (0.2² · 2 · 0.5 / 0.08 = 0.5000000000000001) that is meaningless in an f32
124
+ // and only makes the emitted shader harder to read.
125
+ return fmtFloat(Number(((r * r * 2 * Math.max(l, 0)) / 0.08).toPrecision(9)))
126
+ }
127
+
112
128
  export const NODE_REGISTRY: Record<string, NodeSpec> = {
113
129
  // ── Context inputs (template locals; no emission) ──
114
130
  texture: {
@@ -137,6 +153,44 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
137
153
  reflection: "reflect(-v, n)",
138
154
  },
139
155
  },
156
+ // The scene's key light. Blender NPR presets rarely use a diffuse closure —
157
+ // they build their own term, typically dot(normal, direction) pushed through a
158
+ // ramp or a soft threshold band, because that is what gives an anime shader its
159
+ // hard terminator. Reaching that term needs the direction as a value, which no
160
+ // other node exposes: `shader_to_rgb*` and `bsdf_diffuse` bake the whole closure
161
+ // and hand back a result. `shadow` is the same cascade sample those closures
162
+ // take, so a graph can tint its own shadow instead of accepting theirs.
163
+ //
164
+ // A ported graph reads `direction` where the Blender original read an Attribute
165
+ // fed by a light empty; the empty and our sun mean the same thing.
166
+ light: {
167
+ inputs: {},
168
+ outputs: { direction: "vector", color: "color", ambient: "color", shadow: "float" },
169
+ contextOutputs: { direction: "l", color: "sun", ambient: "amb", shadow: "shadow" },
170
+ },
171
+
172
+ // The head bone's world basis — what an SDF face shadow is built on.
173
+ //
174
+ // That technique compares a face-shaped distance field against the light's
175
+ // angle IN THE HEAD'S OWN FRAME, so the shadow sweeps across the face as the
176
+ // light moves and stays put as the head turns. Without the frame there is
177
+ // nothing to measure the angle against, and the effect cannot be expressed at
178
+ // all. `right` also carries the sign that mirrors the field's U coordinate,
179
+ // which is how one half-face texture serves both sides.
180
+ //
181
+ // Read from the 頭 bone's skinning matrix, already bound to the fragment stage
182
+ // for the eye's rear-view gate. A model without that bone falls back to bone 0
183
+ // rather than sampling out of bounds; the face shadow is then wrong, not unsafe.
184
+ head_basis: {
185
+ inputs: {},
186
+ outputs: { forward: "vector", right: "vector", up: "vector" },
187
+ contextOutputs: {
188
+ forward: "(-normalize(skinMats[u32(max(material.headBoneIndex, 0.0))][2].xyz))",
189
+ right: "normalize(skinMats[u32(max(material.headBoneIndex, 0.0))][0].xyz)",
190
+ up: "normalize(skinMats[u32(max(material.headBoneIndex, 0.0))][1].xyz)",
191
+ },
192
+ },
193
+
140
194
  // PMX material's diffuse color (the authored base tint). Multiply the diffuse texture
141
195
  // by this for the MMD-correct base — untextured materials carry their color here, so a
142
196
  // texture-only base would render them white.
@@ -157,6 +211,267 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
157
211
  emit: (a) => `pmx_sphere_map(${a.base}, ${a.strength}, n)`,
158
212
  },
159
213
 
214
+ // ── Blender 5.2 Math node, full operation set ──
215
+ "math/absolute": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_absolute(${a.a})` },
216
+ "math/sqrt": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_sqrt(${a.a})` },
217
+ "math/inversesqrt": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_inversesqrt(${a.a})` },
218
+ "math/exponent": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_exponent(${a.a})` },
219
+ "math/sign": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_sign(${a.a})` },
220
+ "math/round": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_round(${a.a})` },
221
+ "math/floor": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_floor(${a.a})` },
222
+ "math/ceil": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_ceil(${a.a})` },
223
+ "math/truncate": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_truncate(${a.a})` },
224
+ "math/fraction": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_fraction(${a.a})` },
225
+ "math/sine": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_sine(${a.a})` },
226
+ "math/cosine": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_cosine(${a.a})` },
227
+ "math/tangent": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_tangent(${a.a})` },
228
+ "math/arcsine": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_arcsine(${a.a})` },
229
+ "math/arccosine": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_arccosine(${a.a})` },
230
+ "math/arctangent": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_arctangent(${a.a})` },
231
+ "math/radians": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_radians(${a.a})` },
232
+ "math/degrees": { inputs: { a: F(0) }, outputs: { value: "float" }, emit: (a) => `math_degrees(${a.a})` },
233
+ "math/subtract": { inputs: { a: F(0), b: F(0) }, outputs: { value: "float" }, emit: (a) => `math_subtract(${a.a}, ${a.b})` },
234
+ "math/divide": { inputs: { a: F(0), b: F(1) }, outputs: { value: "float" }, emit: (a) => `math_divide(${a.a}, ${a.b})` },
235
+ "math/logarithm": { inputs: { a: F(0), b: F(1) }, outputs: { value: "float" }, emit: (a) => `math_logarithm(${a.a}, ${a.b})` },
236
+ "math/minimum": { inputs: { a: F(0), b: F(0) }, outputs: { value: "float" }, emit: (a) => `math_minimum(${a.a}, ${a.b})` },
237
+ "math/maximum": { inputs: { a: F(0), b: F(0) }, outputs: { value: "float" }, emit: (a) => `math_maximum(${a.a}, ${a.b})` },
238
+ "math/less_than": { inputs: { a: F(0), b: F(0) }, outputs: { value: "float" }, emit: (a) => `math_less_than(${a.a}, ${a.b})` },
239
+ "math/modulo": { inputs: { a: F(0), b: F(1) }, outputs: { value: "float" }, emit: (a) => `math_modulo(${a.a}, ${a.b})` },
240
+ "math/floored_modulo": { inputs: { a: F(0), b: F(1) }, outputs: { value: "float" }, emit: (a) => `math_floored_modulo(${a.a}, ${a.b})` },
241
+ "math/snap": { inputs: { a: F(0), b: F(1) }, outputs: { value: "float" }, emit: (a) => `math_snap(${a.a}, ${a.b})` },
242
+ "math/pingpong": { inputs: { a: F(0), b: F(1) }, outputs: { value: "float" }, emit: (a) => `math_pingpong(${a.a}, ${a.b})` },
243
+ "math/arctan2": { inputs: { a: F(0), b: F(0) }, outputs: { value: "float" }, emit: (a) => `math_arctan2(${a.a}, ${a.b})` },
244
+ "math/multiply_add": { inputs: { a: F(0), b: F(0), c: F(0) }, outputs: { value: "float" }, emit: (a) => `math_multiply_add(${a.a}, ${a.b}, ${a.c})` },
245
+ "math/compare": { inputs: { a: F(0), b: F(0), c: F(0) }, outputs: { value: "float" }, emit: (a) => `math_compare(${a.a}, ${a.b}, ${a.c})` },
246
+ "math/smooth_min": { inputs: { a: F(0), b: F(0), c: F(0) }, outputs: { value: "float" }, emit: (a) => `math_smooth_min(${a.a}, ${a.b}, ${a.c})` },
247
+ "math/smooth_max": { inputs: { a: F(0), b: F(0), c: F(0) }, outputs: { value: "float" }, emit: (a) => `math_smooth_max(${a.a}, ${a.b}, ${a.c})` },
248
+ "math/wrap": { inputs: { a: F(0), b: F(0), c: F(0) }, outputs: { value: "float" }, emit: (a) => `math_wrap(${a.a}, ${a.b}, ${a.c})` },
249
+
250
+ // ── Blender 5.2 Vector Math node ──
251
+ "vector_math/normalize": { inputs: { a: V([0, 0, 0], true) }, outputs: { vector: "vector" }, emit: (a) => `vector_normalize(${a.a})` },
252
+ "vector_math/absolute": { inputs: { a: V([0, 0, 0], true) }, outputs: { vector: "vector" }, emit: (a) => `vector_absolute(${a.a})` },
253
+ "vector_math/floor": { inputs: { a: V([0, 0, 0], true) }, outputs: { vector: "vector" }, emit: (a) => `vector_floor(${a.a})` },
254
+ "vector_math/ceil": { inputs: { a: V([0, 0, 0], true) }, outputs: { vector: "vector" }, emit: (a) => `vector_ceil(${a.a})` },
255
+ "vector_math/fraction": { inputs: { a: V([0, 0, 0], true) }, outputs: { vector: "vector" }, emit: (a) => `vector_fraction(${a.a})` },
256
+ "vector_math/add": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_add(${a.a}, ${a.b})` },
257
+ "vector_math/subtract": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_subtract(${a.a}, ${a.b})` },
258
+ "vector_math/multiply": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_multiply(${a.a}, ${a.b})` },
259
+ "vector_math/divide": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_divide(${a.a}, ${a.b})` },
260
+ "vector_math/cross": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_cross(${a.a}, ${a.b})` },
261
+ "vector_math/project": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_project(${a.a}, ${a.b})` },
262
+ "vector_math/reflect": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_reflect(${a.a}, ${a.b})` },
263
+ "vector_math/minimum": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_minimum(${a.a}, ${a.b})` },
264
+ "vector_math/maximum": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_maximum(${a.a}, ${a.b})` },
265
+ "vector_math/modulo": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_modulo(${a.a}, ${a.b})` },
266
+ "vector_math/snap": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_snap(${a.a}, ${a.b})` },
267
+ "vector_math/dot": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { value: "float" }, emit: (a) => `vector_dot(${a.a}, ${a.b})` },
268
+ "vector_math/distance": { inputs: { a: V([0, 0, 0], true), b: V() }, outputs: { value: "float" }, emit: (a) => `vector_distance(${a.a}, ${a.b})` },
269
+ "vector_math/length": { inputs: { a: V([0, 0, 0], true) }, outputs: { value: "float" }, emit: (a) => `vector_length(${a.a})` },
270
+ "vector_math/scale": { inputs: { a: V([0, 0, 0], true), scale: F(1) }, outputs: { vector: "vector" }, emit: (a) => `vector_scale(${a.a}, ${a.scale})` },
271
+ "vector_math/multiply_add": { inputs: { a: V([0, 0, 0], true), b: V(), c: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_multiply_add(${a.a}, ${a.b}, ${a.c})` },
272
+ "vector_math/faceforward": { inputs: { a: V([0, 0, 0], true), b: V(), c: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_faceforward(${a.a}, ${a.b}, ${a.c})` },
273
+ "vector_math/refract": { inputs: { a: V([0, 0, 0], true), b: V(), ior: F(1.45) }, outputs: { vector: "vector" }, emit: (a) => `vector_refract(${a.a}, ${a.b}, ${a.ior})` },
274
+ "vector_math/wrap": { inputs: { a: V([0, 0, 0], true), b: V(), c: V() }, outputs: { vector: "vector" }, emit: (a) => `vector_wrap(${a.a}, ${a.b}, ${a.c})` },
275
+
276
+ // ── Blender 5.2 Mix (Color) blend set ──
277
+ "mix/add": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_add(${a.fac}, ${a.a}, ${a.b})` },
278
+ "mix/subtract": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_subtract(${a.fac}, ${a.a}, ${a.b})` },
279
+ "mix/darken": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_darken(${a.fac}, ${a.a}, ${a.b})` },
280
+ "mix/difference": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_difference(${a.fac}, ${a.a}, ${a.b})` },
281
+ "mix/exclusion": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_exclusion(${a.fac}, ${a.a}, ${a.b})` },
282
+ "mix/screen": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_screen(${a.fac}, ${a.a}, ${a.b})` },
283
+ "mix/soft_light": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_soft_light(${a.fac}, ${a.a}, ${a.b})` },
284
+ "mix/dodge": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_dodge(${a.fac}, ${a.a}, ${a.b})` },
285
+ "mix/burn": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_burn(${a.fac}, ${a.a}, ${a.b})` },
286
+ "mix/divide": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_divide(${a.fac}, ${a.a}, ${a.b})` },
287
+ "mix/hue": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_hue(${a.fac}, ${a.a}, ${a.b})` },
288
+ "mix/saturation": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_saturation(${a.fac}, ${a.a}, ${a.b})` },
289
+ "mix/value": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_value(${a.fac}, ${a.a}, ${a.b})` },
290
+ "mix/color": { inputs: { fac: F(0.5), a: C([1, 1, 1], true), b: C() }, outputs: { color: "color" }, emit: (a) => `mix_color_blend(${a.fac}, ${a.a}, ${a.b})` },
291
+
292
+ // ── RGB Curves, sampled. See rgb_curve in nodes.ts for why five. ──
293
+ rgb_curve: {
294
+ inputs: {
295
+ color: C([1, 1, 1], true),
296
+ fac: F(1),
297
+ y0: F(0),
298
+ y1: F(0.25),
299
+ y2: F(0.5),
300
+ y3: F(0.75),
301
+ y4: F(1),
302
+ },
303
+ outputs: { color: "color" },
304
+ emit: (a) => `mix(${a.color}, rgb_curve(${a.color}, ${a.y0}, ${a.y1}, ${a.y2}, ${a.y3}, ${a.y4}), ${a.fac})`,
305
+ },
306
+ // UV Map — one layer on a PMX, which is the mesh UV the texture node uses.
307
+ uv_map: {
308
+ inputs: {},
309
+ outputs: { uv: "vector" },
310
+ contextOutputs: { uv: "vec3f(input.uv, 0.0)" },
311
+ },
312
+
313
+ // ── Normal Map, Vector Transform ──
314
+ normal_map: {
315
+ inputs: { color: C([0.5, 0.5, 1], true), strength: F(1) },
316
+ outputs: { normal: "vector" },
317
+ emit: (a) => `node_normal_map(${a.color}, ${a.strength}, n, input.worldPos, input.uv)`,
318
+ },
319
+ "vector_transform/world_to_camera": {
320
+ inputs: { vector: V([0, 0, 0], true) },
321
+ outputs: { vector: "vector" },
322
+ emit: (a) => `vector_world_to_camera(${a.vector})`,
323
+ },
324
+ "vector_transform/camera_to_world": {
325
+ inputs: { vector: V([0, 0, 0], true) },
326
+ outputs: { vector: "vector" },
327
+ emit: (a) => `vector_camera_to_world(${a.vector})`,
328
+ },
329
+ "vector_transform/point_world_to_camera": {
330
+ inputs: { vector: V([0, 0, 0], true) },
331
+ outputs: { vector: "vector" },
332
+ emit: (a) => `point_world_to_camera(${a.vector})`,
333
+ },
334
+
335
+ // ── Shader nodes. Shaders travel as RGB here (see shader_to_rgb_diffuse), so
336
+ // a "transparent" shader is the colour that contributes nothing; the actual
337
+ // cutout is the group's alphaMode, which is where transparency belongs in a
338
+ // rasteriser without order-independent blending.
339
+ bsdf_transparent: { inputs: {}, outputs: { color: "color" }, emit: () => `vec3f(0.0)` },
340
+ bsdf_diffuse: {
341
+ inputs: { color: C([0.8, 0.8, 0.8], true) },
342
+ outputs: { color: "color" },
343
+ emit: (a) => `${a.color} * shader_to_rgb_lit(n, l, sun, amb, shadow)`,
344
+ },
345
+
346
+ // ── Nodes with no meaning on a PMX, answered honestly with a constant ──
347
+ // Each returns what Blender returns for the absent case, so a graph that reads
348
+ // one degrades to a sensible look instead of failing to compile. Documented
349
+ // rather than silently wrong.
350
+ //
351
+ // attribute: MMD meshes carry no vertex colour layer, so Color reads white and
352
+ // Fac reads 1 — the identity for the multiply these usually feed.
353
+ attribute: {
354
+ inputs: {},
355
+ outputs: { color: "color", fac: "float" },
356
+ contextOutputs: { color: "vec3f(1.0)", fac: "1.0" },
357
+ },
358
+ // object_info: one model, one instance — Random is the only field with a real
359
+ // use (per-instance variation) and there are no instances to vary.
360
+ object_info: {
361
+ inputs: {},
362
+ outputs: { location: "vector", color: "color", random: "float" },
363
+ contextOutputs: { location: "vec3f(0.0)", color: "vec3f(1.0)", random: "0.0" },
364
+ },
365
+ // light_path: this is a raster pass, so every shaded fragment IS a camera ray.
366
+ light_path: {
367
+ inputs: {},
368
+ outputs: { is_camera_ray: "float", is_shadow_ray: "float", ray_depth: "float" },
369
+ contextOutputs: { is_camera_ray: "1.0", is_shadow_ray: "0.0", ray_depth: "0.0" },
370
+ },
371
+
372
+ // ── Image Texture on a style-group slot ──
373
+ // The PMX gives a material ONE image; this style needs several, so the extra
374
+ // maps ride on the group. Slot is part of the type because it selects a
375
+ // binding, which is topology rather than a value. The uv input defaults to the
376
+ // mesh UV, matching an unlinked Vector socket in Blender.
377
+ "tex_image/0": {
378
+ inputs: { uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" } },
379
+ outputs: { color: "color", alpha: "float" },
380
+ outputSelect: { color: ".rgb", alpha: ".a" },
381
+ emit: (a) => `group_tex0(${a.uv}.xy)`,
382
+ },
383
+ "tex_image/1": {
384
+ inputs: { uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" } },
385
+ outputs: { color: "color", alpha: "float" },
386
+ outputSelect: { color: ".rgb", alpha: ".a" },
387
+ emit: (a) => `group_tex1(${a.uv}.xy)`,
388
+ },
389
+ "tex_image/2": {
390
+ inputs: { uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" } },
391
+ outputs: { color: "color", alpha: "float" },
392
+ outputSelect: { color: ".rgb", alpha: ".a" },
393
+ emit: (a) => `group_tex2(${a.uv}.xy)`,
394
+ },
395
+ "tex_image/3": {
396
+ inputs: { uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" } },
397
+ outputs: { color: "color", alpha: "float" },
398
+ outputSelect: { color: ".rgb", alpha: ".a" },
399
+ emit: (a) => `group_tex3(${a.uv}.xy)`,
400
+ },
401
+
402
+ // ── Blender 5.2 colour utilities ──
403
+ separate_color: {
404
+ inputs: { color: C([1, 1, 1], true) },
405
+ outputs: { r: "float", g: "float", b: "float" },
406
+ outputSelect: { r: ".r", g: ".g", b: ".b" },
407
+ emit: (a) => a.color,
408
+ },
409
+ "separate_color/hsv": {
410
+ inputs: { color: C([1, 1, 1], true) },
411
+ outputs: { h: "float", s: "float", v: "float" },
412
+ outputSelect: { h: ".x", s: ".y", v: ".z" },
413
+ emit: (a) => `rgb_to_hsv(${a.color})`,
414
+ },
415
+ "separate_color/hsl": {
416
+ inputs: { color: C([1, 1, 1], true) },
417
+ outputs: { h: "float", s: "float", l: "float" },
418
+ outputSelect: { h: ".x", s: ".y", l: ".z" },
419
+ emit: (a) => `rgb_to_hsl(${a.color})`,
420
+ },
421
+ combine_color: {
422
+ inputs: { r: F(0), g: F(0), b: F(0) },
423
+ outputs: { color: "color" },
424
+ emit: (a) => `vec3f(${a.r}, ${a.g}, ${a.b})`,
425
+ },
426
+ "combine_color/hsv": {
427
+ inputs: { h: F(0), s: F(0), v: F(0) },
428
+ outputs: { color: "color" },
429
+ emit: (a) => `hsv_to_rgb(vec3f(${a.h}, ${a.s}, ${a.v}))`,
430
+ },
431
+ "combine_color/hsl": {
432
+ inputs: { h: F(0), s: F(0), l: F(0) },
433
+ outputs: { color: "color" },
434
+ emit: (a) => `hsl_to_rgb(vec3f(${a.h}, ${a.s}, ${a.l}))`,
435
+ },
436
+ combine_xyz: {
437
+ inputs: { x: F(0), y: F(0), z: F(0) },
438
+ outputs: { vector: "vector" },
439
+ emit: (a) => `vec3f(${a.x}, ${a.y}, ${a.z})`,
440
+ },
441
+ gamma: {
442
+ inputs: { color: C([1, 1, 1], true), gamma: F(1) },
443
+ outputs: { color: "color" },
444
+ emit: (a) => `node_gamma(${a.color}, ${a.gamma})`,
445
+ },
446
+ // Map Range: the interpolation is topology, as everywhere else here. The
447
+ // clamped form is Blender's default, which is why it carries the bare name.
448
+ map_range: {
449
+ inputs: { value: F(0, true), from_min: F(0), from_max: F(1), to_min: F(0), to_max: F(1) },
450
+ outputs: { value: "float" },
451
+ emit: (a) => `map_range_clamped(${a.value}, ${a.from_min}, ${a.from_max}, ${a.to_min}, ${a.to_max})`,
452
+ },
453
+ "map_range/linear": {
454
+ inputs: { value: F(0, true), from_min: F(0), from_max: F(1), to_min: F(0), to_max: F(1) },
455
+ outputs: { value: "float" },
456
+ emit: (a) => `map_range_linear(${a.value}, ${a.from_min}, ${a.from_max}, ${a.to_min}, ${a.to_max})`,
457
+ },
458
+ "map_range/smoothstep": {
459
+ inputs: { value: F(0, true), from_min: F(0), from_max: F(1), to_min: F(0), to_max: F(1) },
460
+ outputs: { value: "float" },
461
+ emit: (a) => `map_range_smooth(${a.value}, ${a.from_min}, ${a.from_max}, ${a.to_min}, ${a.to_max})`,
462
+ },
463
+ // Vector Rotate — the rotation TYPE is topology.
464
+ "vector_rotate/axis_angle": {
465
+ inputs: { vector: V([0, 0, 0], true), center: V(), axis: V([0, 0, 1]), angle: F(0) },
466
+ outputs: { vector: "vector" },
467
+ emit: (a) => `vector_rotate_axis(${a.vector}, ${a.center}, ${a.axis}, ${a.angle})`,
468
+ },
469
+ "vector_rotate/euler_xyz": {
470
+ inputs: { vector: V([0, 0, 0], true), center: V(), rotation: V() },
471
+ outputs: { vector: "vector" },
472
+ emit: (a) => `vector_rotate_euler(${a.vector}, ${a.center}, ${a.rotation})`,
473
+ },
474
+
160
475
  // ── Literals as nodes (for editor ergonomics; inlined literals work too) ──
161
476
  value: { inputs: { value: F(0) }, outputs: { value: "float" }, emit: (a) => a.value },
162
477
  rgb: { inputs: { color: C() }, outputs: { color: "color" }, emit: (a) => a.color },
@@ -201,8 +516,28 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
201
516
  outputSelect: RAMP_SELECT,
202
517
  emit: (a) => `ramp_constant_edge_aa(${a.fac}, ${a.edge}, ${a.color0}, ${a.color1})`,
203
518
  },
519
+ // Blender ColorRamp LINEAR with three arbitrary stops. A two-stop ramp cannot
520
+ // express a shadow that passes through a colour on its way — a warm terminator
521
+ // between cool shadow and lit skin is three stops, and that middle band is
522
+ // where a lot of NPR character lives. Decomposing it into two ramps and a
523
+ // select costs three extra nodes and stops reading as a ramp.
524
+ ramp_linear_3: {
525
+ inputs: {
526
+ fac: F(0.5, true),
527
+ pos0: F(0),
528
+ color0: V4([0, 0, 0, 1]),
529
+ pos1: F(0.5),
530
+ color1: V4([0.5, 0.5, 0.5, 1]),
531
+ pos2: F(1),
532
+ color2: V4([1, 1, 1, 1]),
533
+ },
534
+ outputs: RAMP_OUTPUTS,
535
+ outputSelect: RAMP_SELECT,
536
+ emit: (a) =>
537
+ `ramp_linear3(${a.fac}, ${a.pos0}, ${a.color0}, ${a.pos1}, ${a.color1}, ${a.pos2}, ${a.color2})`,
538
+ },
204
539
  // Blender ColorRamp LINEAR with 3 stops black→white→black (triangular peak at 0.5).
205
- // The only >2-stop ramp the presets use; folded to closed form like the hand port.
540
+ // Folded to closed form like the hand port.
206
541
  ramp_tri: {
207
542
  inputs: { fac: F(0.5, true) },
208
543
  outputs: { value: "float" },
@@ -296,12 +631,37 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
296
631
  },
297
632
 
298
633
  // ── Lighting capture ──
634
+ /**
635
+ * Shader → RGB on a white diffuse closure, reduced to a scalar.
636
+ *
637
+ * Rec.709 luminance, kept exactly as it was: every shipped preset ramps this,
638
+ * and changing the weights would move every terminator in the library.
639
+ */
299
640
  shader_to_rgb_diffuse: {
300
641
  inputs: {},
301
642
  outputs: { value: "float" },
302
643
  emit: () => `shader_to_rgb_diffuse(n, l, sun, amb, shadow)`,
303
644
  },
304
645
 
646
+ /**
647
+ * The same closure as a COLOUR, which is what Blender's Shader to RGB
648
+ * actually returns.
649
+ *
650
+ * A warm sun against a cool ambient radiates warm light and cool shadow, and
651
+ * that colour IS the look in most of this style. Reducing it to luminance
652
+ * first — the only thing reachable before — leaves every ramp working on grey.
653
+ *
654
+ * Linking this into a scalar socket still yields a float, because Blender's
655
+ * own implicit Color → Value conversion (BT.601) happens at the link. So the
656
+ * two nodes differ only in whether the graph wants the colour or the scalar,
657
+ * and each gets Blender's answer for its own question.
658
+ */
659
+ shader_to_rgb: {
660
+ inputs: {},
661
+ outputs: { color: "color" },
662
+ emit: () => `shader_to_rgb_lit(n, l, sun, amb, shadow)`,
663
+ },
664
+
305
665
  // ── Vector ──
306
666
  separate_xyz: {
307
667
  inputs: { vector: V([0, 0, 0], true) },
@@ -352,19 +712,88 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
352
712
  // ── Principled BSDF (frozen 3.6 legacy-EEVEE semantics: eval_principled port) ──
353
713
  // normal defaults to the template's shading normal; link a bump/normal_map chain
354
714
  // to perturb it (body/cloth_rough noise bump).
715
+ /**
716
+ * Principled BSDF, Blender 5.2 socket names.
717
+ *
718
+ * v2 (4.0+) renamed most of what a graph touches and re-derived one of them,
719
+ * which is exactly where a transcribed port goes quietly wrong: Specular
720
+ * became "Specular IOR Level" and no longer sets reflectance directly — IOR
721
+ * does, and the level scales it. So f0 is computed the v2 way here, and the
722
+ * defaults land on 0.04 for ior 1.5 at level 0.5, matching both versions at
723
+ * their defaults while tracking v2 when a preset moves either.
724
+ *
725
+ * Emission Strength defaults to 0 in v2 where 3.6's Emission was visible, so a
726
+ * naive port turns every emissive material black. Following v2 means honouring
727
+ * that default and letting the preset say otherwise.
728
+ *
729
+ * Not implemented, and not silently wrong — a graph setting these gets the
730
+ * base BSDF rather than a wrong approximation of them: Coat, Transmission,
731
+ * Subsurface, Anisotropic, Thin Film. They need path-traced or
732
+ * multi-scatter machinery this renderer does not have.
733
+ */
355
734
  principled: {
356
735
  inputs: {
357
- base: C([0.8, 0.8, 0.8], true),
736
+ base_color: C([0.8, 0.8, 0.8], true),
358
737
  metallic: F(0),
359
- specular: F(0.5),
360
738
  roughness: F(0.5),
361
- spec_clamp: F(1e30),
362
- sheen: F(0),
739
+ ior: F(1.5),
740
+ specular_ior_level: F(0.5),
741
+ sheen_weight: F(0),
363
742
  sheen_tint: F(0),
743
+ emission_color: C([1, 1, 1]),
744
+ emission_strength: F(0),
364
745
  normal: { type: "vector", contextDefault: "n" },
746
+ /** Ours, not Blender's: caps firefly speculars from noise-bumped NDF
747
+ * aliasing, which EEVEE hides behind TAA and we have none. */
748
+ spec_clamp: F(1e30),
365
749
  },
366
750
  outputs: { color: "color" },
367
- emit: (a) =>
368
- `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)`,
751
+ emit: (a) => {
752
+ // Fold the reflectance when both sockets are literals, which is nearly
753
+ // always. It keeps the emitted WGSL as tight as the 3.6 form was — and at
754
+ // the defaults it folds to exactly 0.5, which is what 3.6's Specular held,
755
+ // so the two versions agree byte-for-byte where a preset changed nothing.
756
+ const spec = foldSpecular(a.ior, a.specular_ior_level)
757
+ const bsdf =
758
+ `eval_principled(PrincipledIn(${a.base_color}, ${a.metallic}, ` +
759
+ `${spec}, ${a.roughness}, ` +
760
+ `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}), ${a.normal}, l, v, sun, amb, shadow)`
761
+ // v2 defaults Emission Strength to 0, which is the overwhelming case. Emit
762
+ // the term only when it can do something, so the common shader carries no
763
+ // dead add and the output stays readable.
764
+ return a.emission_strength === "0.0" ? bsdf : `${bsdf} + ${a.emission_color} * ${a.emission_strength}`
765
+ },
369
766
  },
370
767
  }
768
+
769
+ // ─── Blender socket parity ────────────────────────────────────────────
770
+ // Blender's Math node carries three Value inputs whatever operation is selected,
771
+ // its Vector Math node three Vectors plus a Scale, and Vector Rotate the union of
772
+ // every rotation type's inputs. Sockets are part of the node, not of the mode.
773
+ //
774
+ // Declaring the same set here is what makes a port mechanical: a transcriber maps
775
+ // socket-for-socket and never has to know which inputs a given operation happens
776
+ // to read. The emit functions read only what their operation uses, so the added
777
+ // sockets change no generated WGSL — an unread one is inert.
778
+ //
779
+ // Applied as a pass rather than written into each entry so that every operation
780
+ // KEEPS the default it already declared (divide's b is 1, not 0). Only genuinely
781
+ // missing sockets are added.
782
+ function widen(prefix: string, sockets: Record<string, InputSpec>): void {
783
+ for (const [key, spec] of Object.entries(NODE_REGISTRY)) {
784
+ if (!key.startsWith(prefix)) continue
785
+ for (const [name, def] of Object.entries(sockets)) {
786
+ if (!(name in spec.inputs)) spec.inputs[name] = def
787
+ }
788
+ }
789
+ }
790
+
791
+ widen("math/", { a: F(0), b: F(0), c: F(0) })
792
+ widen("vector_math/", { a: V(), b: V(), c: V(), scale: F(1) })
793
+ widen("vector_rotate/", {
794
+ vector: V([0, 0, 0], true),
795
+ center: V(),
796
+ axis: V([0, 0, 1]),
797
+ angle: F(0),
798
+ rotation: V(),
799
+ })
@@ -22,8 +22,52 @@ export type StyleGroup = {
22
22
  renderClass?: RenderClass
23
23
  /** Alpha-handling axis, orthogonal to renderClass. Default "opaque". */
24
24
  alphaMode?: AlphaMode
25
+ /**
26
+ * Extra image maps for this group's shading, up to four.
27
+ *
28
+ * A PMX material carries exactly ONE image, and a Blender-authored look is
29
+ * usually built on more than that — a lightmap encoding shadow thresholds and
30
+ * specular masks in its channels, a ramp, a detail map. Those belong to the
31
+ * LOOK rather than to the model, so they ride on the group: the same graph
32
+ * applied to another character brings its own maps with it.
33
+ *
34
+ * Read by the `tex_image/0`…`tex_image/3` nodes, in order. A slot left empty
35
+ * (or a graph reading past the end) samples 1×1 white, so a missing map is a
36
+ * no-op rather than a failure.
37
+ */
38
+ images?: (GroupImage | GroupImageSource | null)[]
25
39
  }
26
40
 
41
+ export type GroupImageSource = ImageBitmap | HTMLImageElement | HTMLCanvasElement
42
+
43
+ /**
44
+ * A group map, with the one thing the engine cannot infer from the pixels:
45
+ * whether they are colour.
46
+ *
47
+ * A PMX texture is always colour, so material textures are uploaded sRGB
48
+ * unconditionally. A group's maps are not — half of them are data whose channels
49
+ * encode thresholds, masks and distances, and running those through an sRGB
50
+ * decode bends every value. Blender asks the same question per image, and a
51
+ * ported look carries whatever answer it was authored with.
52
+ *
53
+ * A bare source reads as data (`srgb: false`), because a map that is silently
54
+ * mis-decoded as colour is the harder failure to see: it renders, just wrong.
55
+ */
56
+ /**
57
+ * `premultiplied` — whether the upload should multiply RGB by alpha.
58
+ *
59
+ * Blender premultiplies a straight-alpha image for rendering, so a transparent
60
+ * pixel contributes nothing. A highlight texture is white everywhere with its
61
+ * shape only in alpha, and without this it screens to a white card instead of
62
+ * resolving to its shape. Left off for a channel-packed map, whose alpha is a
63
+ * separate signal and would scale colour by something unrelated.
64
+ *
65
+ * It has to happen HERE rather than in the host's decode: copyExternalImageToTexture
66
+ * converts to the destination's tagged alpha mode, so a premultiplied ImageBitmap
67
+ * copied into an untagged texture is un-premultiplied again on the way in.
68
+ */
69
+ export type GroupImage = { source: GroupImageSource; srgb?: boolean; premultiplied?: boolean }
70
+
27
71
  export type GroupDiagnostic = { groupId: string; diagnostics: Diagnostic[]; ok: boolean }
28
72
 
29
73
  export type ApplyStyleGroupsResult = {