reze-engine 0.56.13 → 0.57.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.
@@ -650,6 +650,28 @@ fn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
650
650
  }
651
651
 
652
652
  // LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference
653
+ // The same bump measured against the WORLD instead of the screen.
654
+ //
655
+ // bump_lh below takes dpdx(height) — the change across one PIXEL — and drops
656
+ // the world size of that pixel on the floor when it normalizes the cross
657
+ // products. Its slope is therefore per pixel, so the same surface ripples at a
658
+ // distance, where a pixel spans metres, and goes glass-flat underfoot, where it
659
+ // spans millimetres. For skin and cloth that reads as detail which holds its
660
+ // size on screen, which is why it stays; for water it is the difference between
661
+ // a pool and a mirror, because water is the thing you stand next to.
662
+ //
663
+ // Dividing each derivative by its own footprint gives the true world gradient:
664
+ // view-independent, and strength becomes a real slope — 0.25 means a quarter
665
+ // rise over a run, whatever the camera does.
666
+ fn bump_world(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
667
+ let dpdx_pos = dpdx(world_pos);
668
+ let dpdy_pos = dpdy(world_pos);
669
+ let dhdx = dpdx(height) / max(length(dpdx_pos), 1e-8);
670
+ let dhdy = dpdy(height) / max(length(dpdy_pos), 1e-8);
671
+ let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));
672
+ return normalize(perturbed);
673
+ }
674
+
653
675
  fn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
654
676
  let dhdx = dpdx(height);
655
677
  let dhdy = dpdy(height);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.56.13",
