reze-engine 0.64.0 → 0.66.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 (173) hide show
  1. package/dist/engine.d.ts +300 -94
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1628 -787
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +5 -0
  6. package/dist/graph/presets/body.d.ts.map +1 -1
  7. package/dist/graph/presets/body.js +26 -24
  8. package/dist/graph/presets/cloth_rough.js +8 -8
  9. package/dist/graph/presets/cloth_smooth.js +8 -8
  10. package/dist/graph/presets/default.js +4 -4
  11. package/dist/graph/presets/eye.js +10 -10
  12. package/dist/graph/presets/face.d.ts.map +1 -1
  13. package/dist/graph/presets/face.js +28 -26
  14. package/dist/graph/presets/hair.js +6 -6
  15. package/dist/graph/presets/metal.js +8 -8
  16. package/dist/graph/presets/stockings.d.ts.map +1 -1
  17. package/dist/graph/presets/stockings.js +4 -11
  18. package/dist/graph/presets/unlit.d.ts.map +1 -1
  19. package/dist/graph/presets/unlit.js +9 -13
  20. package/dist/graph/registry.d.ts.map +1 -1
  21. package/dist/graph/registry.js +128 -157
  22. package/dist/graph/render-class.d.ts +5 -2
  23. package/dist/graph/render-class.d.ts.map +1 -1
  24. package/dist/graph/slots.d.ts.map +1 -1
  25. package/dist/graph/slots.js +12 -2
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/model.d.ts +23 -1
  29. package/dist/model.d.ts.map +1 -1
  30. package/dist/model.js +41 -1
  31. package/dist/outline-normals.d.ts +12 -0
  32. package/dist/outline-normals.d.ts.map +1 -0
  33. package/dist/outline-normals.js +100 -0
  34. package/dist/pmx-loader.d.ts +7 -0
  35. package/dist/pmx-loader.d.ts.map +1 -1
  36. package/dist/pmx-loader.js +17 -3
  37. package/dist/shaders/anchor-table.d.ts +11 -0
  38. package/dist/shaders/anchor-table.d.ts.map +1 -1
  39. package/dist/shaders/anchor-table.js +9 -3
  40. package/dist/shaders/directives.d.ts +42 -5
  41. package/dist/shaders/directives.d.ts.map +1 -1
  42. package/dist/shaders/directives.js +51 -4
  43. package/dist/shaders/lights.d.ts +11 -7
  44. package/dist/shaders/lights.d.ts.map +1 -1
  45. package/dist/shaders/lights.js +315 -271
  46. package/dist/shaders/materials/common.d.ts.map +1 -1
  47. package/dist/shaders/materials/common.js +30 -5
  48. package/dist/shaders/materials/nodes.d.ts +1 -1
  49. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  50. package/dist/shaders/materials/nodes.js +191 -271
  51. package/dist/shaders/passes/bloom.d.ts +4 -3
  52. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  53. package/dist/shaders/passes/bloom.js +58 -87
  54. package/dist/shaders/passes/composite.d.ts +1 -1
  55. package/dist/shaders/passes/composite.d.ts.map +1 -1
  56. package/dist/shaders/passes/composite.js +121 -77
  57. package/dist/shaders/passes/ground.js +1 -1
  58. package/dist/shaders/passes/mirror.d.ts +18 -12
  59. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  60. package/dist/shaders/passes/mirror.js +59 -13
  61. package/dist/shaders/passes/outline.d.ts.map +1 -1
  62. package/dist/shaders/passes/outline.js +71 -14
  63. package/dist/shaders/passes/particles.d.ts.map +1 -1
  64. package/dist/shaders/passes/particles.js +9 -3
  65. package/dist/shaders/passes/probe.d.ts +45 -0
  66. package/dist/shaders/passes/probe.d.ts.map +1 -0
  67. package/dist/shaders/passes/probe.js +230 -0
  68. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  69. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  70. package/dist/shaders/passes/scene-contract.js +4 -0
  71. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  72. package/dist/shaders/scene-light-api.js +1 -0
  73. package/dist/unity/globals.d.ts +7 -0
  74. package/dist/unity/globals.d.ts.map +1 -1
  75. package/dist/unity/globals.js +4 -3
  76. package/dist/unity/host.d.ts +18 -0
  77. package/dist/unity/host.d.ts.map +1 -1
  78. package/dist/unity/host.js +144 -70
  79. package/dist/unity/looks.d.ts +47 -0
  80. package/dist/unity/looks.d.ts.map +1 -1
  81. package/dist/unity/looks.js +150 -6
  82. package/dist/unity/skin.d.ts.map +1 -1
  83. package/dist/unity/skin.js +5 -2
  84. package/dist/unity/stage.d.ts +24 -0
  85. package/dist/unity/stage.d.ts.map +1 -1
  86. package/dist/unity/stage.js +59 -7
  87. package/package.json +1 -1
  88. package/src/engine.ts +2010 -1033
  89. package/src/graph/compile.ts +4 -0
  90. package/src/graph/presets/body.ts +26 -24
  91. package/src/graph/presets/cloth_rough.ts +8 -8
  92. package/src/graph/presets/cloth_smooth.ts +8 -8
  93. package/src/graph/presets/default.ts +4 -4
  94. package/src/graph/presets/eye.ts +10 -10
  95. package/src/graph/presets/face.ts +28 -26
  96. package/src/graph/presets/hair.ts +6 -6
  97. package/src/graph/presets/metal.ts +8 -8
  98. package/src/graph/presets/stockings.ts +4 -11
  99. package/src/graph/presets/unlit.ts +9 -13
  100. package/src/graph/registry.ts +133 -158
  101. package/src/graph/render-class.ts +5 -2
  102. package/src/graph/slots.ts +12 -2
  103. package/src/index.ts +1 -0
  104. package/src/model.ts +50 -1
  105. package/src/outline-normals.ts +110 -0
  106. package/src/pmx-loader.ts +18 -3
  107. package/src/shaders/anchor-table.ts +15 -3
  108. package/src/shaders/directives.ts +88 -6
  109. package/src/shaders/lights.ts +464 -415
  110. package/src/shaders/materials/common.ts +30 -5
  111. package/src/shaders/materials/nodes.ts +191 -271
  112. package/src/shaders/passes/bloom.ts +59 -87
  113. package/src/shaders/passes/composite.ts +121 -77
  114. package/src/shaders/passes/ground.ts +1 -1
  115. package/src/shaders/passes/mirror.ts +60 -13
  116. package/src/shaders/passes/outline.ts +71 -14
  117. package/src/shaders/passes/particles.ts +9 -3
  118. package/src/shaders/passes/probe.ts +240 -0
  119. package/src/shaders/passes/scene-contract.ts +17 -0
  120. package/src/shaders/scene-light-api.ts +1 -0
  121. package/src/unity/globals.ts +7 -3
  122. package/src/unity/host.ts +93 -20
  123. package/src/unity/looks.ts +182 -9
  124. package/src/unity/skin.ts +5 -2
  125. package/src/unity/stage.ts +66 -7
  126. package/dist/physics-debug.d.ts +0 -30
  127. package/dist/physics-debug.d.ts.map +0 -1
  128. package/dist/physics-debug.js +0 -526
  129. package/dist/shaders/agx-lut.d.ts +0 -11
  130. package/dist/shaders/agx-lut.d.ts.map +0 -1
  131. package/dist/shaders/agx-lut.js +0 -29
  132. package/dist/shaders/dfg_lut.d.ts +0 -3
  133. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  134. package/dist/shaders/dfg_lut.js +0 -129
  135. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  136. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  137. package/dist/shaders/ltc_mag_lut.js +0 -1033
  138. package/dist/shaders/materials/body.d.ts +0 -2
  139. package/dist/shaders/materials/body.d.ts.map +0 -1
  140. package/dist/shaders/materials/body.js +0 -95
  141. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  142. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  143. package/dist/shaders/materials/cloth_rough.js +0 -69
  144. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  145. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  146. package/dist/shaders/materials/cloth_smooth.js +0 -61
  147. package/dist/shaders/materials/default.d.ts +0 -2
  148. package/dist/shaders/materials/default.d.ts.map +0 -1
  149. package/dist/shaders/materials/default.js +0 -43
  150. package/dist/shaders/materials/eye.d.ts +0 -2
  151. package/dist/shaders/materials/eye.d.ts.map +0 -1
  152. package/dist/shaders/materials/eye.js +0 -60
  153. package/dist/shaders/materials/face.d.ts +0 -2
  154. package/dist/shaders/materials/face.d.ts.map +0 -1
  155. package/dist/shaders/materials/face.js +0 -95
  156. package/dist/shaders/materials/hair.d.ts +0 -2
  157. package/dist/shaders/materials/hair.d.ts.map +0 -1
  158. package/dist/shaders/materials/hair.js +0 -90
  159. package/dist/shaders/materials/metal.d.ts +0 -2
  160. package/dist/shaders/materials/metal.d.ts.map +0 -1
  161. package/dist/shaders/materials/metal.js +0 -77
  162. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  163. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  164. package/dist/shaders/materials/mmd_classic.js +0 -66
  165. package/dist/shaders/materials/stockings.d.ts +0 -2
  166. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  167. package/dist/shaders/materials/stockings.js +0 -122
  168. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  169. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  170. package/dist/shaders/passes/physics-debug.js +0 -69
  171. package/src/shaders/agx-lut.ts +0 -30
  172. package/src/shaders/dfg_lut.ts +0 -131
  173. package/src/shaders/ltc_mag_lut.ts +0 -1035
