reze-engine 0.66.5 → 0.67.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.
package/src/engine.ts CHANGED
@@ -79,7 +79,14 @@ import {
79
79
  } from "./shaders/lights"
80
80
  import { buildLightGrid } from "./light-grid"
81
81
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
82
- import { outlineShaderWgsl, RZ_OUTLINE_COLOR_OFFSET, RZ_OUTLINE_DISSOLVE_OFFSET, RZ_OUTLINE_WIDTH_OFFSET } from "./shaders/passes/outline"
82
+ import {
83
+ outlineShaderWgsl,
84
+ RZ_OUTLINE_COLOR_OFFSET,
85
+ RZ_OUTLINE_DISSOLVE_OFFSET,
86
+ RZ_OUTLINE_SHADOW_OFFSET,
87
+ RZ_OUTLINE_TEXTURED_OFFSET,
88
+ RZ_OUTLINE_WIDTH_OFFSET,
89
+ } from "./shaders/passes/outline"
83
90
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
84
91
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
85
92
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
@@ -238,7 +245,7 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
238
245
  "hair", "ahoge", "bang",
239
246
  ],
240
247
  ],
241
- ["body", ["肌", "皮肤", "skin"]],
248
+ ["body", ["肌", "皮肤", "skin", "指甲", "nail"]],
242
249
  ["metal", ["金属", "メタル", "metal", "earring", "耳环", "耳環"]],
243
250
  [
244
251
  "cloth_smooth",
@@ -284,6 +291,11 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
284
291
  ],
285
292
  ]
286
293
 
294
+ // Whole names that are body parts — skin by another name. Whole, never
295
+ // substrings: 手 alone is a hand, inside 手套 (gloves) or 手镯 (a bracelet) it is
296
+ // what the hand wears; 足 is a leg, 足袋 a sock.
297
+ const BODY_PART_NAMES = new Set(["手", "手部", "腕", "足", "脚", "腿", "爪", "hand", "hands", "arm", "arms", "leg", "legs", "body"])
298
+
287
299
  // Resolve a material name to a style category (override map first, then name hints), or
288
300
  // null if nothing matches — a null-resolving material stays ungrouped (neutral default).
289
301
  function resolvePreset(materialName: string, map: MaterialPresetMap | undefined): MaterialPreset | null {
@@ -293,6 +305,7 @@ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined)
293
305
  }
294
306
  }
295
307
  const lower = materialName.toLowerCase()
