reze-engine 0.63.0 → 0.65.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 (106) hide show
  1. package/dist/engine.d.ts +226 -96
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1187 -732
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +4 -1
  6. package/dist/graph/registry.d.ts +3 -0
  7. package/dist/graph/registry.d.ts.map +1 -1
  8. package/dist/graph/registry.js +133 -0
  9. package/dist/graph/schema.d.ts +12 -0
  10. package/dist/graph/schema.d.ts.map +1 -1
  11. package/dist/graph/slots.d.ts.map +1 -1
  12. package/dist/graph/slots.js +15 -18
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/model.d.ts +10 -1
  17. package/dist/model.d.ts.map +1 -1
  18. package/dist/model.js +15 -1
  19. package/dist/outline-normals.d.ts +12 -0
  20. package/dist/outline-normals.d.ts.map +1 -0
  21. package/dist/outline-normals.js +100 -0
  22. package/dist/pmx-loader.d.ts +3 -0
  23. package/dist/pmx-loader.d.ts.map +1 -1
  24. package/dist/pmx-loader.js +7 -2
  25. package/dist/shaders/lights.d.ts +3 -2
  26. package/dist/shaders/lights.d.ts.map +1 -1
  27. package/dist/shaders/lights.js +16 -3
  28. package/dist/shaders/materials/common.d.ts.map +1 -1
  29. package/dist/shaders/materials/common.js +65 -56
  30. package/dist/shaders/materials/nodes.d.ts +1 -1
  31. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  32. package/dist/shaders/materials/nodes.js +210 -0
  33. package/dist/shaders/passes/bloom.d.ts +4 -3
  34. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  35. package/dist/shaders/passes/bloom.js +58 -87
  36. package/dist/shaders/passes/composite.d.ts.map +1 -1
  37. package/dist/shaders/passes/composite.js +122 -64
  38. package/dist/shaders/passes/cull.d.ts +1 -1
  39. package/dist/shaders/passes/cull.d.ts.map +1 -1
  40. package/dist/shaders/passes/cull.js +7 -3
  41. package/dist/shaders/passes/ground.d.ts.map +1 -1
  42. package/dist/shaders/passes/ground.js +32 -55
  43. package/dist/shaders/passes/mirror.d.ts +18 -12
  44. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  45. package/dist/shaders/passes/mirror.js +59 -13
  46. package/dist/shaders/passes/outline.d.ts.map +1 -1
  47. package/dist/shaders/passes/outline.js +71 -14
  48. package/dist/shaders/passes/particles.d.ts +1 -1
  49. package/dist/shaders/passes/particles.js +1 -1
  50. package/dist/shaders/scene-light-api.d.ts +18 -10
  51. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  52. package/dist/shaders/scene-light-api.js +58 -46
  53. package/dist/shadow-cascades.d.ts +47 -20
  54. package/dist/shadow-cascades.d.ts.map +1 -1
  55. package/dist/shadow-cascades.js +36 -13
  56. package/dist/unity/character.d.ts +59 -0
  57. package/dist/unity/character.d.ts.map +1 -0
  58. package/dist/unity/character.js +120 -0
  59. package/dist/unity/globals.d.ts +93 -0
  60. package/dist/unity/globals.d.ts.map +1 -0
  61. package/dist/unity/globals.js +340 -0
  62. package/dist/unity/host.d.ts +152 -0
  63. package/dist/unity/host.d.ts.map +1 -0
  64. package/dist/unity/host.js +529 -0
  65. package/dist/unity/looks.d.ts +93 -0
  66. package/dist/unity/looks.d.ts.map +1 -0
  67. package/dist/unity/looks.js +312 -0
  68. package/dist/unity/skin.d.ts +9 -0
  69. package/dist/unity/skin.d.ts.map +1 -0
  70. package/dist/unity/skin.js +120 -0
  71. package/dist/unity/stage.d.ts +155 -0
  72. package/dist/unity/stage.d.ts.map +1 -0
  73. package/dist/unity/stage.js +410 -0
  74. package/dist/unity/state.d.ts +22 -0
  75. package/dist/unity/state.d.ts.map +1 -0
  76. package/dist/unity/state.js +170 -0
  77. package/package.json +1 -1
  78. package/src/engine.ts +1535 -964
  79. package/src/graph/compile.ts +3 -1
  80. package/src/graph/registry.ts +142 -0
  81. package/src/graph/schema.ts +9 -0
  82. package/src/graph/slots.ts +15 -18
  83. package/src/index.ts +12 -0
  84. package/src/model.ts +18 -1
  85. package/src/outline-normals.ts +110 -0
  86. package/src/pmx-loader.ts +8 -2
  87. package/src/shaders/lights.ts +16 -3
  88. package/src/shaders/materials/common.ts +65 -57
  89. package/src/shaders/materials/nodes.ts +210 -0
  90. package/src/shaders/passes/bloom.ts +59 -87
  91. package/src/shaders/passes/composite.ts +122 -64
  92. package/src/shaders/passes/cull.ts +7 -3
  93. package/src/shaders/passes/ground.ts +32 -55
  94. package/src/shaders/passes/mirror.ts +60 -13
  95. package/src/shaders/passes/outline.ts +71 -14
  96. package/src/shaders/passes/particles.ts +1 -1
  97. package/src/shaders/scene-light-api.ts +59 -46
  98. package/src/shadow-cascades.ts +61 -32
  99. package/src/unity/character.ts +169 -0
  100. package/src/unity/globals.ts +443 -0
  101. package/src/unity/host.ts +630 -0
  102. package/src/unity/looks.ts +391 -0
  103. package/src/unity/skin.ts +131 -0
  104. package/src/unity/stage.ts +604 -0
  105. package/src/unity/state.ts +172 -0
  106. package/src/shaders/agx-lut.ts +0 -30