@@ -1,16 +1,12 @@
1
1
  // Unlit — the texture, at its own brightness, and nothing else.
2
2
  //
3
- // What FOOTAGE needs. A media plane carries pixels somebody already finished
4
- // somewhere else: a gradient painted in Photoshop, a title card, a rendered
5
- // element. Shading it is not a stylistic choice but a mistake — the scene's sun
3
+ // What a picture needs: a media plane carries pixels somebody already finished
4
+ // somewhere else — a gradient painted in Photoshop, a title card, a rendered
5
+ // element. Shading it is not a stylistic choice but a mistake: the scene's sun
6
6
  // would dim one side of a card that has no side, and the world colour would
7
- // tint artwork whose colour is the point. Blender's answer is the same one:
8
- // wire the image straight into an Emission shader and let it out at the value
9
- // it was authored at.
10
- //
11
- // Emission rather than Principled with roughness 1: emission is radiance, so it
12
- // leaves the light loop entirely instead of being an unusually flat surface
13
- // inside it. That is also what lets a card sit in front of a lamp without
7
+ // tint artwork whose colour is the point. Unity's answer is the same one, its
8
+ // Unlit shader: the colour out at the value it was authored at, never in the
9
+ // light loop — which is also what lets a card sit in front of a lamp without
14
10
  // picking up its highlight.
15
11
 
16
12
  import type { ShaderGraph } from "../schema"
@@ -26,12 +22,12 @@ export const UNLIT_GRAPH: ShaderGraph = {
26
22
  // one of its own.
27
23
  { id: "mat", type: "material_diffuse" },
28
24
  { id: "base", type: "mix/multiply", inputs: { fac: 1.0 } },
29
- { id: "emit", type: "emission", inputs: { strength: 1.0 } },
25
+ { id: "emit", type: "vector_math/scale", inputs: { scale: 1.0 } },
30
26
  ],
31
27
  links: [
32
28
  { from: { node: "tex", socket: "color" }, to: { node: "base", socket: "a" } },
33
29
  { from: { node: "mat", socket: "color" }, to: { node: "base", socket: "b" } },
34
- { from: { node: "base", socket: "color" }, to: { node: "emit", socket: "color" } },
30
+ { from: { node: "base", socket: "color" }, to: { node: "emit", socket: "a" } },
35
31
  ],