308
+ if (BODY_PART_NAMES.has(lower.replace(/[\s_.\-0-9]+$/, ""))) return "body"
296
309
  for (const [preset, hints] of PRESET_NAME_HINTS) {
297
310
  for (const hint of hints) {
298
311
  if (lower.includes(hint)) return preset
@@ -641,10 +654,16 @@ export type ViewTransformOptions = {
641
654
  exposure: number
642
655
  /** After the transform, display gamma (`pow(rgb, 1/gamma)`). */
643
656
  gamma: number
657
+ /**
658
+ * The "soft" curve's power, (1 - e^(-2.5x))^contrast. Aether Gazer sets it per
659
+ * scene (SceneSetting._contrast): 1.4 is its common value, the character page
660
+ * runs 1.7. A stage brings its own; the other transforms ignore it.
661
+ */
662
+ contrast: number
644
663
  /**
645
664
  * Which display transform the frame is formed with (composite.ts, viewTransform).
646
665
  *
647
- * "soft" — Aether Gazer's own final curve, (1 - e^(-2.5x))^1.4. The default.
666
+ * "soft" — Aether Gazer's own final curve, (1 - e^(-2.5x))^contrast. The default.
648
667
  * "neutral" — Unity URP's Neutral, the Khronos PBR Neutral operator: colour
649
668
  * passes through until the top fifth, which rolls off.
650
669
  * "aces" — Unity URP's ACES (RRT + ODT approximation from the Core RP).
@@ -660,6 +679,7 @@ export type ViewTransformName = "soft" | "neutral" | "aces" | "none"
660
679
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
661
680
  exposure: 0,
662
681
  gamma: 1.0,
682
+ contrast: 1.4,
663
683
  transform: "soft",
664
684
  }
665
685
 
@@ -823,7 +843,9 @@ interface DrawCall {
823
843
  /** Edge-flagged materials: interleaved inverted-hull outline drawn right after
824
844
  * this material with the outline pipeline. Shares this call's index range;
825
845
  * own bind group (edge uniforms + diffuse texture for the alpha test). */
826
- outline?: { bindGroup: GPUBindGroup }
846
+ /** The inverted hull: `pmx` when the PMX asked for an edge, `drawn` when it
847
+ * is drawn — the PMX's, or its style group's look's outline. */
848
+ outline?: { bindGroup: GPUBindGroup; pmx: boolean; drawn: boolean }
827
849
  /** MODEL-SPACE AABB over this material's index range, computed at load:
828
850
  * [minX, minY, minZ, maxX, maxY, maxZ]. Usable for culling only while the
829
851
  * owning model is rigid (see ModelInstance.rigid) — animation moves vertices
@@ -930,6 +952,9 @@ interface ModelInstance {
930
952
  * material buffers never reached it and a dissolved character kept her
931
953
  * outline. Kept here so that write has somewhere to go. */
932
954
  outlineUniformBuffers: GPUBuffer[]
955
+ /** Each outlined material's hull block and its PMX edge (colour, size), for
956
+ * a style graph's outline to take over and give back. */
957
+ outlineByMaterial: Map<string, { buffer: GPUBuffer; edge: Float32Array<ArrayBuffer>; picture: GPUTextureView }>
933
958
  model: Model
934
959
  /**
935
960
  * Keep simulating this model's cloth while it is HIDDEN.
@@ -2632,7 +2657,7 @@ export class Engine {
2632
2657
  // 15 × vec4f — see the viewU comment in composite.ts. The last one is the
2633
2658
  // camera's world position, which is what lets a foreground effect turn the
2634
2659
  // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
2635
- private readonly compositeUniformData = new Float32Array(60)
2660
+ private readonly compositeUniformData = new Float32Array(64)
2636
2661
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
2637
2662
  private backgroundColor: Vec3 | null = null
2638
2663
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -3054,6 +3079,7 @@ export class Engine {
3054
3079
  return {
3055
3080
  exposure: partial?.exposure ?? d.exposure,
3056
3081
  gamma: partial?.gamma ?? d.gamma,
3082
+ contrast: partial?.contrast ?? d.contrast,
3057
3083
  transform: partial?.transform ?? d.transform,
3058
3084
  }
3059
3085
  }
@@ -3072,7 +3098,7 @@ export class Engine {
3072
3098
 
3073
3099
  getViewTransformOptions(): ViewTransformOptions {
3074
3100
  const v = this.viewTransform
3075
- return { exposure: v.exposure, gamma: v.gamma, transform: v.transform }
3101
+ return { exposure: v.exposure, gamma: v.gamma, contrast: v.contrast, transform: v.transform }
3076
3102
  }
3077
3103
 
3078
3104
  private colorGrading: ColorGradingOptions = {
@@ -3188,6 +3214,7 @@ export class Engine {
3188
3214
  const v = this.viewTransform
3189
3215
  if (patch.exposure !== undefined) v.exposure = patch.exposure
3190
3216
  if (patch.gamma !== undefined) v.gamma = patch.gamma
3217
+ if (patch.contrast !== undefined) v.contrast = patch.contrast
3191
3218
  if (patch.transform !== undefined) v.transform = patch.transform
3192
3219
  if (this.device && this.compositeUniformBuffer) {
3193
3220
  this.writeCompositeViewUniforms()
@@ -3266,6 +3293,9 @@ export class Engine {
3266
3293
  u[25] = Engine.VIEW_TRANSFORM_ID[v.transform] ?? 0
3267
3294
  u[26] = this.canvas.width
3268
3295
  u[27] = this.canvas.height
3296
+ // The soft curve's power (viewU[15].x), the scene's own (SceneSetting._contrast).
3297
+ // Its own vec4: viewU[11..14] are the cast's positions whenever an effect runs.
3298
+ u[60] = v.contrast
3269
3299
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
3270
3300
  // on the CPU, so the shader only ever sees slope/offset/power. Mid-gray is
3271
3301
  // neutral in all three; the signed distance from it is the amount.
@@ -8468,6 +8498,8 @@ export class Engine {
8468
8498
  entries: [
8469
8499
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8470
8500
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
8501
+ // the key light, for a style graph's outline colour (see outline.ts)
8502
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8471
8503
  ],
8472
8504
  })
8473
8505
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -8476,6 +8508,7 @@ export class Engine {
8476
8508
  entries: [
8477
8509
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8478
8510
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
8511
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
8479
8512
  ],
8480
8513
  })
8481
8514
 
@@ -8494,6 +8527,7 @@ export class Engine {
8494
8527
  entries: [
8495
8528
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
8496
8529
  { binding: 1, resource: this.materialSampler },
8530
+ { binding: 2, resource: { buffer: this.lightUniformBuffer } },
8497
8531
  ],
8498
8532
  })
8499
8533
 
@@ -8507,6 +8541,7 @@ export class Engine {
8507
8541
  entries: [
8508
8542
  { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
8509
8543
  { binding: 1, resource: this.materialSampler },
8544
+ { binding: 2, resource: { buffer: this.lightUniformBuffer } },
8510
8545
  ],
8511
8546
  })
8512
8547
 
@@ -8694,13 +8729,14 @@ export class Engine {
8694
8729
  // strength belong to the combine step.
8695
8730
  this.compositeUniformBuffer = this.device.createBuffer({
8696
8731
  label: "composite view uniforms",
8697
- // 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
8732
+ // 16 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
8698
8733
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
8699
8734
  // (time, _, canvas width, canvas height) for user effects · three grade
8700
8735
  // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
8701
8736
  // world position, for an effect placing itself in the scene · four
8702
- // character positions, for one that wants to respond to the cast.
8703
- size: 240,
8737
+ // character positions, for one that wants to respond to the cast · the
8738
+ // soft curve's contrast. Shaders that stop at the cast declare 15.
8739
+ size: 256,
8704
8740
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8705
8741
  })
8706
8742
  this.dofUniformBuffer = this.device.createBuffer({
@@ -14894,6 +14930,7 @@ export class Engine {
14894
14930
  dissolve: 1,
14895
14931
  materialUniformBuffers: [],
14896
14932
  outlineUniformBuffers: [],
14933
+ outlineByMaterial: new Map(),
14897
14934
  cullModelIndex: 0,
14898
14935
  // Seeded false: the first skin-matrix upload decides it, and until then the
14899
14936
  // sphere path is the safe answer (it never culls something it should not).
@@ -15535,8 +15572,12 @@ export class Engine {
15535
15572
  // every material — on the heaviest mesh in the scene that doubles the
15536
15573
  // geometry submitted per frame to draw cartoon outlines around
15537
15574
  // architecture, which is not the look anyone is after.
15575
+ // Every character material gets its hull: the PMX edge flag draws it as
15576
+ // MMD does, and a style group whose look carries an outline draws it on
15577
+ // any material (assignDrawCallGroups sets `drawn`).
15538
15578
  let outline: DrawCall["outline"]
15539
- if (!inst.isStage && (mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
15579
+ const pmxEdge = (mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0
15580
+ if (!inst.isStage) {
15540
15581
  const materialUniformData = new Float32Array([
15541
15582
  mat.edgeColor[0],
15542
15583
  mat.edgeColor[1],
@@ -15551,25 +15592,29 @@ export class Engine {
15551
15592
  1,
15552
15593
  // The scene's width multiplier, at RZ_OUTLINE_WIDTH_OFFSET.
15553
15594
  this.outlineWidth,
15595
+ // A style graph's outline, at RZ_OUTLINE_TEXTURED_OFFSET: none yet.
15554
15596
  0,
15555
15597
  // The scene's colour override, at RZ_OUTLINE_COLOR_OFFSET.
15556
15598
  this.outlineColor[0],
15557
15599
  this.outlineColor[1],
15558
15600
  this.outlineColor[2],
15559
15601
  this.outlineColor[3],
15602
+ // The graph outline's shadow colour, at RZ_OUTLINE_SHADOW_OFFSET.
15603
+ 0,
15604
+ 0,
15605
+ 0,
15606
+ 0,
15560
15607
  ])
15561
15608
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
15562
15609
  inst.gpuBuffers.push(outlineUniformBuffer)
15563
15610
  inst.outlineUniformBuffers.push(outlineUniformBuffer)
15564
- const outlineBindGroup = this.device.createBindGroup({
15565
- label: `${prefix}outline: ${mat.name}`,
15566
- layout: this.outlinePerMaterialBindGroupLayout,
15567
- entries: [
15568
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
15569
- { binding: 1, resource: textureView },
15570
- ],
15611
+ inst.outlineByMaterial.set(mat.name, {
15612
+ buffer: outlineUniformBuffer,
15613
+ edge: materialUniformData.slice(0, 5),
15614
+ picture: textureView,
15571
15615
  })
15572
- outline = { bindGroup: outlineBindGroup }
15616
+ const outlineBindGroup = this.outlineBindGroup(`${prefix}outline: ${mat.name}`, outlineUniformBuffer, textureView, null)
15617
+ outline = { bindGroup: outlineBindGroup, pmx: pmxEdge, drawn: pmxEdge }
15573
15618
  if (!inst.outlineVertexBuffer) {
15574
15619
  const data = inst.model.getOutlineVertices()
15575
15620
  const buf = this.device.createBuffer({
@@ -18109,8 +18154,45 @@ export class Engine {
18109
18154
 
18110
18155
  // Rebind each material draw call to its (successfully-installed) group's uniform buffer,
18111
18156
  // or the zero buffer when ungrouped, then re-sort by render-class draw order.
18157
+ /** A hull's per-material group: its block, the picture, the look's tint. */
18158
+ private outlineBindGroup(label: string, buffer: GPUBuffer, picture: GPUTextureView, tint: GPUTexture | null): GPUBindGroup {
18159
+ return this.device.createBindGroup({
18160
+ label,
18161
+ layout: this.outlinePerMaterialBindGroupLayout,
18162
+ entries: [
18163
+ { binding: 0, resource: { buffer } },
18164
+ { binding: 1, resource: picture },
18165
+ { binding: 2, resource: (tint ?? this.fallbackMaterialTexture).createView() },
18166
+ ],
18167
+ })
18168
+ }
18169
+
18112
18170
  private assignDrawCallGroups(inst: ModelInstance, claimed: Map<string, string>): void {
18113
18171
  inst.materialToGroup.clear()
18172
+ // The hulls: a group whose look carries an outline (ShaderGraph.outline)
18173
+ // draws it in place of the PMX edge; the rest keep, or get back, their own.
18174
+ for (const [name, hull] of inst.outlineByMaterial) {
18175
+ const id = claimed.get(name)
18176
+ const install = id ? inst.styleGroups.get(id) : undefined
18177
+ const o = install?.group.graph.outline
18178
+ const block = o
18179
+ ? new Float32Array([o.color[0], o.color[1], o.color[2], 1, o.width])
18180
+ : hull.edge
18181
+ this.device.queue.writeBuffer(hull.buffer, 0, block)
18182
+ this.device.queue.writeBuffer(hull.buffer, RZ_OUTLINE_TEXTURED_OFFSET, new Float32Array([o ? 1 : 0]))
18183
+ if (o) this.device.queue.writeBuffer(hull.buffer, RZ_OUTLINE_SHADOW_OFFSET, new Float32Array([...o.shadowColor, 1]))
18184
+ const tint = o?.tint != null ? (install?.imagesByMaterial?.[name] ?? install?.images)?.[o.tint] ?? null : null
18185
+ for (const dc of inst.drawCalls) {
18186
+ if (dc.materialName !== name || !dc.outline) continue
18187
+ dc.outline.bindGroup = this.outlineBindGroup(`outline: ${name}`, hull.buffer, hull.picture, tint)
18188
+ this.bundlesDirty = true
18189
+ const drawn = o ? o.width > 0 : dc.outline.pmx
18190
+ if (dc.outline.drawn !== drawn) {
18191
+ dc.outline.drawn = drawn
18192
+ this.bundlesDirty = true
18193
+ }
18194
+ }
18195
+ }
18114
18196
  for (const dc of inst.drawCalls) {
18115
18197
  if (!dc.baseBindGroupEntries) continue // outlines/ground are never grouped
18116
18198
  const wantId = claimed.get(dc.materialName)
@@ -18609,7 +18691,7 @@ export class Engine {
18609
18691
  }
18610
18692
  pass.setBindGroup(2, draw.bindGroup)
18611
18693
  this.issueDraw(pass, draw, view.args)
18612
- if (draw.outline && this.outlineEnabled && view.outlines) outlined.push(draw)
18694
+ if (draw.outline?.drawn && this.outlineEnabled && view.outlines) outlined.push(draw)
18613
18695
  }
18614
18696
  // THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
18615
18697
  // at the minimum width is faded, and it still writes depth: drawn between
@@ -24,7 +24,7 @@ export const BODY_GRAPH: ShaderGraph = {
24
24
  { id: "lit_tint", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.5, value: 1.0, fac: 1.0 } },
25
25
  { id: "toon_color", type: "mix/blend" },
26
26
  { id: "bc", type: "bright_contrast", inputs: { bright: 0.1, contrast: 0.2 } },
27
- { id: "emission3", type: "vector_math/scale", inputs: { scale: 4.0 } },
27
+ { id: "emission3", type: "vector_math/scale", inputs: { scale: 3.2 } },
28
28
  { id: "warm_add", type: "math/add", inputs: { b: 0.5 } },
29
29
  { id: "warm_clamp", type: "math/clamp01" },
30
30
  {
@@ -30,7 +30,7 @@ export const CLOTH_ROUGH_GRAPH: ShaderGraph = {
30
30
  { id: "mix_003", type: "mix/blend" },
31
31
  { id: "hue_004", type: "hue_sat", inputs: { hue: 0.5, saturation: 0.800000011920929, value: 2.0, fac: 1.0 } },
32
32
  { id: "npr_overlay", type: "mix/overlay", inputs: { fac: 1.0 } },
33
- { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 18.200000762939453 } },
33
+ { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 14.56 } },
34
34
  { id: "noise", type: "tex_noise", inputs: { scale: 17.7 } },
35
35
  { id: "noise_ramp", type: "ramp_linear", inputs: { pos0: 0.0, pos1: 1.0 } },
36
36
  { id: "bump", type: "bump", inputs: { strength: 1.0 } },
@@ -28,7 +28,7 @@ export const CLOTH_SMOOTH_GRAPH: ShaderGraph = {
28
28
  { id: "mix_003", type: "mix/blend" },
29
29
  { id: "hue_004", type: "hue_sat", inputs: { hue: 0.5, saturation: 0.800000011920929, value: 2.0, fac: 1.0 } },
30
30
  { id: "npr_overlay", type: "mix/overlay", inputs: { fac: 1.0 } },
31
- { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 18.200000762939453 } },
31
+ { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 14.56 } },
32
32
  { id: "principled_base", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 0.800000011920929, fac: 1.0 } },
33
33
  {
34
34
  id: "principled",
@@ -29,7 +29,7 @@ export const FACE_GRAPH: ShaderGraph = {
29
29
  { id: "lit_tint", type: "hue_sat", inputs: { hue: 0.46000000834465027, saturation: 1.600000023841858, value: 1.5, fac: 1.0 } },
30
30
  { id: "toon_color", type: "mix/blend" },
31
31
  { id: "bc", type: "bright_contrast", inputs: { bright: 0.1, contrast: 0.2 } },
32
- { id: "emission3", type: "vector_math/scale", inputs: { scale: 2.5 } },
32
+ { id: "emission3", type: "vector_math/scale", inputs: { scale: 2.0 } },
33
33
 
34
34
  // ── warm rim (toon*0.5+0.5 → cardinal ramp) ──
35
35
  { id: "warm_mul", type: "math/multiply", inputs: { b: 0.5 } },
@@ -27,7 +27,7 @@ export const METAL_GRAPH: ShaderGraph = {
27
27
  { id: "mix_004", type: "mix/blend" },
28
28
  { id: "hue_004", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.0, value: 2.0, fac: 1.0 } },
29
29
  { id: "npr_overlay", type: "mix/overlay", inputs: { fac: 1.0 } },
30
- { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 8.100000381469727 } },
30
+ { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 6.48 } },
31
31
  { id: "voro_cross", type: "vect_cross", inputs: { b: [0, 1, 0] } },
32
32
  { id: "voro", type: "tex_voronoi/color", inputs: { scale: 4.3 } },
33
33
  { id: "voro_ramp", type: "ramp_linear", inputs: { pos0: 0.0, pos1: 1.0 } },
@@ -123,7 +123,11 @@ const uberNode = (slot: number): NodeSpec => ({
123
123
  roughness: F(0.6),
124
124
  occlusion: F(1),
125
125
  normal: { type: "vector", contextDefault: "n" },
126
- direction: { type: "vector", requiresLink: true },
126
+ // The key light: this draw's, by default — the sun, or the light on the
127
+ // character's own layer — its way and its colour (the game's _LocalLightDir,
128
+ // _LocalLightColor). Linked, a look supplies its own (ag_key_light).
129
+ direction: { type: "vector", contextDefault: "l" },
130
+ light_color: { type: "color", contextDefault: "sun" },
127
131
  // Unity's v into the ramp: the property map's alpha, which picks a
128
132
  // surface's band (skin 0.702, hair and pale cloth 0.506).
129
133
  row: F(0.702),
@@ -137,24 +141,45 @@ const uberNode = (slot: number): NodeSpec => ({
137
141
  rim_in_light: F(1),
138
142
  emission: C([0, 0, 0]),
139
143
  reflection: F(1),
144
+ // THE STUDIO AMBIENT (_GlobalIlluminationOverride): the game blends a
145
+ // character's ambient from its own reflection cube's smallest mip — near
146
+ // neutral, 0.4–0.9 across the newer skins — over the scene's light, by the
147
+ // material's blend (bodies 0.8, hair 1, faces 0.5). It is what keeps a
148
+ // character clean on a coloured stage.
149
+ studio: C([0.6, 0.6, 0.6]),
150
+ studio_blend: F(0),
140
151
  // A face's SDF shade (ag_face_sdf); left at -1, the material reads N·L.
141
152
  shade: F(-1),
142
153
  // 1: the game's FACE_MODE — no GGX, the environment at a flat 0.0157.
143
154
  face: F(0),
155
+ // A face's SDF highlight (ag_face_spec), in place of GGX.
156
+ face_spec: F(0),
157
+ // 1: hair (_ANISOTROPIC_SPECULAR) — its ring (ag_hair_ring) in place of GGX,
158
+ // the environment at a flat 0.0157.
159
+ hair: F(0),
160
+ hair_spec: C([0, 0, 0]),
144
161
  },
145
162
  outputs: { color: "color" },
146
163
  emit: (a) =>
147
- `ag_uber(${slot}u, ${a.base}, ${a.metallic}, ${a.roughness}, ${a.occlusion}, ${a.normal}, ${a.direction}, ${a.row}, ${a.receive_shadow}, ` +
148
- `${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)`,
164
+ `ag_uber(${slot}u, ${a.base}, ${a.metallic}, ${a.roughness}, ${a.occlusion}, ${a.normal}, ${a.direction}, ${a.light_color}, ${a.row}, ${a.receive_shadow}, ` +
165
+ `${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.studio}, ${a.studio_blend}, ${a.shade}, ${a.face}, ${a.face_spec}, ${a.hair}, ${a.hair_spec}, input.worldPos, v)`,
149
166
  takesLight: true,
150
167
  })
151
168
  const UBER_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_uber/${s}`, uberNode(s)]))
152
- // The face's SDF shade, the image on the slot the type names — 104903's face
153
- // template (SDF_04_shaonv) by default in the AG pack.
169
+ // How much a colour reads as skin (ag_skin): what picks a surface's ramp row in
170
+ // a general look, where the game would read its property mask.
171
+ const AG_SKIN: NodeSpec = {
172
+ inputs: { color: C([1, 1, 1], true) },
173
+ outputs: { value: "float" },
174
+ emit: (a) => `ag_skin(${a.color})`,
175
+ }
176
+ // The face's SDF shade, the image on the slot the type names. An empty slot
177
+ // reads white: the whole face takes one shade from where the key light stands
178
+ // to the head, the game's flat face for a model without its SDF.
154
179
  const faceSdfNode = (slot: number): NodeSpec => ({
155
180
  inputs: {
156
181
  uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" },
157
- direction: { type: "vector", requiresLink: true },
182
+ direction: { type: "vector", contextDefault: "l" },
158
183
  normal: { type: "vector", contextDefault: "n" },
159
184
  smoothness: F(0.1),
160
185
  invert: F(0),
@@ -163,6 +188,55 @@ const faceSdfNode = (slot: number): NodeSpec => ({
163
188
  emit: (a) => `ag_face_sdf(${slot}u, ${a.uv}.xy, ${a.direction}, ${a.normal}, ${a.smoothness}, ${a.invert})`,
164
189
  })
165
190
  const FACE_SDF_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_face_sdf/${s}`, faceSdfNode(s)]))