@@ -344,9 +344,11 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
344
344
  }
345
345
 
346
346
  const lines: string[] = []
347
+ let nodeTakesLight = false
347
348
  for (const id of order) {
348
349
  const node = nodes.get(id)!
349
350
  const s = spec(node)
351
+ if (s.takesLight) nodeTakesLight = true
350
352
  if (s.contextOutputs) continue
351
353
  const args: Record<string, Resolved> = {}
352
354
  for (const [socket, input] of Object.entries(s.inputs)) args[socket] = resolveInput(node, socket, input.type)
@@ -360,7 +362,7 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
360
362
  const fsBody = lines.join("\n")
361
363
  // A graph takes light when its body reads any: the four shading nodes all
362
364
  // pass `sun, amb`, and a hand-written node reaching for the lamps names them.
363
- const takesLight = /\bsun\b|\bamb\b|rzLight|rzLamp/.test(fsBody)
365
+ const takesLight = nodeTakesLight || /\bsun\b|\bamb\b|rzLight|rzLamp/.test(fsBody)
364
366
  const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over", takesLight)
365
367
  return { ok: true, wgsl, fsBody, slotMap, diagnostics, prunedNodes }
366
368
  }
@@ -26,6 +26,9 @@ export type NodeSpec = {
26
26
  contextOutputs?: Record<string, string>
27
27
  /** Swizzle applied to the node's variable per output socket (default: none). */
28
28
  outputSelect?: Record<string, string>
29
+ /** The node shades by the scene's light without naming it: its graph takes
30
+ * the lamps and the fog as the four shading nodes' graphs do. */
31
+ takesLight?: boolean
29
32
  }
30
33
 
31
34
  // ─── Literal formatting ───────────────────────────────────────────────
@@ -125,6 +128,54 @@ function foldSpecular(ior: string, level: string): string {
125
128
  return fmtFloat(Number(((r * r * 2 * Math.max(l, 0)) / 0.08).toPrecision(9)))
126
129
  }
127
130
 