36
- output: { node: "emit", socket: "color" },
32
+ output: { node: "emit", socket: "vector" },
37
33
  }
@@ -116,18 +116,6 @@ const RAMP_OUTPUTS: Record<string, SockT> = { color: "color", alpha: "float", fa
116
116
  // routing through the BT.601 color→float conversion instead would change the value.
117
117
  const RAMP_SELECT = { color: ".rgb", alpha: ".a", fac_out: ".r" }
118
118
 
119
- /** Principled v2 reflectance, evaluated at compile time when it can be. */
120
- function foldSpecular(ior: string, level: string): string {
121
- const i = Number(ior)
122
- const l = Number(level)
123
- if (!Number.isFinite(i) || !Number.isFinite(l)) return `principled_specular(${ior}, ${level})`
124
- const r = (i - 1) / Math.max(i + 1, 1e-6)
125
- // Snapped to 9 significant digits: the arithmetic leaves double noise
126
- // (0.2² · 2 · 0.5 / 0.08 = 0.5000000000000001) that is meaningless in an f32
127
- // and only makes the emitted shader harder to read.
128
- return fmtFloat(Number(((r * r * 2 * Math.max(l, 0)) / 0.08).toPrecision(9)))
129
- }
130
-
131
119
  const uberNode = (slot: number): NodeSpec => ({
132
120
  inputs: {
133
121
  base: C([1, 1, 1], true),
@@ -176,6 +164,37 @@ const faceSdfNode = (slot: number): NodeSpec => ({
176
164
  })
177
165
  const FACE_SDF_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_face_sdf/${s}`, faceSdfNode(s)]))
178
166
 
167
+ // A packed property map on the slot the type names — the game's material
168
+ // contract: R metal, G perceptual roughness (answered as smoothness, Unity's
169
+ // word for 1 − r), B occlusion, A emission mask, each remapped through the
170
+ // material's own min/max (see rz_property_map). The four
171
+ // plain values are what the surface is WITHOUT a map, so one look serves a
172
+ // converted set that brought maps and a hand-made stage that brought none.
173
+ // Defaults are the game's: the full 0–1 range, and the glow off (min = max = 1).
174
+ const propertyMapNode = (slot: number): NodeSpec => ({
175
+ inputs: {
176
+ uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" },
177
+ metallic: F(0),
178
+ smoothness: F(0.5),
179
+ occlusion: F(1),
180
+ emission: F(0),
181
+ metal_min: F(0),
182
+ metal_max: F(1),
183
+ rough_min: F(0),
184
+ rough_max: F(1),
185
+ ao_min: F(0),
186
+ ao_max: F(1),
187
+ emission_min: F(1),
188
+ emission_max: F(1),
189
+ },
190
+ outputs: { metallic: "float", smoothness: "float", occlusion: "float", emission: "float" },
191
+ outputSelect: { metallic: ".x", smoothness: ".y", occlusion: ".z", emission: ".w" },
192
+ emit: (a) =>
193
+ `rz_property_map(group_tex${slot}(${a.uv}.xy), rz_group_bound${slot}(), vec4f(${a.metallic}, ${a.smoothness}, ${a.occlusion}, ${a.emission}), ` +
194
+ `vec4f(${a.metal_min}, ${a.rough_min}, ${a.ao_min}, ${a.emission_min}), vec4f(${a.metal_max}, ${a.rough_max}, ${a.ao_max}, ${a.emission_max}))`,
195
+ })
196
+ const PROPERTY_MAP_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`property_map/${s}`, propertyMapNode(s)]))
197
+
179
198
  export const NODE_REGISTRY: Record<string, NodeSpec> = {
180
199
  // ── Context inputs (template locals; no emission) ──
181
200
  texture: {
@@ -409,19 +428,43 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
409
428
  outputs: { color: "color" },
410
429
  emit: (a) => `mix(${a.color}, rgb_curve(${a.color}, ${a.y0}, ${a.y1}, ${a.y2}, ${a.y3}, ${a.y4}), ${a.fac})`,
411
430
  },
412
- // UV Map — one layer on a PMX, which is the mesh UV the texture node uses.
431
+ // UV Map — the mesh UV the texture node uses, and the second set: a PMX's
432
+ // second additional UV (xy) where it has one, else the first UV again.
413
433
  uv_map: {
414
434
  inputs: {},
415
- outputs: { uv: "vector" },
416
- contextOutputs: { uv: "vec3f(input.uv, 0.0)" },
435
+ outputs: { uv: "vector", uv2: "vector" },
436
+ contextOutputs: { uv: "vec3f(input.uv, 0.0)", uv2: "vec3f(input.uv2, 0.0)" },
437
+ },
438
+ // Whether each group image slot holds a real map, 1 or 0. A slot nobody
439
+ // filled is a 1×1 white stand-in, and white is not "no map" to a normal or
440
+ // a property map — this is how a look tells the two apart and keeps the
441
+ // surface's own normal where the model brought none.
442
+ image_bound: {
443
+ inputs: {},
444
+ outputs: { slot0: "float", slot1: "float", slot2: "float", slot3: "float" },
445
+ contextOutputs: {
446
+ slot0: "select(0.0, 1.0, rz_group_bound0())",
447
+ slot1: "select(0.0, 1.0, rz_group_bound1())",
448
+ slot2: "select(0.0, 1.0, rz_group_bound2())",
449
+ slot3: "select(0.0, 1.0, rz_group_bound3())",
450
+ },
417
451
  },
418
452
 
419
453
  // ── Normal Map, Vector Transform ──
454
+ // Strength scales the map's tilt, so above 1 steepens it (Unity's
455
+ // _NormalScale, Blender's strength); 0 is the surface's own normal.
420
456
  normal_map: {
421
457
  inputs: { color: C([0.5, 0.5, 1], true), strength: F(1) },
422
458
  outputs: { normal: "vector" },
423
459
  emit: (a) => `node_normal_map(${a.color}, ${a.strength}, n, input.worldPos, input.uv)`,
424
460
  },
461
+ // A normal map packed as game engines store one (DXT5nm/BC5: X in alpha, Y
462
+ // in green, Z rebuilt). Link the image's colour and its alpha.
463
+ "normal_map/packed": {
464
+ inputs: { color: C([1, 0.5, 1], true), alpha: F(0.5), strength: F(1) },
465
+ outputs: { normal: "vector" },
466
+ emit: (a) => `node_normal_map_packed(vec4f(${a.color}, ${a.alpha}), ${a.strength}, n, input.worldPos, input.uv)`,
467
+ },
425
468
  "vector_transform/world_to_camera": {
426
469
  inputs: { vector: V([0, 0, 0], true) },
427
470
  outputs: { vector: "vector" },
@@ -439,27 +482,20 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
439
482
  },
440
483
 
441
484
  // ── Shader nodes. Shaders travel as RGB here (see shader_to_rgb_diffuse), so
442
- // a "transparent" shader is the colour that contributes nothing; the actual
443
- // cutout is the group's alphaMode, which is where transparency belongs in a
444
- // rasteriser without order-independent blending.
445
- bsdf_transparent: { inputs: {}, outputs: { color: "color" }, emit: () => `vec3f(0.0)` },
446
- bsdf_diffuse: {
447
- inputs: { color: C([0.8, 0.8, 0.8], true) },
448
- outputs: { color: "color" },
449
- emit: (a) => `${a.color} * shader_to_rgb_lit(n, l, sun, amb, shadow)`,
450
- },
451
485
 
452
486
  // ── Nodes with no meaning on a PMX, answered honestly with a constant ──
453
487
  // Each returns what Blender returns for the absent case, so a graph that reads
454
488
  // one degrades to a sensible look instead of failing to compile. Documented
455
489
  // rather than silently wrong.
456
490
  //
457
- // attribute: MMD meshes carry no vertex colour layer, so Color reads white and
458
- // Fac reads 1 — the identity for the multiply these usually feed.
491
+ // attribute: the vertex colour. A PMX carries none of its own, so a model
492
+ // keeps it in its first additional UV (RGBA) — what a converted game stage
493
+ // writes there, a plant's occlusion in the alpha. A model without one reads
494
+ // white and alpha 1, the identity for the multiply these usually feed.
459
495
  attribute: {
460
496
  inputs: {},
461
- outputs: { color: "color", fac: "float" },
462
- contextOutputs: { color: "vec3f(1.0)", fac: "1.0" },
497
+ outputs: { color: "color", fac: "float", alpha: "float" },
498
+ contextOutputs: { color: "input.vcolor.rgb", fac: "color_to_value(input.vcolor.rgb)", alpha: "input.vcolor.a" },
463
499
  },
464
500
  // object_info: one model, one instance — Random is the only field with a real
465
501
  // use (per-instance variation) and there are no instances to vary.
@@ -595,6 +631,66 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
595
631
  // Defaults are 104903's skin.
596
632
  ...UBER_NODES,
597
633
  ...FACE_SDF_NODES,
634
+ ...PROPERTY_MAP_NODES,
635
+
636
+ /**
637
+ * Lit — Unity's Lit (URP's PBR), term for term: what a stage, a prop or
638
+ * the weather is shaded with, so a surface authored with Unity's inputs
639
+ * looks here as it does there. See urp_lit in nodes.ts for the formula.
640
+ *
641
+ * smoothness is Unity's (1 − perceptual roughness). emission is added on
642
+ * top, as HDR colour. alpha < 1 is Transparent with Preserve Specular
643
+ * Lighting — the diffuse fades, the reflection does not; the `alpha`
644
+ * output is the coverage to hand the graph's opacity, on a group blending
645
+ * "premultiplied".
646
+ */
647
+ lit: {
648
+ inputs: {
649
+ base_color: C([0.8, 0.8, 0.8], true),
650
+ metallic: F(0),
651
+ smoothness: F(0.5),
652
+ /** A dielectric's reflectance, as Unreal's and HDRP's Specular: 0.5 is
653
+ * URP's fixed 0.04, 1 is 0.08 — glossy cloth, lacquer, wet skin. */
654
+ specular: F(0.5),
655
+ occlusion: F(1),
656
+ emission: C([0, 0, 0]),
657
+ alpha: F(1),
658
+ normal: { type: "vector", contextDefault: "n" },
659
+ },
660
+ outputs: { color: "color", alpha: "float" },
661
+ outputSelect: { color: ".rgb", alpha: ".a" },
662
+ emit: (a) =>
663
+ `urp_lit(${a.base_color}, ${a.metallic}, ${a.smoothness}, ${a.specular}, ${a.occlusion}, ${a.emission}, ${a.alpha}, ` +
664
+ `${a.normal}, l, v, sun, amb, shadow, input.worldPos, true)`,
665
+ takesLight: true,
666
+ },
667
+
668
+ /**
669
+ * The scene's reflection along a direction — the stage's own reflection
670
+ * probe, box-projected to the stage's bounds, where the engine captured one;
671
+ * the sky where it did not. Roughness picks the blur on the game's curve.
672
+ * Its default vector is this surface's reflection, so a glass shell or a
673
+ * water surface wires only what it bends.
674
+ */
675
+ /**
676
+ * The highlights alone: the sun's and the lamps' specular on a GGX lobe
677
+ * (URP's, α = r²), untinted. A water or glass look that computes its own
678
+ * reflection multiplies this by its reflectance for the glints.
679
+ */
680
+ glossy_direct: {
681
+ inputs: { smoothness: F(0.9), normal: { type: "vector", contextDefault: "n" } },
682
+ outputs: { color: "color" },
683
+ emit: (a) => `rz_glossy_direct(${a.normal}, l, v, sun, shadow, input.worldPos, 1.0 - (${a.smoothness}))`,
684
+ takesLight: true,
685
+ },
686
+ reflection_probe: {
687
+ inputs: {
688
+ vector: { type: "vector", contextDefault: "reflect(-v, n)" },
689
+ smoothness: F(1),
690
+ },
691
+ outputs: { color: "color" },
692
+ emit: (a) => `rzProbeSpecular(${a.vector}, input.worldPos, 1.0 - (${a.smoothness}))`,
693
+ },
598
694
 
599
695
  // ── Blender 5.2 colour utilities ──
600
696
  separate_color: {
@@ -793,26 +889,6 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
793
889
  outputs: { color: "color" },
794
890
  emit: (a) => `${a.a} + vec3f(${a.b})`,
795
891
  },
796
- // Blender Emission shader: color × strength. In the ShaderToRGB-era ports the
797
- // emission result feeds a Mix Shader directly as radiance. Color may be a literal
798
- // (body's rim tints) or linked.
799
- emission: {
800
- inputs: { color: C([1, 1, 1]), strength: F(1) },
801
- outputs: { color: "color" },
802
- emit: (a) => `${a.color} * ${a.strength}`,
803
- },
804
- // Blender Add Shader — radiance sum of two evaluated shading results.
805
- add_shader: {
806
- inputs: { a: C([0, 0, 0], true), b: C([0, 0, 0], true) },
807
- outputs: { color: "color" },
808
- emit: (a) => `${a.a} + ${a.b}`,
809
- },
810
- // Mix Shader — plain lerp between two evaluated shading results.
811
- mix_shader: {
812
- inputs: { fac: F(0.5), a: C([0, 0, 0], true), b: C([0, 0, 0]) },
813
- outputs: { color: "color" },
814
- emit: (a) => `mix(${a.a}, ${a.b}, ${a.fac})`,
815
- },
816
892
 
817
893
  // ── View-dependent scalars ──
818
894
  fresnel: { inputs: { ior: F(1.45) }, outputs: { value: "float" }, emit: (a) => `fresnel(${a.ior}, n, v)` },
@@ -833,38 +909,19 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
833
909
  emit: (a) => `layer_weight_facing(${a.blend}, ${a.normal}, v)`,
834
910
  },
835
911
 
836
- /**
837
- * The world's radiance from a direction — Blender's Environment Texture, read
838
- * off the scene's own sky rather than off an image a material carries.
839
- *
840
- * ITS DEFAULT VECTOR IS THE REFLECTION, which is the whole point: wire
841
- * nothing and it answers what this surface reflects. The roughness input
842
- * picks how blurred the answer is; at 0 it is a mirror.
843
- *
844
- * A scene with no HDRI has no sky to reflect, and this returns the flat world
845
- * colour — the same value the ambient already carries, so such a scene looks
846
- * exactly as it did.
847
- */
848
- environment: {
849
- inputs: {
850
- vector: { type: "vector", contextDefault: "reflect(-v, n)" },
851
- roughness: F(0),
852
- },
853
- outputs: { color: "color" },
854
- emit: (a) => `rzWorldSpecular(${a.vector}, ${a.roughness})`,
855
- },
856
912
 
857
913
  // ── Lighting capture ──
858
914
  /**
859
- * Shader → RGB on a white diffuse closure, reduced to a scalar.
860
- *
861
- * Rec.709 luminance, kept exactly as it was: every shipped preset ramps this,
862
- * and changing the weights would move every terminator in the library.
915
+ * Lambert — URP's LightingLambert for the main light, plus the ambient:
916
+ * what a white surface facing `normal` receives. A toon look ramps `value`
917
+ * (its luminance) into a terminator, or tints by `color`. Shader Graph's
918
+ * custom-lighting idiom, in one node.
863
919
  */
864
- shader_to_rgb_diffuse: {
865
- inputs: {},
866
- outputs: { value: "float" },
867
- emit: () => `shader_to_rgb_diffuse(n, l, sun, amb, shadow)`,
920
+ lambert: {
921
+ inputs: { normal: { type: "vector", contextDefault: "n" } },
922
+ outputs: { color: "color", value: "float" },
923
+ outputSelect: { color: ".rgb", value: ".a" },
924
+ emit: (a) => `urp_lambert(${a.normal}, l, sun, amb, shadow)`,
868
925
  },
869
926
  /**
870
927
  * Hands this surface to the screen-space scattering pass: the colour passes
@@ -878,24 +935,6 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
878
935
  emit: (a) => `rz_subsurface(${a.strength}, ${a.color})`,
879
936
  },
880
937
 
881
- /**
882
- * The same closure as a COLOUR, which is what Blender's Shader to RGB
883
- * actually returns.
884
- *
885
- * A warm sun against a cool ambient radiates warm light and cool shadow, and
886
- * that colour IS the look in most of this style. Reducing it to luminance
887
- * first — the only thing reachable before — leaves every ramp working on grey.
888
- *
889
- * Linking this into a scalar socket still yields a float, because Blender's
890
- * own implicit Color → Value conversion (BT.601) happens at the link. So the
891
- * two nodes differ only in whether the graph wants the colour or the scalar,
892
- * and each gets Blender's answer for its own question.
893
- */
894
- shader_to_rgb: {
895
- inputs: {},
896
- outputs: { color: "color" },
897
- emit: () => `shader_to_rgb_lit(n, l, sun, amb, shadow)`,
898
- },
899
938
 
900
939
  // ── Vector ──
901
940
  separate_xyz: {
@@ -956,70 +995,6 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
956
995
  // ── Principled BSDF (frozen 3.6 legacy-EEVEE semantics: eval_principled port) ──
957
996
  // normal defaults to the template's shading normal; link a bump/normal_map chain
958
997
  // to perturb it (body/cloth_rough noise bump).
959
- /**
960
- * Principled BSDF, Blender 5.2 socket names.
961
- *
962
- * v2 (4.0+) renamed most of what a graph touches and re-derived one of them,
963
- * which is exactly where a transcribed port goes quietly wrong: Specular
964
- * became "Specular IOR Level" and no longer sets reflectance directly — IOR
965
- * does, and the level scales it. So f0 is computed the v2 way here, and the
966
- * defaults land on 0.04 for ior 1.5 at level 0.5, matching both versions at
967
- * their defaults while tracking v2 when a preset moves either.
968
- *
969
- * Emission Strength defaults to 0 in v2 where 3.6's Emission was visible, so a
970
- * naive port turns every emissive material black. Following v2 means honouring
971
- * that default and letting the preset say otherwise.
972
- *
973
- * Not implemented, and not silently wrong — a graph setting these gets the
974
- * base BSDF rather than a wrong approximation of them: Coat, Transmission,
975
- * Subsurface, Anisotropic, Thin Film. They need path-traced or
976
- * multi-scatter machinery this renderer does not have.
977
- */
978
- principled: {
979
- inputs: {
980
- base_color: C([0.8, 0.8, 0.8], true),
981
- metallic: F(0),
982
- roughness: F(0.5),
983
- ior: F(1.5),
984
- specular_ior_level: F(0.5),
985
- sheen_weight: F(0),
986
- sheen_tint: F(0),
987
- emission_color: C([1, 1, 1]),
988
- emission_strength: F(0),
989
- normal: { type: "vector", contextDefault: "n" },
990
- /** Ours, not Blender's: caps firefly speculars from noise-bumped NDF
991
- * aliasing, which EEVEE hides behind TAA and we have none. */
992
- spec_clamp: F(1e30),
993
- /** Ours: how roughness picks the reflection's blur. 0 is the engine's
994
- * sqrt ramp; 1 is Unity's probe curve, p·(1.7−0.7p) mip-steps per
995
- * eighth, stopping where a cube's last face texel does — what a stage
996
- * ripped from a Unity game was authored against. See rzWorldSpecularLod. */
997
- reflection_lod: F(0),
998
- /** Ours: 1 shades the sun's and the lamps' highlights with URP's
999
- * direct-light BRDF, as a stage from a Unity game was lit — see bsdf_urp. */
1000
- unity_direct: F(0),
1001
- },
1002
- outputs: { color: "color" },
1003
- emit: (a) => {
1004
- // Fold the reflectance when both sockets are literals, which is nearly
1005
- // always. It keeps the emitted WGSL as tight as the 3.6 form was — and at
1006
- // the defaults it folds to exactly 0.5, which is what 3.6's Specular held,
1007
- // so the two versions agree byte-for-byte where a preset changed nothing.
1008
- const spec = foldSpecular(a.ior, a.specular_ior_level)
1009
- const bsdf =
1010
- `eval_principled(PrincipledIn(${a.base_color}, ${a.metallic}, ` +
1011
- `${spec}, ${a.roughness}, ` +
1012
- `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}, ${a.reflection_lod}, ${a.unity_direct}), ${a.normal}, l, v, sun, amb, shadow, input.worldPos)`
1013
- // v2 defaults Emission Strength to 0, which is the overwhelming case. Emit
1014
- // the term only when it can do something, so the common shader carries no
1015
- // dead add and the output stays readable.
1016
- const lit = a.emission_strength === "0.0" ? bsdf : `${bsdf} + ${a.emission_color} * ${a.emission_strength}`
1017
- // Unity mode also ends as SimPipeline's surfaces end: saturate()d, so no
1018
- // lit or glowing surface passes 1.0 before the post — its bloom works on
1019
- // what is left under that. A literal, as every stage graph states it.
1020
- return a.unity_direct === "1.0" ? `min(${lit}, vec3f(1.0))` : lit
1021
- },
1022
- },
1023
998
  }
1024
999
 
1025
1000
  // ─── Blender socket parity ────────────────────────────────────────────
@@ -12,8 +12,11 @@ export type RenderClass = "auto" | "eye" | "hair"
12
12
  export type AlphaMode = "opaque" | "hashed"
13
13
 
14
14
  /** How a group's colour meets the frame. "over" is the ordinary alpha blend;
15
- * "additive" adds its light and never reads alpha — see StyleGroup.blend. */
16
- export type StyleBlend = "over" | "additive"
15
+ * "additive" adds its light and never reads alpha; "premultiplied" takes a
16
+ * colour already weighted by its own coverage (One, OneMinusSrcAlpha) — a
17
+ * game's glass, whose reflection must not fade with its transparency. See
18
+ * StyleGroup.blend. */
19
+ export type StyleBlend = "over" | "additive" | "premultiplied"
17
20
 
18
21
  /** Descriptive manifest for hosts (reze-design) so the render-class picker is data-driven
19
22
  * instead of hardcoding strings. The effect implementations stay engine-side; this only
@@ -191,7 +191,13 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
191
191
  // painted sheet — is Blender's Emission shader, which no lamp reaches; adding
192
192
  // the lamps to it turned X340's floor shadow, a white-RGB picture at a soft
193
193
  // alpha, into a glowing disc under the spot above it.
194
- const LIT = takesLight ? ` + rzLightsDiffuseOnce(input.worldPos, n) * albedo` : ""
194
+ //
195
+ // The model's fill (Engine.setModelFill) rides here too, as brightness: the
196
+ // surface colour times the fill, on every pixel of her alike. In the graph's
197
+ // ambient an NPR ramp would read it as LIGHT — sliding it would move her
198
+ // shadows, and under a hard step flip whole regions at once. After the graph
199
+ // it lifts her evenly and leaves every shadow where it is.
200
+ const LIT = takesLight ? ` + (rzLightsDiffuseOnce(input.worldPos, n) + objectLight.fill.rgb) * albedo` : ""
195
201
 
196
202
  const ALBEDO = ` let albedo = tex_color;
197
203
  `
@@ -202,8 +208,12 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
202
208
  const BURN = ` + RZ_BURN_COLOR * rz_burn`
203
209
  // The scene fog, last, as the game lays it over a lit surface; an
204
210
  // emission-only graph takes none (the game's effect shaders have no fog).
211
+ // A premultiplied colour takes its fog premultiplied too: the haze a pane
212
+ // adds is the haze over its own coverage, the rest arrives through what is
213
+ // behind it (the game scales its fog by the output alpha the same way).
214
+ const fogOver = blend === "premultiplied" ? " * out.color.a" : ""
205
215
  const FOG = takesLight
206
- ? ` out.color = vec4f(mix(mix(out.color.rgb, light.fog[0].rgb, input.fog.x), light.fog[2].rgb, input.fog.y), out.color.a);\n`
216
+ ? ` out.color = vec4f(mix(mix(out.color.rgb, light.fog[0].rgb${fogOver}, input.fog.x), light.fog[2].rgb${fogOver}, input.fog.y), out.color.a);\n`
207
217
  : ""
208
218
  if (renderClass === "hair") {
209
219
  return `${ALBEDO} var outAlpha = ${alphaBase};
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type BloomOptions,
15
15
  type DepthOfFieldOptions,
16
16
  type ViewTransformOptions,
17
+ type ViewTransformName,
17
18
  type LoadModelFromFilesOptions,
18
19
  type MaterialPreset,
19
20
  type MaterialPresetMap,
package/src/model.ts CHANGED
@@ -3,6 +3,7 @@ import { Engine } from "./engine"
3
3
  import { joinAssetPath, type AssetReader } from "./asset-reader"
4
4
  import { Rigidbody, Joint } from "./physics"
5
5
  import { IKSolverSystem } from "./ik-solver"
6
+ import { buildOutlineVertices } from "./outline-normals"
6
7
  import { VMDLoader, type VMDKeyFrame } from "./vmd-loader"
7
8
  import { VMDWriter, type VmdTrackSelection } from "./vmd-writer"
8
9
  import {
@@ -564,8 +565,12 @@ export class Model {
564
565
  morphing: Morphing,
565
566
  rigidbodies: Rigidbody[] = [],
566
567
  joints: Joint[] = [],
567
- loadWarnings: string[] = []
568
+ loadWarnings: string[] = [],
569
+ edgeScales: Float32Array | null = null,
570
+ additionalUvs: Float32Array[] = []
568
571
  ) {
572
+ this.edgeScales = edgeScales
573
+ this.additionalUvs = additionalUvs
569
574
  // Store base vertex data (original positions before morphing)
570
575
  this.baseVertexData = new Float32Array(vertexData)
571
576
  this.vertexData = vertexData
@@ -1001,6 +1006,28 @@ export class Model {
1001
1006
  return this.vertexData
1002
1007
  }
1003
1008
 
1009
+ /** PMX per-vertex edge scale, when the source had one. */
1010
+ private edgeScales: Float32Array | null = null
1011
+ private additionalUvs: Float32Array[]
1012
+
1013
+ /** A PMX additional UV channel (1–4) as loaded, four floats a vertex — or
1014
+ * null when the file has fewer. Its morphs (types 4–7) are in getMorphing(),
1015
+ * with uvOffsets; the engine applies neither, a native look reads both. */
1016
+ getAdditionalUv(channel: number): Float32Array | null {
1017
+ return this.additionalUvs[channel - 1] ?? null
1018
+ }
1019
+ private outlineVertices: Float32Array<ArrayBuffer> | null = null
1020
+
1021
+ /**
1022
+ * The outline hull's own vertex stream: per vertex, the smoothed rest-pose
1023
+ * normal (xyz) and the PMX edge scale (w). See outline-normals.ts. Built on
1024
+ * first ask — only models with an edge-flagged material ever ask.
1025
+ */
1026
+ getOutlineVertices(): Float32Array<ArrayBuffer> {
1027
+ this.outlineVertices ??= buildOutlineVertices(this.baseVertexData, VERTEX_STRIDE, this.indexData, this.edgeScales ?? undefined)
1028
+ return this.outlineVertices
1029
+ }
1030
+
1004
1031
  getTextures(): Texture[] {
1005
1032
  return this.textures
1006
1033
  }
@@ -1115,6 +1142,28 @@ export class Model {
1115
1142
  return this.runtimeSkeleton.worldMatrices[idx].getPosition()
1116
1143
  }
1117
1144
 
1145
+ /**
1146
+ * The point `distance` model units down a bone's own axis, posed — where
1147
+ * `#anchor <bone> along <d>` puts an effect. The axis is the bone's rest tail
1148
+ * direction (the way a PMX editor draws it pointing) turned by the bone's
1149
+ * pose, the same mapping the named-points tips use. Model space, like
1150
+ * getBoneWorldPosition. A bone with no tail has no axis: the joint itself.
1151
+ */
1152
+ getBonePointAlong(boneName: string, distance: number): Vec3 | null {
1153
+ const idx = this.runtimeSkeleton.nameIndex[boneName]
1154
+ if (idx === undefined || idx < 0) return null
1155
+ const w = this.runtimeSkeleton.worldMatrices[idx].values
1156
+ const t = this.skeleton.bones[idx]?.tail
1157
+ const len = t ? Math.hypot(t[0], t[1], t[2]) : 0
1158
+ if (!t || len < 1e-6) return new Vec3(w[12], w[13], w[14])
1159
+ const k = distance / len
1160
+ return new Vec3(
1161
+ w[12] + (w[0] * t[0] + w[4] * t[1] + w[8] * t[2]) * k,
1162
+ w[13] + (w[1] * t[0] + w[5] * t[1] + w[9] * t[2]) * k,
1163
+ w[14] + (w[2] * t[0] + w[6] * t[1] + w[10] * t[2]) * k,
1164
+ )
1165
+ }
1166
+
1118
1167
  /**
1119
1168
  * A bone's forward axis, normalised — which way a foot points, where a head
1120
1169
  * looks. Model space, like getBoneWorldPosition: the caller composes the model
@@ -0,0 +1,110 @@
1
+ // The inverted hull's own push direction, one per vertex, built once at load.
2
+ //
3
+ // The hull is the model pushed out along a normal. Pushed along the MESH's
4
+ // normals it tears wherever the mesh carries two vertices at one position —
5
+ // every hard edge and every UV seam — because the two copies go two ways and
6
+ // the gap between them shows the body through the line: the jaw, the finger
7
+ // joints, the parting in the hair. Aether Gazer bakes a smoothed normal into
8
+ // vertex colour for exactly this (Character/Debug.shader, pass "Outline"); a
9
+ // PMX has nowhere to carry one, so it is computed here instead: the normals of
10
+ // every vertex at one position, averaged, so the copies agree and the hull
11
+ // stays closed.
12
+ //
13
+ // Each vertex's vote is weighted by the corner angles it owns, which is what
14
+ // makes a hard corner come out as the corner's true bisector rather than
15
+ // leaning toward whichever side happened to be cut into more triangles.
16
+ //
17
+ // Rest pose only, as the game's is: a morph moves the positions and leaves this
18
+ // alone, and skinning turns it exactly as it turns the normal.
19
+
20
+ /** Positions closer than this (model units) are one position. */
21
+ export const OUTLINE_WELD_EPSILON = 1e-4
22
+
23
+ /**
24
+ * Smoothed outline normals, packed with the PMX per-vertex edge scale:
25
+ * xyz = unit direction, w = edge scale — four floats per vertex.
26
+ *
27
+ * @param vertexData interleaved [pos3, normal3, uv2] per vertex
28
+ * @param indices triangle list
29
+ * @param edgeScales per-vertex PMX edge scale, or omitted for 1
30
+ */
31
+ export function buildOutlineVertices(
32
+ vertexData: ArrayLike<number>,
33
+ stride: number,
34
+ indices: ArrayLike<number>,
35
+ edgeScales?: ArrayLike<number>,
36
+ ): Float32Array<ArrayBuffer> {
37
+ const n = Math.floor(vertexData.length / stride)
38
+ const out = new Float32Array(n * 4)
39
+
40
+ // Corner angles each vertex owns. A vertex no triangle uses keeps a token
41
+ // weight, so it still has a direction of its own.
42
+ const weight = new Float32Array(n).fill(1e-6)
43
+ for (let t = 0; t + 2 < indices.length; t += 3) {
44
+ const a = indices[t], b = indices[t + 1], c = indices[t + 2]
45
+ if (a >= n || b >= n || c >= n) continue
46
+ weight[a] += cornerAngle(vertexData, stride, a, b, c)
47
+ weight[b] += cornerAngle(vertexData, stride, b, c, a)
48
+ weight[c] += cornerAngle(vertexData, stride, c, a, b)
49
+ }
50
+
51
+ // Weld by quantized position.
52
+ const inv = 1 / OUTLINE_WELD_EPSILON
53
+ const group = new Int32Array(n)
54
+ const ids = new Map<string, number>()
55
+ let groups = 0
56
+ for (let i = 0; i < n; i++) {
57
+ const o = i * stride
58
+ const key = `${Math.round(vertexData[o] * inv)},${Math.round(vertexData[o + 1] * inv)},${Math.round(vertexData[o + 2] * inv)}`
59
+ let g = ids.get(key)
60
+ if (g === undefined) {
61
+ g = groups++
62
+ ids.set(key, g)
63
+ }
64
+ group[i] = g
65
+ }
66
+
67
+ const sum = new Float32Array(groups * 3)
68
+ for (let i = 0; i < n; i++) {
69
+ const o = i * stride
70
+ const w = weight[i]
71
+ const g = group[i] * 3
72
+ sum[g] += vertexData[o + 3] * w
73
+ sum[g + 1] += vertexData[o + 4] * w
74
+ sum[g + 2] += vertexData[o + 5] * w
75
+ }
76
+
77
+ for (let i = 0; i < n; i++) {
78
+ const o = i * stride
79
+ const nx = vertexData[o + 3], ny = vertexData[o + 4], nz = vertexData[o + 5]
80
+ const g = group[i] * 3
81
+ let x = sum[g], y = sum[g + 1], z = sum[g + 2]
82
+ const len = Math.hypot(x, y, z)
83
+ // Two sheets back to back at one position (a double-sided skirt, a shell
84
+ // and its lining) cancel to nothing, or to a direction pointing INTO one of
85
+ // them. Either way the welded answer is wrong for this vertex, and its own
86
+ // normal is the honest one.
87
+ if (len < 1e-6 || x * nx + y * ny + z * nz <= 0) {
88
+ x = nx
89
+ y = ny
90
+ z = nz
91
+ }
92
+ const l = Math.hypot(x, y, z) || 1
93
+ out[i * 4] = x / l
94
+ out[i * 4 + 1] = y / l
95
+ out[i * 4 + 2] = z / l
96
+ out[i * 4 + 3] = edgeScales ? edgeScales[i] : 1
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** The angle at vertex `a` of triangle (a, b, c), in radians. */
102
+ function cornerAngle(v: ArrayLike<number>, stride: number, a: number, b: number, c: number): number {
103
+ const oa = a * stride, ob = b * stride, oc = c * stride
104
+ const ux = v[ob] - v[oa], uy = v[ob + 1] - v[oa + 1], uz = v[ob + 2] - v[oa + 2]
105
+ const wx = v[oc] - v[oa], wy = v[oc + 1] - v[oa + 1], wz = v[oc + 2] - v[oa + 2]
106
+ const lu = Math.hypot(ux, uy, uz), lw = Math.hypot(wx, wy, wz)
107
+ if (lu < 1e-12 || lw < 1e-12) return 0
108
+ const cos = (ux * wx + uy * wy + uz * wz) / (lu * lw)
109
+ return Math.acos(Math.max(-1, Math.min(1, cos)))
110
+ }