reze-engine 0.60.1 → 0.60.3

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.
@@ -8,7 +8,7 @@ import type { Diagnostic, ExposedParam, GraphNode, SocketValue, ShaderGraph } fr
8
8
  import { NODE_REGISTRY, canConvert, convert, fmtValue, literalFits } from "./registry"
9
9
  import type { NodeSpec, SockT } from "./registry"
10
10
  import { assembleModule } from "./slots"
11
- import type { AlphaMode, RenderClass } from "./render-class"
11
+ import type { AlphaMode, RenderClass, StyleBlend } from "./render-class"
12
12
 
13
13
  export type CompileOptions = {
14
14
  /** Fold exposed params to their defaults as consts (no StyleUniforms binding).
@@ -22,6 +22,8 @@ export type CompileOptions = {
22
22
  renderClass?: RenderClass
23
23
  /** Alpha-handling axis, orthogonal to renderClass. Default "opaque". */
24
24
  alphaMode?: AlphaMode
25
+ /** How the result meets the frame — see StyleGroup.blend. Default "over". */
26
+ blend?: StyleBlend
25
27
  }
26
28
 
27
29
  /** UBO slot for one exposed param: write `value` at style.p[vec4Index] (+ component). */
@@ -356,6 +358,6 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
356
358
  if (opacity) lines.push(` let final_opacity = saturate(${outputExpr(opacity, "float")}); // @node:${opacity.node}`)
357
359
 
358
360
  const fsBody = lines.join("\n")
359
- const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity)
361
+ const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over")
360
362
  return { ok: true, wgsl, fsBody, slotMap, diagnostics, prunedNodes }
361
363
  }
@@ -848,7 +848,7 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
848
848
  const bsdf =
849
849
  `eval_principled(PrincipledIn(${a.base_color}, ${a.metallic}, ` +
850
850
  `${spec}, ${a.roughness}, ` +
851
- `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}), ${a.normal}, l, v, sun, amb, shadow)`
851
+ `${a.spec_clamp}, ${a.sheen_weight}, ${a.sheen_tint}), ${a.normal}, l, v, sun, amb, shadow, input.worldPos)`
852
852
  // v2 defaults Emission Strength to 0, which is the overwhelming case. Emit
853
853
  // the term only when it can do something, so the common shader carries no
854
854
  // dead add and the output stays readable.
@@ -11,6 +11,10 @@ export type RenderClass = "auto" | "eye" | "hair"
11
11
  * hashed alpha test (stockings); "opaque" = the standard near-zero threshold discard. */
12
12
  export type AlphaMode = "opaque" | "hashed"
13
13
 
14
+ /** How a group's colour meets the frame. "over" is the ordinary alpha blend;
15
+ * "additive" adds its light and never reads alpha — see StyleGroup.blend. */
16
+ export type StyleBlend = "over" | "additive"
17
+
14
18
  /** Descriptive manifest for hosts (reze-design) so the render-class picker is data-driven
15
19
  * instead of hardcoding strings. The effect implementations stay engine-side; this only
16
20
  * describes them. `pairsWith` = the effect needs a counterpart class present to show. */
@@ -10,7 +10,7 @@
10
10
  import { NODES_WGSL } from "../shaders/materials/nodes"
11
11
  import { COMMON_MATERIAL_PRELUDE_WGSL, DISSOLVE_WGSL, commonFsOutWgsl } from "../shaders/materials/common"
12
12
  import { sceneIdWriteWgsl } from "../shaders/passes/scene-contract"
13
- import type { AlphaMode, RenderClass } from "./render-class"
13
+ import type { AlphaMode, RenderClass, StyleBlend } from "./render-class"
14
14
 
15
15
  // ── Module-scope declarations ──
16
16
  // hair: the over-eyes pipeline-override constant (a second pipeline is compiled with
