reze-engine 0.25.1 → 0.26.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.
@@ -39,7 +39,7 @@ override APPLY_GAMMA: bool = true;
39
39
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
40
40
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
41
41
  @group(0) @binding(2) var bloomSamp: sampler;
42
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 7>;
42
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 10>;
43
43
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
44
44
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
45
45
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -62,6 +62,8 @@ override APPLY_GAMMA: bool = true;
62
62
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
63
63
  // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
64
64
  // viewU[6] = (time seconds, effect on/off, canvas width, canvas height).
65
+ // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
66
+ // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
65
67
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
66
68
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
67
69
 
@@ -81,6 +83,24 @@ fn filmic(x: f32) -> f32 {
81
83
 
82
84
  /** Canvas size in pixels — for user background effects (aspect correction). */
83
85
  fn bgResolution() -> vec2f { return viewU[6].zw; }
86
+
87
+ /** Color grading, applied to the tonemapped SCENE (not the background — see the
88
+ * call site). The core is ASC CDL, the film-industry interchange standard:
89
+ *
90
+ * out = (in · slope + offset) ^ power then saturation (SOP → SAT)
91
+ *
92
+ * Using the real standard rather than invented controls means a look authored
93
+ * here maps onto any grading tool. slope/offset/power are derived on the CPU
94
+ * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the
95
+ * per-pixel cost is one mul-add, one pow, one lerp. */
96
+ fn grade(c: vec3f) -> vec3f {
97
+ var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);
98
+ // Contrast pivots on 0.5 — display-referred midpoint, since we grade post-Filmic.
99
+ x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);
100
+ // Rec.709 luma, matching the ASC SAT node.
101
+ let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));
102
+ return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));
103
+ }
84
104
  `
85
105
 
86
106
  const COMPOSITE_BODY = /* wgsl */ `
@@ -107,6 +127,13 @@ const COMPOSITE_BODY = /* wgsl */ `
107
127
  let exposed = combined * exp2(viewU[0].x);
108
128
  let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
109
129
  var disp = max(tm, vec3f(0.0));
130
+ // Grade the SCENE only, before the display gamma. Deliberately not applied to
131
+ // the background: it keeps a picked background color exactly as picked, and —
132
+ // load-bearing — leaves green-screen mode's key color unshifted so chroma
133
+ // keying still works. Skipped entirely when the grade is neutral.
134
+ if (viewU[9].w > 0.5) {
135
+ disp = grade(disp);
136
+ }
110
137
  if (APPLY_GAMMA) {
111
138
  disp = pow(disp, vec3f(viewU[0].y));
112
139
  }
@@ -117,7 +144,7 @@ const COMPOSITE_BODY = /* wgsl */ `
117
144
  var bgA = select(0.0, 1.0, bg.w > 0.5);
118
145
  var bgPm = bg.rgb * bgA; // premultiplied accumulator
119
146
  let fxOn = viewU[6].y > 0.5;
120
- if (bg.w > 1.5 || fxOn) {
147
+ if ((bg.w > 1.5 || fxOn) COVERAGE_GATE) {
121
148
  // The equirect and any effect both need this pixel's world-space view ray,
122
149
  // rebuilt from the camera basis. The dome sits at infinity (no parallax) —
123
150
  // PhotoDome-style, display-only.
@@ -152,8 +179,28 @@ const EFFECT_CALL = /* wgsl */ `
152
179
  }
153
180
  `
154
181
 
182
+ // Derivative builtins are illegal in non-uniform control flow (WGSL uniformity
183
+ // analysis rejects the pipeline), so the coverage gate below can only wrap
184
+ // effect code that doesn't use them. Checked textually at build time.
185
+ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
186
+
187
+ /** Skip the whole background block (equirect sample + effect) behind pixels the
188
+ * model fully covers — the composite multiplies the result by (1 - alpha) = 0
189
+ * there anyway, and on a full-screen effect that's a third or more of the frame
190
+ * (the cost Safari feels most). The equirect uses explicit-LOD sampling, which
191
+ * is always legal in non-uniform flow; only derivative-using effects must keep
192
+ * uniform control flow and forgo the gate. */
193
+ function coverageGate(effect?: CompositeEffectSource | null): string {
194
+ const gated = !effect || !USES_DERIVATIVES.test(effect.wgsl)
195
+ return gated ? "&& alpha < 0.999" : ""
196
+ }
197
+
155
198
  export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
156
- if (!effect) return COMPOSITE_HEAD + COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL)
199
+ if (!effect)
200
+ return (
201
+ COMPOSITE_HEAD +
202
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", NO_EFFECT_CALL).replace("COVERAGE_GATE", coverageGate(null))
203
+ )
157
204
  return (
158
205
  COMPOSITE_HEAD +
159
206
  "\n// ── user background effect (setBackgroundEffect) ──\n" +
@@ -161,7 +208,7 @@ export function buildCompositeShader(effect?: CompositeEffectSource | null): str
161
208
  "\n" +
162
209
  effect.wgsl +
163
210
  "\n" +
164
- COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim())
211
+ COMPOSITE_BODY.replace("BG_EFFECT_CALL", EFFECT_CALL.trim()).replace("COVERAGE_GATE", coverageGate(effect))
165
212
  )
166
213
  }
167
214
 