191
+ // The newer SDF (_SDFType 1): a painted face normal, not a threshold field.
192
+ const faceSdfNewNode = (slot: number): NodeSpec => ({
193
+ ...faceSdfNode(slot),
194
+ emit: (a) => `ag_face_sdf_new(${slot}u, ${a.uv}.xy, ${a.direction}, ${a.normal}, ${a.smoothness}, ${a.invert})`,
195
+ })
196
+ const FACE_SDF_NEW_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_face_sdf_new/${s}`, faceSdfNewNode(s)]))
197
+ // The face's highlight from the newer SDF's green and blue.
198
+ const faceSpecNode = (slot: number): NodeSpec => ({
199
+ inputs: {
200
+ uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" },
201
+ direction: { type: "vector", contextDefault: "l" },
202
+ normal: { type: "vector", contextDefault: "n" },
203
+ anisotropy: F(1),
204
+ shift: F(0),
205
+ },
206
+ outputs: { value: "float" },
207
+ emit: (a) => `ag_face_spec(${slot}u, ${a.uv}.xy, ${a.direction}, ${a.normal}, v, ${a.anisotropy}, ${a.shift})`,
208
+ })
209
+ // The hair's angel ring, the band on the slot's image.
210
+ const hairRingNode = (slot: number): NodeSpec => ({
211
+ inputs: {
212
+ uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" },
213
+ normal: { type: "vector", contextDefault: "n" },
214
+ color: C([1, 1, 1]),
215
+ anisotropy: F(1),
216
+ shift: F(0),
217
+ },
218
+ outputs: { color: "color" },
219
+ emit: (a) => `ag_hair_ring(${slot}u, ${a.uv}.xy, ${a.normal}, v, ${a.color}, ${a.anisotropy}, ${a.shift})`,
220
+ })
221
+ const HAIR_RING_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_hair_ring/${s}`, hairRingNode(s)]))
222
+ // The game's pupil (SimPipeline/Character/Eye): unlit relief, matcap and
223
+ // glints — slots: 0 depth, 1 normal, 2 matcap, 3 glint mask.
224
+ const AG_EYE: NodeSpec = {
225
+ inputs: {
226
+ height_scale: F(0.2), min_layer: F(8), max_layer: F(32), intensity: F(0.9),
227
+ main_color: C([1, 1, 1]), matcap_color: C([1, 1, 1]), matcap_pow: F(1),
228
+ mask_scale: F(0.15), mask_soft: F(0.014),
229
+ mask2: F(0), mask2_color: C([1, 1, 1]), mask3: F(0), mask3_color: C([1, 1, 1]),
230
+ blink: F(0), blink_scale: F(10), scale_speed: F(0.1), rotate_speed: F(0.6), blink_angle: F(5),
231
+ level: F(1),
232
+ },
233
+ outputs: { color: "color" },
234
+ emit: (a) =>
235
+ `ag_eye(input.uv, n, v, input.worldPos, ${a.height_scale}, ${a.min_layer}, ${a.max_layer}, ${a.intensity}, ${a.main_color}, ` +
236
+ `${a.matcap_color}, ${a.matcap_pow}, ${a.mask_scale}, ${a.mask_soft}, ${a.mask2}, ${a.mask2_color}, ${a.mask3}, ${a.mask3_color}, ` +
237
+ `${a.blink}, ${a.blink_scale}, ${a.scale_speed}, ${a.rotate_speed}, ${a.blink_angle}, ${a.level})`,
238
+ }
239
+ const FACE_SPEC_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_face_spec/${s}`, faceSpecNode(s)]))
166
240
 
167
241
  // A packed property map on the slot the type names — the game's material
168
242
  // contract: R metal, G perceptual roughness (answered as smoothness, Unity's
@@ -630,7 +704,12 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
630
704
  // Principled is one: the ramp image on the group slot named by the type.
631
705
  // Defaults are 104903's skin.
632
706
  ...UBER_NODES,
707
+ ag_skin: AG_SKIN,
633
708
  ...FACE_SDF_NODES,
709
+ ...FACE_SDF_NEW_NODES,
710
+ ...FACE_SPEC_NODES,
711
+ ag_eye: AG_EYE,
712
+ ...HAIR_RING_NODES,
634
713
  ...PROPERTY_MAP_NODES,
635
714
 
636
715
  /**
@@ -75,6 +75,20 @@ export type ShaderGraph = {
75
75
  * images keeps those.
76
76
  */
77
77
  images?: ({ url: string; srgb?: boolean } | null)[]
78
+ /**
79
+ * The look's outline, the anime game's in place of MMD's flat edge: the
80
+ * material's picture × colour, toward shadowColor where the key light leaves
81
+ * it, `width` world units wide (held 1.2–3 px of a 1920-wide frame) — an Aether
82
+ * Gazer _OutlineWidth w is w·1.3/1920·8 here. Unset, the PMX edge stands.
83
+ */
84
+ outline?: {
85
+ color: [number, number, number]
86
+ shadowColor: [number, number, number]
87
+ width: number
88
+ /** One of `images` (its index) whose colour multiplies the outline at the
89
+ * mesh's uvs — the game's per-texel outline mask. */
90
+ tint?: number
91
+ }
78
92
  }
79
93
 
80
94
  export type Diagnostic = {