@@ -80,11 +80,19 @@ function decls(renderClass: RenderClass, alphaMode: AlphaMode): string {
80
80
  // fs() header up to and including the graph body's context locals. Composed so the
81
81
  // hand-written material shaders' local names (tex_color, n, v, l, sun, amb, shadow) are
82
82
  // preserved exactly — the registry's context nodes and emit functions reference them.
83
- function prelude(renderClass: RenderClass, alphaMode: AlphaMode): string {
83
+ function prelude(renderClass: RenderClass, alphaMode: AlphaMode, blend: StyleBlend): string {
84
+ // ADDITIVE NEVER GATES ON ALPHA. Its blend adds colour and reads no alpha at
85
+ // all, so a surface painted this way carries its picture in RGB and leaves
86
+ // alpha at zero — a game's sky layers do exactly that. Discarding on alpha
87
+ // throws every one of those fragments away before the blend can add them,
88
+ // which is what made X309's nebula and starfield invisible while its cloud
89
+ // dome (opaque) came through.
84
90
  const discard =
85
- alphaMode === "hashed"
86
- ? " if (alpha < hashed_alpha_threshold(input.restPos)) { discard; }"
87
- : " if (alpha < 0.001) { discard; }"
91
+ blend === "additive"
92
+ ? ""
93
+ : alphaMode === "hashed"
94
+ ? " if (alpha < hashed_alpha_threshold(input.restPos)) { discard; }"
95
+ : " if (alpha < 0.001) { discard; }"
88
96
  const gate = renderClass === "eye" ? EYE_REAR_GATE : ""
89
97
  // Double-sided shading, winding-independent: a normal pointing away from the
90
98
  // camera means we're seeing the surface's other side — flip it. Only genuinely
@@ -143,11 +151,15 @@ ${gate}
143
151
  // Tail of fs(): consumes `final_color` + locals, writes FSOut. hashed forces output
144
152
  // alpha to 1 (the discard already did the cutout); hair scales alpha for the over-eyes
145
153
  // pass when IS_OVER_EYES is compiled true.
146
- function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: boolean): string {
154
+ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: boolean, blend: StyleBlend): string {
147
155
  // A graph that computes its own opacity wins, including over hashed: hashed
148
156
  // 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"
157
+ // for a curve is asking for the opposite of a cutout. Additive writes 1 for a
158
+ // third reason: its colour blend ignores src alpha entirely, so the only thing
159
+ // alpha still reaches is the aux target's coverage — and a sky layer that
160
+ // reported zero coverage would be invisible to bloom and to the composite.
161
+ const alphaBase =
162
+ blend === "additive" ? "1.0" : hasOpacity ? "final_opacity" : alphaMode === "hashed" ? "1.0" : "alpha"
151
163
  // Empty while ids are off, so the epilogue is exactly what it was. The values
152
164
  // ride in the per-draw material uniform (see MaterialUniforms), which is what
153
165
  // keeps this working through the indirect-draw path.
@@ -202,6 +214,7 @@ export function assembleModule(
202
214
  fsBody: string,
203
215
  includeStyleUniforms: boolean,
204
216
  hasOpacity = false,
217
+ blend: StyleBlend = "over",
205
218
  ): string {
206
219
  return (
207
220
  NODES_WGSL +
@@ -212,10 +225,10 @@ export function assembleModule(
212
225
  commonFsOutWgsl() +
213
226
  (includeStyleUniforms ? STYLE_UNIFORMS_WGSL : "") +
214
227
  decls(renderClass, alphaMode) +
215
- prelude(renderClass, alphaMode) +
228
+ prelude(renderClass, alphaMode, blend) +
216
229
  fsBody +
217
230
  "\n" +
218
- epilogue(renderClass, alphaMode, hasOpacity) +
231
+ epilogue(renderClass, alphaMode, hasOpacity, blend) +
219
232
  "}\n"
220
233
  )
221
234
  }
@@ -5,7 +5,7 @@
5
5
  // the hand-written preset path. See docs/style-groups-spec.md.
6
6
 
7
7
  import type { Diagnostic, ShaderGraph } from "./schema"
8
- import type { AlphaMode, RenderClass } from "./render-class"
8
+ import type { AlphaMode, RenderClass, StyleBlend } from "./render-class"
9
9
  import type { StyleSlot } from "./compile"
10
10
 
11
11
  export type StyleGroup = {
@@ -22,6 +22,16 @@ export type StyleGroup = {
22
22
  renderClass?: RenderClass
23
23
  /** Alpha-handling axis, orthogonal to renderClass. Default "opaque". */
24
24
  alphaMode?: AlphaMode
25
+ /**
26
+ * How this group's colour meets what is already there. Default "over".
27
+ *
28
+ * "additive" is LIGHT rather than matter: the fragment is added to the frame
29
+ * and its alpha is never read. A game's sky layers are painted this way — a
30
+ * nebula and a starfield on cylinders round the scene, their alpha 0 across
31
+ * the whole image because additive blending does not look at it. Laid over,
32
+ * they are invisible.
33
+ */
34
+ blend?: StyleBlend
25
35
  /**
26
36
  * Extra image maps for this group's shading, up to four.
27
37
  *
package/src/index.ts CHANGED
@@ -69,7 +69,7 @@ export type {
69
69
  Diagnostic,
70
70
  } from "./graph/schema"
71
71
  export { NODE_REGISTRY, type NodeSpec, type SockT } from "./graph/registry"
72
- export { RENDER_CLASSES, type RenderClass, type AlphaMode, type RenderClassInfo } from "./graph/render-class"
72
+ export { RENDER_CLASSES, type RenderClass, type AlphaMode, type StyleBlend, type RenderClassInfo } from "./graph/render-class"
73
73
  export type {
74
74
  StyleGroup,
75
75
  GroupImage,
package/src/model.ts CHANGED
@@ -1359,11 +1359,44 @@ export class Model {
1359
1359
  Quat.slerpInto(_eyeOwn, _eyeQuat, w, own)
1360
1360
  }
1361
1361
  if (bothRot) bothRot.setIdentity()
1362
- // Only these need their world matrices again; nothing hangs off an eye.
1363
- this.eyeSubset ??= new Int32Array(both !== undefined ? [both, left, right] : [left, right])
1362
+ // The eye bones and everything that follows them — the bones under them
1363
+ // and the bones inheriting from them. Rigs often weight the eyeball to one
1364
+ // of those (左目2, a 付与 child), and a motion keying the eyes left it on
1365
+ // the motion's gaze: the full pass had already run.
1366
+ this.eyeSubset ??= this.eyeFollowers(both !== undefined ? [both, left, right] : [left, right])
1364
1367
  this.computeWorldMatrices(this.eyeSubset)
1365
1368
  }
1366
1369
 
1370
+ /** The seeds, their descendants and their 付与 inheritors, transitively, in
1371
+ * deform order. */
1372
+ private eyeFollowers(seeds: number[]): Int32Array {
1373
+ const bones = this.skeleton.bones
1374
+ const n = bones.length
1375
+ const affected = new Uint8Array(n)
1376
+ for (const s of seeds) affected[s] = 1
1377
+ let changed = true
1378
+ while (changed) {
1379
+ changed = false
1380
+ for (let k = 0; k < n; k++) {
1381
+ const i = this.deformOrder[k]
1382
+ if (affected[i]) continue
1383
+ const b = bones[i]
1384
+ const ap = b.appendParentIndex
1385
+ const inherits = (b.appendRotate || b.appendMove) && ap !== undefined && ap >= 0 && ap < n && affected[ap]
1386
+ if (inherits || (b.parentIndex >= 0 && affected[b.parentIndex])) {
1387
+ affected[i] = 1
1388
+ changed = true
1389
+ }
1390
+ }
1391
+ }
1392
+ const order: number[] = []
1393
+ for (let k = 0; k < n; k++) {
1394
+ const i = this.deformOrder[k]
1395
+ if (affected[i]) order.push(i)
1396
+ }
1397
+ return Int32Array.from(order)
1398
+ }
1399
+
1367
1400
  /** Compose a constant local rotation onto a bone AFTER every pose source (clip,
1368
1401
  * blend, tween) each frame — the classic MMD "heel correction": pitch the 足首
1369
1402
  * bones so a motion authored for flat shoes grounds a heeled model. Persists
@@ -979,6 +979,70 @@ fn principled_specular(ior: f32, level: f32) -> f32 {
979
979
  return (r * r * 2.0 * max(level, 0.0)) / 0.08;
980
980
  }
981
981
 
982
+ // ─── The scene's lamps, on a PBR closure ───────────────────────────
983
+ //
984
+ // A lamp reaches a material as rzLightsDiffuse, added outside the graph and
985
+ // multiplied by the raw texture: Lambert and nothing else, because that layer
986
+ // cannot know what the surface is. On a principled closure it can. A floor at
987
+ // roughness 0.23 under forty candles carries forty glints, and a metal
988
+ // REFLECTS a lamp instead of having it painted on — without this a ported
989
+ // stage is a photograph of itself, lit but never shining.
990
+ //
991
+ // Same reach as _rzLightOne: the inverse square past RZ_LAMP_NEAR, the
992
+ // (1 − (d/R)⁴)² window that makes the radius real, the squared cone. N·L is
993
+ // inside bsdf_ggx. Declared here for bsdf_ggx and resolved against the lights
994
+ // API the material prelude brings; the ground pass takes that API without
995
+ // this file and is untouched.
996
+ //
997
+ // It walks the light grid a second time — the lamps THIS CELL can see, which
998
+ // is what the grid is for — and with no lamps at all neither walk runs.
999
+
1000
+ fn _rzLampSpecOne(i: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {
1001
+ let pr = _rzLightVec(i, 0u);
1002
+ let d = pr.xyz - p;
1003
+ let dist = length(d);
1004
+ if (dist >= pr.w) { return vec3f(0.0); }
1005
+ let toLight = d / max(dist, 1e-4);
1006
+ let ndl = dot(n, toLight);
1007
+ if (ndl <= 0.0) { return vec3f(0.0); }
1008
+ let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);
1009
+ let t2 = t * t;
1010
+ let window = 1.0 - t2 * t2;
1011
+ let falloff = window * window / max(dist * dist, RZ_LAMP_NEAR * RZ_LAMP_NEAR);
1012
+ let cone = rzLightCone(i);
1013
+ let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
1014
+ return rzLightColor(i) * (bsdf_ggx(n, toLight, v, ndl, ndv, roughness) * falloff * aim * aim);
1015
+ }
1016
+
1017
+ fn _rzLampSpecWord(bits0: u32, base: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {
1018
+ var acc = vec3f(0.0);
1019
+ var bits = bits0;
1020
+ loop {
1021
+ if (bits == 0u) { break; }
1022
+ let i = base + firstTrailingBit(bits);
1023
+ bits = bits & (bits - 1u);
1024
+ acc = acc + _rzLampSpecOne(i, p, n, v, ndv, roughness);
1025
+ }
1026
+ return acc;
1027
+ }
1028
+
1029
+ fn rzLampsSpecular(p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {
1030
+ var acc = vec3f(0.0);
1031
+ let count = rzLightCount();
1032
+ let docs = _rzLightDocCount();
1033
+ if (docs > 0u) {
1034
+ let m = _rzLightCellMask(p);
1035
+ acc = acc + _rzLampSpecWord(m.x, 0u, p, n, v, ndv, roughness) +
1036
+ _rzLampSpecWord(m.y, 32u, p, n, v, ndv, roughness) +
1037
+ _rzLampSpecWord(m.z, 64u, p, n, v, ndv, roughness) +
1038
+ _rzLampSpecWord(m.w, 96u, p, n, v, ndv, roughness);
1039
+ }
1040
+ for (var i = docs; i < count; i = i + 1u) {
1041
+ acc = acc + _rzLampSpecOne(i, p, n, v, ndv, roughness);
1042
+ }
1043
+ return acc;
1044
+ }
1045
+
982
1046
  struct PrincipledIn {
983
1047
  base: vec3f,
984
1048
  metallic: f32,
@@ -992,7 +1056,7 @@ struct PrincipledIn {
992
1056
  fn eval_principled(
993
1057
  p: PrincipledIn,
994
1058
  N: vec3f, L: vec3f, V: vec3f,
995
- sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32
1059
+ sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32, wp: vec3f
996
1060
  ) -> vec3f {
997
1061
  let NL = max(dot(N, L), 0.0);
998
1062
  let NV = max(dot(N, V), 1e-4);
@@ -1011,8 +1075,11 @@ fn eval_principled(
1011
1075
  // Direct glossy — bsdf_ggx already includes NL; no F applied here (tinted after
1012
1076
  // accum with reflection_color). ltc_brdf_scale rescales direct to match the
1013
1077
  // split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.
1014
- let spec_direct_raw = bsdf_ggx(N, L, V, NL, NV, p.roughness)
1015
- * sun_rgb * shadow * ltc_brdf_scale_from_lut(lut);
1078
+ // The sun, and every lamp that reaches this point. One clamp over the pair:
1079
+ // a candle a hand's width from a polished floor is a firefly otherwise.
1080
+ let spec_direct_raw = (bsdf_ggx(N, L, V, NL, NV, p.roughness) * sun_rgb * shadow
1081
+ + rzLampsSpecular(wp, N, V, NV, p.roughness))
1082
+ * ltc_brdf_scale_from_lut(lut);
1016
1083
  let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));
1017
1084
  // Indirect specular reads the world ALONG THE REFLECTION, at a roughness-
1018
1085
  // picked level — EEVEE's probe_evaluate_world_spec, where the diffuse half
@@ -90,6 +90,14 @@ type SceneRenderClass =
90
90
  * cast — a reflection that claimed her object id would seed the distance
91
91
  * field twice and put every silhouette effect's border around the glass. */
92
92
  | "mirror"
93
+ /** A material whose light is ADDED rather than laid over what is behind it.
94
+ * A game's sky layers are painted this way — a nebula, a starfield, a band of
95
+ * colour on a cylinder round the whole scene — and their alpha is 0 across
96
+ * the entire image, because additive blending never reads it: the picture is
97
+ * wholly in RGB. Drawn alpha-over they are invisible, which is exactly what
98
+ * X309's night sky was. Out of WRITES_ID for the mirror's reason — a sheet of
99
+ * light is not an object anything should be able to pick or outline. */
100
+ | "material-additive"
93
101
  /** Particles and ribbons in their default, non-additive mode. */
94
102
  | "particle"
95
103
  /** Particles declaring `#blend additive` — LIGHT rather than matter, so
@@ -172,6 +180,9 @@ const WRITES_ID = new Set<SceneRenderClass>(["material", "ground"])
172
180
  /** The blends each class writes its two attachments with. */
173
181
  const BLENDS: Record<Exclude<SceneRenderClass, "depth-prepass">, [GPUBlendState, GPUBlendState]> = {
174
182
  material: [ALPHA_OVER, ALPHA_OVER],
183
+ // The same pair the additive particles use: light into the colour target, and
184
+ // coverage that still accumulates so bloom and the composite can see it.
185
+ "material-additive": [ADD_KEEP_ALPHA, ADD_BOTH],
175
186
  // PREMULTIPLIED, like the ground and for the same reason: what a mirror
176
187
  // writes is a sample of the HDR target, and that target already holds colour
177
188
  // premultiplied by its own alpha. Handed to the src-alpha blend it would be