@@ -1,12 +1,25 @@
1
- // MMD-style screen-space outline via normal-extrusion in clip space.
2
- // Aspect-compensated so pixel thickness stays stable across viewport sizes.
1
+ // MMD-style inverted-hull outline, ported from babylon-mmd's mmdOutline shader
2
+ // (the reference implementation whose output matches MMD):
3
+ // 1. Extrude along the VIEW-SPACE normal's XY, normalized — a pure screen
4
+ // direction, so rims never smear toward the camera at grazing angles.
5
+ // 2. Offset in clip space by edgeSize · 4/viewport · w. The ×w cancels the
6
+ // perspective divide → CONSTANT screen thickness of exactly
7
+ // 2·edgeSize device pixels (babylon: `screenNormal / (viewport*0.25) *
8
+ // offset * projectedPosition.w`). PMX edgeSize ~0.3–1.0 ⇒ fine 0.6–2px
9
+ // rims, matching MMD instead of our former chunky constants.
10
+ // 3. The FRAGMENT stage samples the material's own diffuse texture and
11
+ // MODULATES the rim's alpha by it (discarding only near-zero cut-out
12
+ // margins) — sheer fabric gets a proportional rim, never a solid black
13
+ // hull, without dropping the author's edge flag.
3
14
 
4
15
  export const OUTLINE_SHADER_WGSL = /* wgsl */ `
5
16
  struct CameraUniforms {
6
17
  view: mat4x4f,
7
18
  projection: mat4x4f,
8
19
  viewPos: vec3f,
9
- _padding: f32,
20
+ // Render-target height in device pixels (engine writes it each frame);
21
+ // width is recovered via the projection matrix's aspect.
22
+ viewportHeight: f32,
10
23
  };
11
24
 
12
25
  struct MaterialUniforms {
@@ -18,16 +31,20 @@ struct MaterialUniforms {
18
31
  };
19
32
 
20
33
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
34
+ @group(0) @binding(1) var edgeSampler: sampler;
21
35
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
22
36
  @group(2) @binding(0) var<uniform> material: MaterialUniforms;
37
+ @group(2) @binding(1) var diffuseTexture: texture_2d<f32>;
23
38
 
24
39
  struct VertexOutput {
25
40
  @builtin(position) position: vec4f,
41
+ @location(0) uv: vec2f,
26
42
  };
27
43
 
28
44
  @vertex fn vs(
29
45
  @location(0) position: vec3f,
30
46
  @location(1) normal: vec3f,
47
+ @location(2) uv: vec2f,
31
48
  @location(3) joints0: vec4<u32>,
32
49
  @location(4) weights0: vec4<f32>
33
50
  ) -> VertexOutput {
@@ -51,33 +68,43 @@ struct VertexOutput {
51
68
  let worldPos = skinnedPos.xyz;
52
69
  let worldNormal = normalize(skinnedNrm);
53
70
 
54
- // Screen-space outline extrusion MMD-style pixel-stable edge line.
55
- // 1. Project position and normal-as-direction to clip space.
56
- // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
57
- // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
58
- // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
59
- // so the perspective divide cancels out offset stays constant in NDC regardless
60
- // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
61
- // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
62
- // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
63
- let viewProj = camera.projection * camera.view;
64
- let clipPos = viewProj * vec4f(worldPos, 1.0);
65
- let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
66
- // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
67
- // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
71
+ let clipPos = camera.projection * camera.view * vec4f(worldPos, 1.0);
72
+
73
+ // babylon-mmd: screenNormal = normalize((view * worldNormal).xy)
74
+ let viewNormal = (camera.view * vec4f(worldNormal, 0.0)).xyz;
75
+ let snLen = length(viewNormal.xy);
76
+ let screenNormal = select(vec2f(0.0, 0.0), viewNormal.xy / snLen, snLen > 1e-5);
77
+
78
+ // Reference-height normalization (babylon-mmd ships this variant commented
79
+ // out as \`renderHeight = 1080\`): thickness is a constant FRACTION of the
80
+ // frame 2·edgeSize px at 1080p — so retina DPR and 4K export don't thin
81
+ // the rims to sub-pixel. Width follows the projection aspect.
82
+ // projection[1][1]/projection[0][0] = width/height for a symmetric frustum.
68
83
  let aspect = camera.projection[1][1] / camera.projection[0][0];
69
- let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
70
- let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
71
- let edgeScale = 0.0016;
72
- let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
84
+ let viewport = vec2f(1080.0 * aspect, 1080.0);
85
+
86
+ // NDC offset = edgeSize · 4/viewport, ×w so the perspective divide cancels:
87
+ // constant screen thickness at any distance (babylon-mmd parity).
88
+ let offset = screenNormal * (material.edgeSize * 4.0 / viewport) * clipPos.w;
73
89
  output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
90
+ output.uv = uv;
74
91
  return output;
75
92
  }
76
93
 
77
94
  struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
78
- @fragment fn fs() -> FSOut {
95
+ @fragment fn fs(input: VertexOutput) -> FSOut {
96
+ // Rim alpha FOLLOWS the fabric's texture alpha instead of a hard alpha test:
97
+ // MMD draws blend-material edges solid (only cutout materials alpha-test), so
98
+ // a 0.4 discard erased the whole hull on semi-transparent cloth — stockinged
99
+ // legs crossing lost their outline entirely. Modulating instead keeps a
100
+ // proportional rim on sheer weave (never a solid black hull) and still
101
+ // discards true cut-out margins like hair-card borders.
102
+ let texA = textureSample(diffuseTexture, edgeSampler, input.uv).a;
103
+ if (texA < 0.05) {
104
+ discard;
105
+ }
79
106
  var out: FSOut;
80
- out.color = material.edgeColor;
107
+ out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA);
81
108
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
82
109
  return out;
83
110
  }