3
+ "version": "0.57.0",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -308,6 +308,8 @@ type GroupInstall = {
308
308
  /** The group's own image maps, uploaded once per apply and owned here — the
309
309
  * install destroys them when it is replaced, so a re-apply cannot leak. */
310
310
  images?: (GPUTexture | null)[]
311
+ /** Per-material overrides of the above; same ownership. */
312
+ imagesByMaterial?: Record<string, (GPUTexture | null)[]>
311
313
  slotMap: StyleSlot[]
312
314
  /** Serialized (graph + renderClass + alphaMode) — lets applyStyleGroups skip recompiling
313
315
  * an unchanged group. */
@@ -679,6 +681,14 @@ interface DrawCall {
679
681
  // Style group this material belongs to, or null (ungrouped → neutral base pipeline).
680
682
  // Outline/ground draw calls are never grouped and leave this null.
681
683
  groupId: string | null
684
+ /** The install this draw call's bind group was actually built from.
685
+ *
686
+ * The id is not enough to decide a rebind: re-applying the SAME group id
687
+ * after an edit compiles a NEW install — its own uniform buffer, its own
688
+ * uploaded images — and destroys the old one. Comparing ids alone left the
689
+ * draw call bound to what was just thrown away, which is why a stage looked
690
+ * wrong on upload (two applies) and right after a refresh (one). */
691
+ boundInstall?: object | null
682
692
  // Bindings 0–3 kept so the bind group can be rebuilt when the material's group changes
683
693
  // (binding 4 must follow the group's style buffer, or the zero buffer when ungrouped).
684
694
  // Present only for material draw calls (opaque/transparent) — the grouping walk skips
@@ -2315,7 +2325,6 @@ export class Engine {
2315
2325
  */
2316
2326
  private worldEquirectTexture: GPUTexture | null = null
2317
2327
  private worldEquirectView: GPUTextureView | null = null
2318
- private worldStrength = 1
2319
2328
  /** A three-colour sky's irradiance SH, when the world is a gradient rather
2320
2329
  * than a picture. An HDRI outranks it — see writeWorld. */
2321
2330
  private worldGradientSH: Float32Array | null = null
@@ -2740,13 +2749,15 @@ export class Engine {
2740
2749
  // exposure and view transform as the scene — a sun rolls off like a sun).
2741
2750
  // The camera basis at u[12..23] is refreshed per frame.
2742
2751
  // In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
2743
- // STRENGTH in u[8] — Blender's world-strength dial, default 1.
2752
+ // STRENGTH in u[8] — Blender's world-strength dial, the SAME number that
2753
+ // scales the irradiance. A sky you can see at full brightness while it
2754
+ // lights at two thirds is two skies.
2744
2755
  const bg = this.backgroundColor
2745
2756
  // THE BACKDROP WINS WHAT YOU SEE; the world lights regardless. With only a
2746
2757
  // world installed it is also the sky, which is what an HDRI alone has
2747
2758
  // always done.
2748
2759
  const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null
2749
- u[8] = showingWorld ? this.worldStrength : (bg?.x ?? 0)
2760
+ u[8] = showingWorld ? this.world.strength : (bg?.x ?? 0)
2750
2761
  u[9] = bg?.y ?? 0
2751
2762
  u[10] = bg?.z ?? 0
2752
2763
  // Base-layer mode only. A user effect is a separate LAYER over whichever
@@ -4113,7 +4124,7 @@ export class Engine {
4113
4124
  * `strength` is Blender's world-strength dial and is folded into the
4114
4125
  * coefficients, so what lights her is what you see.
4115
4126
  */
4116
- setWorldEquirect(source: HdrImage | null, options?: { strength?: number }): void {
4127
+ setWorldEquirect(source: HdrImage | null): void {
4117
4128
  // RETIRED, NOT DESTROYED. A frame already encoded against the old sky can
4118
4129
  // still be in flight — the bind groups that name it are rebuilt below, but
4119
4130
  // the command buffer holding the previous ones is submitted at the end of
@@ -4123,7 +4134,6 @@ export class Engine {
4123
4134
  if (this.worldEquirectTexture) this.retiredSkies.push(this.worldEquirectTexture)
4124
4135
  this.worldEquirectTexture = null
4125
4136
  this.worldEquirectView = null
4126
- this.worldStrength = Math.max(options?.strength ?? 1, 0)
4127
4137
  const hadSH = this.worldSH !== null
4128
4138
  this.worldSH = null
4129
4139
  if (source && this.device) {
@@ -4183,10 +4193,13 @@ export class Engine {
4183
4193
  // The sky lights the scene, not only backs it. The sun keeps the toon
4184
4194
  // ramp — this is the ambient term, exactly where the flat world colour
4185
4195
  // used to sit.
4196
+ // RAW irradiance. The World strength dial is applied where every other
4197
+ // reading of it is, in writeWorld — folding it in here made the sky's
4198
+ // brightness a function of WHEN it was installed: the dial moved
4199
+ // afterwards rescaled the write and not the projection, and an install
4200
+ // that landed mid-session carried a different strength from the same
4201
+ // document reopened.
4186
4202
  this.worldSH = projectIrradianceSH({ ...source, data: source.data }, 4)
4187
- if (this.worldStrength !== 1) {
4188
- for (let i = 0; i < this.worldSH.length; i++) this.worldSH[i] *= this.worldStrength
4189
- }
4190
4203
  }
4191
4204
  if (this.worldSH || hadSH) this.writeWorld()
4192
4205
  // The MATERIAL groups too, not only the composite's: a surface that
@@ -9956,6 +9969,7 @@ export class Engine {
9956
9969
  const texturePath = `plane/${name}`
9957
9970
  const material: Material = {
9958
9971
  name,
9972
+ memo: "",
9959
9973
  diffuse: [1, 1, 1, 1],
9960
9974
  specular: [0, 0, 0],
9961
9975
  ambient: [0, 0, 0],
@@ -10172,6 +10186,56 @@ export class Engine {
10172
10186
  return this.modelInstances.get(name)?.model ?? null
10173
10187
  }
10174
10188
 
10189
+ /**
10190
+ * What the engine is actually about to draw, per model.
10191
+ *
10192
+ * Every model the engine holds, not every model the host thinks it holds —
10193
+ * which is the point. A scene that comes up brighter after an upload than the
10194
+ * same document does after a reload differs somewhere between the two, and
10195
+ * the candidates are all countable: a model left behind by a replace draws its
10196
+ * transparent surfaces a second time, and a group that compiled but claimed
10197
+ * nothing leaves its materials on the default graph. Both are invisible from
10198
+ * the host, which sees its own lists rather than the engine's.
10199
+ *
10200
+ * `grouped` counts draw calls bound to a style group; `ungrouped` is the rest,
10201
+ * on the default graph.
10202
+ */
10203
+ /**
10204
+ * What the composite is about to put behind the scene, and at what level.
10205
+ *
10206
+ * `mode` is the base layer: 0 transparent, 1 a flat colour, 2 an LDR 360
10207
+ * picture, 3 the HDR world as scene-linear radiance. `level` is what mode 3
10208
+ * is multiplied by. The whole frame's brightness turns on these two, and
10209
+ * neither is visible from a host reading its own document — a scene that came
10210
+ * up brighter after an upload than after a reload of the same document is one
10211
+ * of the cases they answer.
10212
+ */
10213
+ getBackgroundState(): { mode: 0 | 1 | 2 | 3; level: number; backdrop: boolean; world: boolean } {
10214
+ const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null
10215
+ return {
10216
+ mode: this.backdropEquirectView ? 2 : showingWorld ? 3 : this.backgroundColor ? 1 : 0,
10217
+ level: this.world.strength,
10218
+ backdrop: this.backdropEquirectView !== null,
10219
+ world: this.worldEquirectView !== null,
10220
+ }
10221
+ }
10222
+
10223
+ getDrawStats(): { model: string; materials: number; opaque: number; transparent: number; grouped: number; ungrouped: number }[] {
10224
+ const out = []
10225
+ for (const [name, inst] of this.modelInstances) {
10226
+ const shaded = inst.drawCalls.filter((d) => d.baseBindGroupEntries)
10227
+ out.push({
10228
+ model: name,
10229
+ materials: shaded.length,
10230
+ opaque: shaded.filter((d) => d.type === "opaque").length,
10231
+ transparent: shaded.filter((d) => d.type === "transparent").length,
10232
+ grouped: shaded.filter((d) => d.groupId !== null).length,
10233
+ ungrouped: shaded.filter((d) => d.groupId === null).length,
10234
+ })
10235
+ }
10236
+ return out
10237
+ }
10238
+
10175
10239
  /**
10176
10240
  * Hang a model from a bone of another — MMD's 外部親 (outside parent).
10177
10241
  *
@@ -11090,13 +11154,14 @@ export class Engine {
11090
11154
  private destroyInstall(install: GroupInstall): void {
11091
11155
  install.uniformBuffer.destroy()
11092
11156
  for (const tex of install.images ?? []) tex?.destroy()
11157
+ for (const set of Object.values(install.imagesByMaterial ?? {})) for (const tex of set) tex?.destroy()
11093
11158
  }
11094
11159
 
11095
11160
  /** Upload a group's image maps. Sources are decoded images the host already
11096
11161
  * holds; the engine never fetches, matching how models and motions arrive. */
11097
- private uploadGroupImages(group: StyleGroup): (GPUTexture | null)[] | undefined {
11098
- if (!group.images?.length) return undefined
11099
- return group.images.slice(0, 4).map((entry) => {
11162
+ private uploadGroupImages(group: StyleGroup, slots = group.images): (GPUTexture | null)[] | undefined {
11163
+ if (!slots?.length) return undefined
11164
+ return slots.slice(0, 4).map((entry) => {
11100
11165
  if (!entry) return null
11101
11166
  const wrapped = "source" in entry
11102
11167
  const src = wrapped ? entry.source : entry
@@ -15503,7 +15568,19 @@ export class Engine {
15503
15568
  ): Promise<ApplyStyleGroupResult> {
15504
15569
  const renderClass = group.renderClass ?? "auto"
15505
15570
  const alphaMode = group.alphaMode ?? "opaque"
15506
- const signature = JSON.stringify({ g: group.graph, rc: renderClass, am: alphaMode, o: opts?.previewNode ?? null })
15571
+ // THE MAPS ARE PART OF THE SIGNATURE, because a matching one skips the
15572
+ // upload below and keeps whatever the install already holds. A group can
15573
+ // arrive twice — once as the model lands and again once its sidecar has
15574
+ // decoded — and without this the second apply refreshed the definition and
15575
+ // silently kept the empty slots from the first.
15576
+ const signature = JSON.stringify({
15577
+ g: group.graph,
15578
+ rc: renderClass,
15579
+ am: alphaMode,
15580
+ o: opts?.previewNode ?? null,
15581
+ im: group.images?.length ?? 0,
15582
+ ibm: Object.keys(group.imagesByMaterial ?? {}).sort(),
15583
+ })
15507
15584
  const existing = inst.styleGroups.get(group.id)
15508
15585
  if (existing && existing.signature === signature) {
15509
15586
  existing.group = group // refresh def (label/materials) without recompiling
@@ -15577,6 +15654,11 @@ export class Engine {
15577
15654
  mirrorPipeline,
15578
15655
  uniformBuffer,
15579
15656
  images: this.uploadGroupImages(group),
15657
+ imagesByMaterial: group.imagesByMaterial
15658
+ ? Object.fromEntries(
15659
+ Object.entries(group.imagesByMaterial).map(([name, slots]) => [name, this.uploadGroupImages(group, slots) ?? []]),
15660
+ )
15661
+ : undefined,
15580
15662
  slotMap: result.slotMap,
15581
15663
  signature,
15582
15664
  })
@@ -15637,15 +15719,27 @@ export class Engine {
15637
15719
  // alpha-blend bucket and is drawn in author order: soft haloed leaves
15638
15720
  // that do not occlude each other, and haze wherever cards overlap. The
15639
15721
  // game it came from alpha-tests exactly these materials.
15640
- const type = install?.alphaMode === "hashed" ? "opaque" : dc.baseType
15722
+ // A GRAPH THAT COMPUTES OPACITY DRAWS TRANSPARENT. The phase was decided
15723
+ // at load from the texture's alpha over this material's geometry, which
15724
+ // cannot know about a curve the graph invents — water whose alpha runs
15725
+ // from nothing to a mirror samples as solid and would land in the opaque
15726
+ // phase, where the blend that makes it water never happens.
15727
+ const type = install?.group.graph.opacity
15728
+ ? "transparent"
15729
+ : install?.alphaMode === "hashed"
15730
+ ? "opaque"
15731
+ : dc.baseType
15641
15732
  if (dc.type !== type) dc.type = type
15642
- if (dc.groupId === groupId) continue
15733
+ // Rebind when the INSTALL changed, not merely when the id did.
15734
+ const bound = install ?? null
15735
+ if (dc.groupId === groupId && dc.boundInstall === bound) continue
15643
15736
  dc.groupId = groupId
15737
+ dc.boundInstall = bound
15644
15738
  dc.bindGroup = this.createMaterialBindGroup(
15645
15739
  `material: ${dc.materialName}`,
15646
15740
  dc.baseBindGroupEntries,
15647
15741
  install ? install.uniformBuffer : this.zeroStyleBuffer,
15648
- install?.images,
15742
+ install?.imagesByMaterial?.[dc.materialName] ?? install?.images,
15649
15743
  )
15650
15744
  }
15651
15745
  this.sortDrawCalls(inst)
@@ -183,6 +183,13 @@ export function validateGraph(graph: ShaderGraph, opts: CompileOptions = {}): Di
183
183
  if (!outT) d.push(err(`output "${out.node}.${out.socket}" doesn't resolve`, out.node))
184
184
  else if (outT === "vec4") d.push(err(`output "${out.node}.${out.socket}" must be color or float`, out.node))
185
185
 
186
+ if (graph.opacity) {
187
+ const opNode = nodes.get(graph.opacity.node)
188
+ const opT = opNode ? NODE_REGISTRY[opNode.type]?.outputs[graph.opacity.socket] : undefined
189
+ if (!opT) d.push(err(`opacity "${graph.opacity.node}.${graph.opacity.socket}" doesn't resolve`, graph.opacity.node))
190
+ else if (opT === "vec4") d.push(err(`opacity "${graph.opacity.node}.${graph.opacity.socket}" must be float or color`, graph.opacity.node))
191
+ }
192
+
186
193
  return d
187
194
  }
188
195
 
@@ -216,7 +223,11 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
216
223
  // Prune: reverse-DFS from the (possibly preview-overridden) output.
217
224
  const out = opts.previewNode ?? graph.output
218
225
  const reachable = new Set<string>()
219
- const stack = [out.node]
226
+ // SEEDED FROM BOTH ENDS. Pruning walks back from what is used, and opacity is
227
+ // used — seeding only the colour output drops every node that exists to feed
228
+ // the alpha curve, and the graph compiles cleanly with a fresnel nobody
229
+ // computed. A preview override replaces the colour output, never the opacity.
230
+ const stack = [out.node, ...(graph.opacity && !opts.previewNode ? [graph.opacity.node] : [])]
220
231
  while (stack.length) {
221
232
  const id = stack.pop()!
222
233
  if (reachable.has(id)) continue
@@ -341,8 +352,10 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
341
352
  }
342
353
 
343
354
  lines.push(` let final_color = ${outputExpr(out, "color")}; // @node:${out.node}`)
355
+ const opacity = graph.opacity && !opts.previewNode ? graph.opacity : null
356
+ if (opacity) lines.push(` let final_opacity = saturate(${outputExpr(opacity, "float")}); // @node:${opacity.node}`)
344
357
 
345
358
  const fsBody = lines.join("\n")
346
- const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current)
359
+ const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity)
347
360
  return { ok: true, wgsl, fsBody, slotMap, diagnostics, prunedNodes }
348
361
  }
@@ -153,6 +153,18 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
153
153
  // Blender Texture Coordinate → Reflection (view ray mirrored on the normal);
154
154
  // drives env-tracking patterns like metal's voronoi sparkle.
155
155
  reflection: "vector",
156
+ /**
157
+ * How much WORLD one pixel covers here, in units.
158
+ *
159
+ * What a texture gets free from its mipmap and a procedural pattern has
160
+ * no way to ask for. Past the point where this exceeds a pattern's
161
+ * wavelength the pattern is under-sampled, and a bump taken from it is
162
+ * differencing two uncorrelated samples — random per 2x2 quad, which on a
163
+ * receding plane stretches along the depth axis and reads as scan lines.
164
+ * Fade a layer out as this approaches its wavelength and the surface goes
165
+ * quietly smooth instead, which is what distance should look like.
166
+ */
167
+ footprint: "float",
156
168
  },
157
169
  contextOutputs: {
158
170
  normal: "n",
@@ -161,6 +173,7 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
161
173
  rest_pos: "input.restPos",
162
174
  uv: "vec3f(input.uv, 0.0)",
163
175
  reflection: "reflect(-v, n)",
176
+ footprint: "max(length(dpdx(input.worldPos)), length(dpdy(input.worldPos)))",
164
177
  },
165
178
  },
166
179
  // The scene's key light. Blender NPR presets rarely use a diffuse closure —
@@ -204,6 +217,38 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
204
217
  // PMX material's diffuse color (the authored base tint). Multiply the diffuse texture
205
218
  // by this for the MMD-correct base — untextured materials carry their color here, so a
206
219
  // texture-only base would render them white.
220
+ // WHAT THE SURFACE IS MADE OF, when whoever wrote the model said so.
221
+ //
222
+ // PMX carries a specular colour its own renderer barely uses, and a stage
223
+ // converted out of a game engine packs its material's (metal, roughness,
224
+ // occlusion) there — the same triple that game's property map holds, averaged
225
+ // per material. One shared look can then read it and a whole stage keeps ONE
226
+ // pipeline, where a look per material was what cost a garden its frame rate.
227
+ //
228
+ // A hand-authored PMX leaves it at whatever its exporter wrote, so a graph
229
+ // that reads this is choosing to trust the model. Nothing else does.
230
+ // How opaque the PMX said this material is, before its texture's alpha is
231
+ // multiplied in. A graph that computes its own opacity still wants it: glass
232
+ // is see-through by the amount its author chose and a mirror at a grazing
233
+ // angle, and without this every pane in a stage gets the same transparency
234
+ // whatever its material says.
235
+ material_alpha: {
236
+ inputs: {},
237
+ outputs: { value: "float" },
238
+ contextOutputs: { value: "material.alpha" },
239
+ },
240
+
241
+ material_specular: {
242
+ inputs: {},
243
+ outputs: { color: "color" },
244
+ contextOutputs: { color: "material.specular" },
245
+ },
246
+ material_shininess: {
247
+ inputs: {},
248
+ outputs: { value: "float" },
249
+ contextOutputs: { value: "material.shininess" },
250
+ },
251
+
207
252
  material_diffuse: {
208
253
  inputs: {},
209
254
  outputs: { color: "color" },
@@ -629,15 +674,21 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
629
674
 
630
675
  // ── View-dependent scalars ──
631
676
  fresnel: { inputs: { ior: F(1.45) }, outputs: { value: "float" }, emit: (a) => `fresnel(${a.ior}, n, v)` },
677
+ // The normal is an INPUT with the shading normal as its default, so these read
678
+ // like every other node when nothing is wired and can be handed a perturbed
679
+ // normal when something is. Water is why: its ripples live in a bump node, and
680
+ // a fresnel that quietly read the flat surface normal gave a pool whose
681
+ // reflection and opacity were both perfectly smooth however the bump was
682
+ // tuned — the ripples existed and touched nothing anybody could see.
632
683
  "layer_weight/fresnel": {
633
- inputs: { blend: F(0.5) },
684
+ inputs: { blend: F(0.5), normal: { type: "vector", contextDefault: "n" } },
634
685
  outputs: { value: "float" },
635
- emit: (a) => `layer_weight_fresnel(${a.blend}, n, v)`,
686
+ emit: (a) => `layer_weight_fresnel(${a.blend}, ${a.normal}, v)`,
636
687
  },
637
688
  "layer_weight/facing": {
638
- inputs: { blend: F(0.5) },
689
+ inputs: { blend: F(0.5), normal: { type: "vector", contextDefault: "n" } },
639
690
  outputs: { value: "float" },
640
- emit: (a) => `layer_weight_facing(${a.blend}, n, v)`,
691
+ emit: (a) => `layer_weight_facing(${a.blend}, ${a.normal}, v)`,
641
692
  },
642
693
 
643
694
  /**
@@ -717,6 +768,15 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
717
768
  outputs: { vector: "vector" },
718
769
  emit: (a) => `bump_lh(${a.strength}, ${a.height}, ${a.normal}, input.worldPos)`,
719
770
  },
771
+ // Its strength is a SLOPE, not a screen effect — see bump_world. Use it for
772
+ // anything whose detail belongs to the surface rather than to the frame:
773
+ // water, sand, hammered metal. `bump` holds its size on screen, which is what
774
+ // skin and cloth want and what water cannot use.
775
+ "bump/world": {
776
+ inputs: { strength: F(0.1), height: F(0, true), normal: V([0, 0, 0], true) },
777
+ outputs: { vector: "vector" },
778
+ emit: (a) => `bump_world(${a.strength}, ${a.height}, ${a.normal}, input.worldPos)`,
779
+ },
720
780
 
721
781
  // ── Procedural textures ──
722
782
  tex_noise: {
@@ -41,6 +41,26 @@ export type ShaderGraph = {
41
41
  links: GraphLink[]
42
42
  /** Must resolve to a color (vec3f) or float (auto-splatted) socket. */
43
43
  output: { node: string; socket: string }
44
+ /**
45
+ * How opaque the fragment is, when the graph wants to say.
46
+ *
47
+ * Absent, alpha is MMD's own: the material's alpha times its texture's, one
48
+ * number per material. That is right for a dress and wrong for anything whose
49
+ * transparency depends on where you stand — glass and water are the same
50
+ * surface at 5% looking straight down and near-mirror at a grazing angle, and
51
+ * no constant is both. A game's own water shader writes
52
+ * `(1 - NdotV) * fresnel` per pixel; this is the socket that lets a graph do
53
+ * the same.
54
+ *
55
+ * It REPLACES the constant rather than scaling it, because a graph that
56
+ * bothers to compute opacity is claiming the whole answer. A graph that still
57
+ * wants the texture's holes multiplies in `texture.alpha` itself.
58
+ *
59
+ * A material wearing a group whose graph declares this always draws in the
60
+ * transparent phase: the phase is otherwise decided at load from the texture's
61
+ * alpha, which knows nothing about a curve the graph invents.
62
+ */
63
+ opacity?: { node: string; socket: string }
44
64
  params?: ExposedParam[]
45
65
  /** Soft, host-only hints for library filtering and smart default-group / render-class
46
66
  * matching (e.g. ["hair"]). Ignored by the compiler; round-tripped. A graph is pure
@@ -143,8 +143,11 @@ ${gate}
143
143
  // Tail of fs(): consumes `final_color` + locals, writes FSOut. hashed forces output
144
144
  // alpha to 1 (the discard already did the cutout); hair scales alpha for the over-eyes
145
145
  // pass when IS_OVER_EYES is compiled true.
146
- function epilogue(renderClass: RenderClass, alphaMode: AlphaMode): string {
147
- const alphaBase = alphaMode === "hashed" ? "1.0" : "alpha"
146
+ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: boolean): string {
147
+ // A graph that computes its own opacity wins, including over hashed: hashed
148
+ // writes 1 because its discard already decided the cutout, and a graph asking
149
+ // for a curve is asking for the opposite of a cutout.
150
+ const alphaBase = hasOpacity ? "final_opacity" : alphaMode === "hashed" ? "1.0" : "alpha"
148
151
  // Empty while ids are off, so the epilogue is exactly what it was. The values
149
152
  // ride in the per-draw material uniform (see MaterialUniforms), which is what
150
153
  // keeps this working through the indirect-draw path.
@@ -198,6 +201,7 @@ export function assembleModule(
198
201
  alphaMode: AlphaMode,
199
202
  fsBody: string,
200
203
  includeStyleUniforms: boolean,
204
+ hasOpacity = false,
201
205
  ): string {
202
206
  return (
203
207
  NODES_WGSL +
@@ -211,7 +215,7 @@ export function assembleModule(
211
215
  prelude(renderClass, alphaMode) +
212
216
  fsBody +
213
217
  "\n" +
214
- epilogue(renderClass, alphaMode) +
218
+ epilogue(renderClass, alphaMode, hasOpacity) +
215
219
  "}\n"
216
220
  )
217
221
  }
@@ -36,6 +36,20 @@ export type StyleGroup = {
36
36
  * no-op rather than a failure.
37
37
  */
38
38
  images?: (GroupImage | GroupImageSource | null)[]
39
+ /**
40
+ * The same slots, but PER MATERIAL — for a group whose members each bring
41
+ * their own maps.
42
+ *
43
+ * `images` belongs to the LOOK and is right when a look carries its own ramp
44
+ * or detail map. A converted stage is the other case: twenty-seven props, one
45
+ * shading model, and a different normal map on every one of them. Keyed here,
46
+ * they share a pipeline and differ only in what the bind group points at —
47
+ * the alternative is a group per material, which is a pipeline per material,
48
+ * which is what cost a garden stage its frame rate.
49
+ *
50
+ * A material with no entry falls back to `images`, and then to 1×1 white.
51
+ */
52
+ imagesByMaterial?: Record<string, (GroupImage | GroupImageSource | null)[]>
39
53
  }
40
54
 
41
55
  export type GroupImageSource = ImageBitmap | HTMLImageElement | HTMLCanvasElement
package/src/model.ts CHANGED
@@ -116,6 +116,17 @@ export interface Material {
116
116
  edgeColor: [number, number, number, number]
117
117
  edgeSize: number
118
118
  vertexCount: number
119
+ /**
120
+ * The PMX material's free-text memo.
121
+ *
122
+ * MMD shows it in the material panel and nothing reads it, which makes it the
123
+ * one place a converter can say what a material WAS without inventing a
124
+ * sidecar. A stage ported out of a game engine writes its source shader here
125
+ * — `SimPipeline/Scene/Ripplet`, `SimPipeline/PBR/Glass` — so a host can give
126
+ * the pool the water look by what it is rather than by hoping its name
127
+ * contains "water". Empty on a hand-authored model, which reads as no claim.
128
+ */
129
+ memo: string
119
130
  }
120
131
 
121
132
  export interface Bone {
package/src/pmx-loader.ts CHANGED
@@ -303,7 +303,7 @@ export class PmxLoader {
303
303
  const isSharedToonTexture = this.getUint8() === 1
304
304
  const toonTextureIndex = isSharedToonTexture ? this.getUint8() : this.getNonVertexIndex(this.textureIndexSize)
305
305
 
306
- this.getText() // comment (skip)
306
+ const memo = this.getText()
307
307
  const vertexCount = this.getInt32()
308
308
 
309
309
  // PMX material flag bits:
@@ -311,6 +311,7 @@ export class PmxLoader {
311
311
  // Bit 4 (0x10): Edge drawing (outline)
312
312
  const mat: Material = {
313
313
  name,
314
+ memo,
314
315
  diffuse,
315
316
  specular,
316
317
  ambient,
@@ -651,6 +651,28 @@ fn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
651
651
  }
652
652
 
653
653
  // LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference
654
+ // The same bump measured against the WORLD instead of the screen.
655
+ //
656
+ // bump_lh below takes dpdx(height) — the change across one PIXEL — and drops
657
+ // the world size of that pixel on the floor when it normalizes the cross
658
+ // products. Its slope is therefore per pixel, so the same surface ripples at a
659
+ // distance, where a pixel spans metres, and goes glass-flat underfoot, where it
660
+ // spans millimetres. For skin and cloth that reads as detail which holds its
661
+ // size on screen, which is why it stays; for water it is the difference between
662
+ // a pool and a mirror, because water is the thing you stand next to.
663
+ //
664
+ // Dividing each derivative by its own footprint gives the true world gradient:
665
+ // view-independent, and strength becomes a real slope — 0.25 means a quarter
666
+ // rise over a run, whatever the camera does.
667
+ fn bump_world(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
668
+ let dpdx_pos = dpdx(world_pos);
669
+ let dpdy_pos = dpdy(world_pos);
670
+ let dhdx = dpdx(height) / max(length(dpdx_pos), 1e-8);
671
+ let dhdy = dpdy(height) / max(length(dpdy_pos), 1e-8);
672
+ let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));
673
+ return normalize(perturbed);
674
+ }
675
+
654
676
  fn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
655
677
  let dhdx = dpdx(height);
656
678
  let dhdy = dpdy(height);