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.
- package/dist/engine.d.ts +49 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +97 -16
- package/dist/graph/compile.d.ts.map +1 -1
- package/dist/graph/compile.js +17 -2
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +62 -4
- package/dist/graph/schema.d.ts +23 -0
- package/dist/graph/schema.d.ts.map +1 -1
- package/dist/graph/slots.d.ts +1 -1
- package/dist/graph/slots.d.ts.map +1 -1
- package/dist/graph/slots.js +7 -4
- package/dist/graph/style-group.d.ts +14 -0
- package/dist/graph/style-group.d.ts.map +1 -1
- package/dist/model.d.ts +11 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +2 -1
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +22 -0
- package/package.json +1 -1
- package/src/engine.ts +109 -15
- package/src/graph/compile.ts +15 -2
- package/src/graph/registry.ts +64 -4
- package/src/graph/schema.ts +20 -0
- package/src/graph/slots.ts +7 -3
- package/src/graph/style-group.ts +14 -0
- package/src/model.ts +11 -0
- package/src/pmx-loader.ts +2 -1
- package/src/shaders/materials/nodes.ts +22 -0
|
@@ -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
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,
|
|
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.
|
|
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
|
|
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 (!
|
|
11099
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/graph/compile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/graph/registry.ts
CHANGED
|
@@ -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},
|
|
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},
|
|
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: {
|
package/src/graph/schema.ts
CHANGED
|
@@ -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
|
package/src/graph/slots.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/graph/style-group.ts
CHANGED
|
@@ -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()
|
|
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);
|