131
+ const uberNode = (slot: number): NodeSpec => ({
132
+ inputs: {
133
+ base: C([1, 1, 1], true),
134
+ metallic: F(0),
135
+ roughness: F(0.6),
136
+ occlusion: F(1),
137
+ normal: { type: "vector", contextDefault: "n" },
138
+ direction: { type: "vector", requiresLink: true },
139
+ // Unity's v into the ramp: the property map's alpha, which picks a
140
+ // surface's band (skin 0.702, hair and pale cloth 0.506).
141
+ row: F(0.702),
142
+ receive_shadow: F(1),
143
+ rim_mask: F(1),
144
+ rim_mid: F(0.448),
145
+ rim_width: F(0.11),
146
+ rim_tint: C([0.9623, 0.9214, 0.9214]),
147
+ rim_intensity: F(1),
148
+ rim_albedo: F(0.9),
149
+ rim_in_light: F(1),
150
+ emission: C([0, 0, 0]),
151
+ reflection: F(1),
152
+ // A face's SDF shade (ag_face_sdf); left at -1, the material reads N·L.
153
+ shade: F(-1),
154
+ // 1: the game's FACE_MODE — no GGX, the environment at a flat 0.0157.
155
+ face: F(0),
156
+ },
157
+ outputs: { color: "color" },
158
+ emit: (a) =>
159
+ `ag_uber(${slot}u, ${a.base}, ${a.metallic}, ${a.roughness}, ${a.occlusion}, ${a.normal}, ${a.direction}, ${a.row}, ${a.receive_shadow}, ` +
160
+ `${a.rim_mask}, ${a.rim_mid}, ${a.rim_width}, ${a.rim_tint}, ${a.rim_intensity}, ${a.rim_albedo}, ${a.rim_in_light}, ${a.emission}, ${a.reflection}, ${a.shade}, ${a.face}, input.worldPos, v)`,
161
+ takesLight: true,
162
+ })
163
+ const UBER_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_uber/${s}`, uberNode(s)]))
164
+ // The face's SDF shade, the image on the slot the type names — 104903's face
165
+ // template (SDF_04_shaonv) by default in the AG pack.
166
+ const faceSdfNode = (slot: number): NodeSpec => ({
167
+ inputs: {
168
+ uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" },
169
+ direction: { type: "vector", requiresLink: true },
170
+ normal: { type: "vector", contextDefault: "n" },
171
+ smoothness: F(0.1),
172
+ invert: F(0),
173
+ },
174
+ outputs: { value: "float" },
175
+ emit: (a) => `ag_face_sdf(${slot}u, ${a.uv}.xy, ${a.direction}, ${a.normal}, ${a.smoothness}, ${a.invert})`,
176
+ })
177
+ const FACE_SDF_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_face_sdf/${s}`, faceSdfNode(s)]))
178
+
128
179
  export const NODE_REGISTRY: Record<string, NodeSpec> = {
129
180
  // ── Context inputs (template locals; no emission) ──
130
181
  texture: {
@@ -454,6 +505,97 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
454
505
  emit: (a) => `group_tex3(${a.uv}.xy)`,
455
506
  },
456
507
 
508
+ // ── Aether Gazer's character shading ──
509
+ // The game's character shader (SimPipeline/Character/Debug, which most of its
510
+ // characters wear), taken apart into the steps it runs — see ag_* in
511
+ // nodes.ts for each step's derivation from the decompiled pass. A graph
512
+ // composes them as the game did: a ramp lookup driven by the key light, a
513
+ // matcap, a reflection, the rim and the fill, with the ramp and matcap
514
+ // images on the style group's slots.
515
+
516
+ // The key light: CharacterEffect's direction, held in a frame that turns
517
+ // with the camera about the vertical (sim_TowardMatrix) — the game lights a
518
+ // character from where the viewer is, not from the scene's sun.
519
+ ag_key_light: {
520
+ inputs: { inclination: F(24), azimuth: F(-28) },
521
+ outputs: { direction: "vector" },
522
+ emit: (a) => `ag_key_light(${a.inclination}, ${a.azimuth})`,
523
+ },
524
+ // Where on the shadow ramp this point falls: N·L under the mask's occlusion,
525
+ // and the sun's cast shadow on top when the character receives it.
526
+ ag_shade_term: {
527
+ inputs: {
528
+ normal: { type: "vector", contextDefault: "n" },
529
+ direction: { type: "vector", requiresLink: true },
530
+ occlusion: F(1),
531
+ occlusion_scale: F(1),
532
+ receive_shadow: F(1),
533
+ },
534
+ outputs: { value: "float" },
535
+ emit: (a) => `ag_shade_term(${a.normal}, ${a.direction}, ${a.occlusion}, ${a.occlusion_scale}, ${a.receive_shadow}, input.worldPos)`,
536
+ takesLight: true,
537
+ },
538
+ // A ramp image's coordinate: u from the shade term, v the ramp's row (the
539
+ // game's property map picks it per texel; a PMX carries one per material).
540
+ ag_ramp_uv: {
541
+ inputs: { value: F(0), row: F(0.5) },
542
+ outputs: { uv: "vector" },
543
+ emit: (a) => `ag_ramp_uv(${a.value}, ${a.row})`,
544
+ },
545
+ // The view-space reflection the matcap, the reflection map, the rim and the
546
+ // fill all read: its uv into a sphere image, and how much it faces the eye.
547
+ ag_view_reflection: {
548
+ inputs: { normal: { type: "vector", contextDefault: "n" } },
549
+ outputs: { uv: "vector", facing: "float" },
550
+ outputSelect: { uv: ".xyz", facing: ".w" },
551
+ emit: (a) => `ag_view_reflection(${a.normal}, v)`,
552
+ },
553
+ // The matcap's lift: a metal's shine, at least 1, where the mask says.
554
+ ag_matcap: {
555
+ inputs: { color: C([1, 1, 1], true), matcap: C([1, 1, 1]), strength: F(1), mask: F(1) },
556
+ outputs: { color: "color" },
557
+ emit: (a) => `ag_matcap(${a.color}, ${a.matcap}, ${a.strength}, ${a.mask})`,
558
+ },
559
+ // The rim: the two fixed screen-side directions CharacterEffect sets, cut to
560
+ // the silhouette, laid on as a colour dodge where the mask allows.
561
+ ag_rim: {
562
+ inputs: {
563
+ color: C([1, 1, 1], true),
564
+ facing: F(1),
565
+ uv: { type: "vector", requiresLink: true },
566
+ rim_color: C([0.8584906, 0.8584906, 0.8584906]),
567
+ threshold: F(0.1),
568
+ fade: F(0.113),
569
+ range: F(0.444),
570
+ inclination: F(0),
571
+ azimuth1: F(0.8499963),
572
+ azimuth2: F(1),
573
+ mask: F(1),
574
+ },
575
+ outputs: { color: "color" },
576
+ emit: (a) =>
577
+ `ag_rim(${a.color}, ${a.facing}, ${a.uv}, ${a.rim_color}, ${a.threshold}, ${a.fade}, ${a.range}, ${a.inclination}, ${a.azimuth1}, ${a.azimuth2}, ${a.mask})`,
578
+ },
579
+ // The fill: a colour laid toward the silhouette, inner to outer.
580
+ ag_fill: {
581
+ inputs: {
582
+ color: C([1, 1, 1], true),
583
+ facing: F(1),
584
+ inner: C([0, 0, 0]),
585
+ outer: C([0, 0, 0]),
586
+ amount: F(0),
587
+ softness: F(0),
588
+ },
589
+ outputs: { color: "color" },
590
+ emit: (a) => `ag_fill(${a.color}, ${a.facing}, ${a.inner}, ${a.outer}, ${a.amount}, ${a.softness})`,
591
+ },
592
+
593
+ // Aether Gazer's current character shading (PBR/Uber) as one closure, the way
594
+ // Principled is one: the ramp image on the group slot named by the type.
595
+ // Defaults are 104903's skin.
596
+ ...UBER_NODES,
597
+ ...FACE_SDF_NODES,
598
+
457
599
  // ── Blender 5.2 colour utilities ──
458
600
  separate_color: {
459
601
  inputs: { color: C([1, 1, 1], true) },
@@ -66,6 +66,15 @@ export type ShaderGraph = {
66
66
  * matching (e.g. ["hair"]). Ignored by the compiler; round-tripped. A graph is pure
67
67
  * shading — pass integration lives on the style group's renderClass, not here. */
68
68
  tags?: string[]
69
+ /**
70
+ * The images this look samples on `tex_image/0`…`tex_image/3`, by URL —
71
+ * a ramp, a matcap, the maps a game's look is built on. They belong to the
72
+ * LOOK, not to the model it is put on: they are read at coordinates the
73
+ * shading computes (a light amount, a reflection), never at the mesh's uvs,
74
+ * so one graph carries them to any model. A style group that brings its own
75
+ * images keeps those.
76
+ */
77
+ images?: ({ url: string; srgb?: boolean } | null)[]
69
78
  }
70
79
 
71
80
  export type Diagnostic = {
@@ -137,20 +137,17 @@ ${discard}
137
137
  var n = safe_normal(input.normal);
138
138
  let v = normalize(camera.viewPos - input.worldPos);${flip}
139
139
  ${gate}
140
- let l = -light.lights[0].direction.xyz;
141
- let sun = select(light.lights[0].color.xyz * light.lights[0].color.w, modelLight.sun.rgb, modelLight.sun.w > 0.5);
140
+ // The key: the directional light this draw's layers take — the sun unless a
141
+ // light on its own layer stands in. The shadow stays the sun's atlas, as the
142
+ // game keeps the scene's shadow under a character's own key.
143
+ let rz_key = rzMainLight();
144
+ let l = -light.lights[rz_key].direction.xyz;
145
+ let sun = light.lights[rz_key].color.xyz * light.lights[rz_key].color.w;
142
146
  let shadow = sampleShadow(input.worldPos, n);
143
- // The world: flat colour, or the HDRI's irradiance at this normal — which
144
- // is what makes a loaded sky actually light her instead of only backing her.
145
- // The model's fill is NOT here: see the epilogue.
146
- //
147
- // A model given a flat sky (setModelFlatSky - the cast, for an anime look)
148
- // takes the sky's AVERAGE instead: its colour and brightness from every
149
- // direction alike. The directional sky shaded a face like PBR - soft
150
- // realistic gradients the toon ramp never drew - where the game gives its
151
- // characters one flat base light. A flat World is its own average, so a
152
- // scene without an HDRI renders exactly as before.
153
- let amb = select(rzWorldAmbient(n), rzWorldAmbientAvg(), modelLight.opts.x > 0.5);
147
+ // The ambient: this object's own where loaded data gave it one — the game
148
+ // gives every renderer its own — else the world's flat colour or the HDRI's
149
+ // irradiance at this normal.
150
+ let amb = rzObjectAmbient(n);
154
151
  let tex_color = tex_s.rgb;
155
152
  // The normal the lamps' diffuse layer is lit by, for a principled walk to
156
153
  // compute it on the way (rzLightsDiffuseOnce). n is final from here on.
@@ -196,11 +193,11 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
196
193
  // alpha, into a glowing disc under the spot above it.
197
194
  //
198
195
  // The model's fill (Engine.setModelFill) rides here too, as brightness: the
199
- // surface colour times the fill, on every pixel of her alike. It used to
200
- // enter the graph's ambient, where an NPR ramp read it as LIGHT — sliding it
201
- // moved her shadows, and under a hard step flipped whole regions at once.
202
- // After the graph it lifts her evenly and leaves every shadow where it is.
203
- const LIT = takesLight ? ` + (rzLightsDiffuseOnce(input.worldPos, n) + modelLight.fill.rgb) * albedo` : ""
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` : ""
204
201
 
205
202
  const ALBEDO = ` let albedo = tex_color;
206
203
  `
package/src/index.ts CHANGED
@@ -14,11 +14,14 @@ 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,
20
21
  type ModelTransform,
21
22
  type SceneLight,
23
+ RENDERING_LAYER_DEFAULT,
24
+ RENDERING_LAYER_CHARACTER,
22
25
  type ModelAttachment,
23
26
  type GizmoDragEvent,
24
27
  type GizmoDragCallback,
@@ -215,3 +218,12 @@ export {
215
218
  */
216
219
  export { EFFECT_MATH_API, PARTICLE_STRUCT_WGSL } from "./shaders/passes/hosted-api"
217
220
  export type { EyeTrackingOptions } from "./model"
221
+
222
+ /**
223
+ * The game's own materials (src/unity): a stage package drawn by its translated
224
+ * shaders (Engine.setNativeStage) and a model dressed in a native look
225
+ * (Engine.setModelNativeLook) — the shapes their files take.
226
+ */
227
+ export type { NativeStagePackage, NativeStageReader } from "./unity/stage"
228
+ export type { NativeLook, NativeMaterialSpec, NativeTexture } from "./unity/looks"
229
+ export type { NativeShader, NativeShaderInfo } from "./unity/host"
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,10 @@ 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
568
570
  ) {
571
+ this.edgeScales = edgeScales
569
572
  // Store base vertex data (original positions before morphing)
570
573
  this.baseVertexData = new Float32Array(vertexData)
571
574
  this.vertexData = vertexData
@@ -1001,6 +1004,20 @@ export class Model {
1001
1004
  return this.vertexData
1002
1005
  }
1003
1006
 
1007
+ /** PMX per-vertex edge scale, when the source had one. */
1008
+ private edgeScales: Float32Array | null = null
1009
+ private outlineVertices: Float32Array<ArrayBuffer> | null = null
1010
+
1011
+ /**
1012
+ * The outline hull's own vertex stream: per vertex, the smoothed rest-pose
1013
+ * normal (xyz) and the PMX edge scale (w). See outline-normals.ts. Built on
1014
+ * first ask — only models with an edge-flagged material ever ask.
1015
+ */
1016
+ getOutlineVertices(): Float32Array<ArrayBuffer> {
1017
+ this.outlineVertices ??= buildOutlineVertices(this.baseVertexData, VERTEX_STRIDE, this.indexData, this.edgeScales ?? undefined)
1018
+ return this.outlineVertices
1019
+ }
1020
+
1004
1021
  getTextures(): Texture[] {
1005
1022
  return this.textures
1006
1023
  }
@@ -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
+ }
package/src/pmx-loader.ts CHANGED
@@ -36,6 +36,9 @@ export class PmxLoader {
36
36
  private inverseBindMatrices: Float32Array | null = null
37
37
  private joints0: Uint16Array | null = null
38
38
  private weights0: Uint8Array | null = null
39
+ /** PMX per-vertex edge scale — the outline's width multiplier, as the
40
+ * game's outline takes vertex colour alpha. */
41
+ private edgeScales: Float32Array | null = null
39
42
  private morphs: Morph[] = []
40
43
  private vertexCount: number = 0
41
44
  private rigidbodies: Rigidbody[] = []
@@ -150,6 +153,7 @@ export class PmxLoader {
150
153
  // Prepare skinning arrays (4 influences per vertex)
151
154
  const joints = new Uint16Array(count * 4)
152
155
  const weights = new Uint8Array(count * 4) // UNORM8, will be normalized to 255
156
+ const edgeScales = new Float32Array(count)
153
157
 
154
158
  for (let i = 0; i < count; i++) {
155
159
  const px = this.getFloat32()
@@ -238,11 +242,12 @@ export class PmxLoader {
238
242
  } else {
239
243
  throw new Error(`Invalid bone weight type: ${type}`)
240
244
  }
241
- this.offset += 4 // edge scale
245
+ edgeScales[i] = this.getFloat32()
242
246
  }
243
247
 
244
248
  this.joints0 = joints
245
249
  this.weights0 = weights
250
+ this.edgeScales = edgeScales
246
251
  return { positions, normals, uvs }
247
252
  }
248
253
 
@@ -1136,7 +1141,8 @@ export class PmxLoader {
1136
1141
  morphing,
1137
1142
  this.rigidbodies,
1138
1143
  this.joints,
1139
- this.warnings
1144
+ this.warnings,
1145
+ this.edgeScales
1140
1146
  )
1141
1147
  }
1142
1148
 
@@ -28,7 +28,8 @@ import { subjectMaskApi } from "./cast-api"
28
28
  // [0..2] position, world space [3] radius
29
29
  // [4..6] colour PREMULTIPLIED by intensity [7] type
30
30
  // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
31
- // [12] cos of the inner angle [13..15] spare
31
+ // [12] cos of the inner angle
32
+ // [13] the rendering layers it does NOT reach (u32 bits) [14..15] spare
32
33
  //
33
34
  // then the grid: LIGHT_MASK_WORDS words of lamp bits per cell.
34
35
  //
@@ -40,6 +41,12 @@ import { subjectMaskApi } from "./cast-api"
40
41
  // branchless: it stores aim (0,0,0) and both cosines at -1, which makes the
41
42
  // cone term below saturate to exactly 1. `type` says which a light is for
42
43
  // anyone reading the buffer; the shading never asks.
44
+ //
45
+ // LAYERS, as the game's pipeline (URP's rendering layers) gates its lights: a
46
+ // draw carries layer bits, and a light reaches it only where its own mask
47
+ // shares one. Stored INVERTED — the layers a light does not reach — so the
48
+ // zero every writer that predates layers leaves in [13] reaches everything,
49
+ // which is what an effect's lamp and a plain document light mean.
43
50
 
44
51
  import { castDistanceStub } from "./passes/cast-distance"
45
52
  import { audioApi } from "./audio-api"
@@ -243,8 +250,9 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
243
250
  `
244
251
  }
245
252
 
246
- /** The rz*Light accessors, with the buffer declared at the given binding. */
247
- export function lightsApi(group: number, binding: number): string {
253
+ /** The rz*Light accessors, with the buffer declared at the given binding.
254
+ * `layers` is the WGSL expression for the drawing's rendering-layer bits. */
255
+ export function lightsApi(group: number, binding: number, layers: string): string {
248
256
  const R = LIGHT_HEADER / 4
249
257
  const S = LIGHT_STRIDE / 4
250
258
  return /* wgsl */ `
@@ -286,6 +294,10 @@ fn rzLightAim(i: u32) -> vec3f { return _rzLightVec(i, 2u).xyz; }
286
294
  * light stores (-1, -1), which saturates the cone term to 1. */
287
295
  fn rzLightCone(i: u32) -> vec2f { return vec2f(_rzLightVec(i, 2u).w, _rzLightVec(i, 3u).x); }
288
296
 
297
+ /** Does light i reach this drawing's rendering layers? Read as bits, never as
298
+ * a float — see the layout. */
299
+ fn _rzLightReaches(i: u32) -> bool { return ((${layers}) & ~_rzLights[${R}u + i * ${S}u + 3u].y) != 0u; }
300
+
289
301
  /** The document lamps that can reach p: its grid cell's bits, or the outside
290
302
  * mask beyond the grid. Written so a NaN position fails the inside test. */
291
303
  fn _rzLightCellMask(p: vec3f) -> vec4u {
@@ -312,6 +324,7 @@ fn _rzLightCellMask(p: vec3f) -> vec4u {
312
324
  * writes in candela over 683.
313
325
  */
314
326
  fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
327
+ if (!_rzLightReaches(i)) { return vec3f(0.0); }
315
328
  let pr = _rzLightVec(i, 0u);
316
329
  let d = pr.xyz - p;
317
330
  let dist = length(d);