reze-engine 0.66.4 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -97,7 +97,7 @@ override APPLY_GAMMA: bool = true;
97
97
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
98
98
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
99
99
  @group(0) @binding(2) var bloomSamp: sampler;
100
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 15>;
100
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 16>;
101
101
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
102
102
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
103
103
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -119,6 +119,8 @@ override APPLY_GAMMA: bool = true;
119
119
  // viewU[9] = (grade slope.rgb, grade flags) — bit 0 the CDL grade(), bit 1 the
120
120
  // scene's own LUT (setStageGrade) — see _rzGradeScene() below.
121
121
  // viewU[10] = (camera world position, _) — refreshed with the basis above.
122
+ // viewU[11..14] = the cast's positions (effects; count in viewU[10].w).
123
+ // viewU[15] = (soft curve contrast, _, _, _).
122
124
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
123
125
  @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
124
126
  // The scene pass's own MSAA depth buffer, bound depth-only. NOT an extra
@@ -217,7 +219,7 @@ fn srgb_encode(x: f32) -> f32 {
217
219
  * The game's ACES branch is off in every scene measured.
218
220
  */
219
221
  fn softTransform(c: vec3f) -> vec3f {
220
- let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
222
+ let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(viewU[15].x));
221
223
  return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
222
224
  }
223
225
 
@@ -6,5 +6,18 @@
6
6
  * edgeSize 16..20, so this is 20.
7
7
  */
8
8
  export declare const RZ_OUTLINE_DISSOLVE_OFFSET = 20;
9
+ /** Where the scene-wide width multiplier sits, in bytes — the float after
10
+ * dissolve (Engine.setOutlineWidth). */
11
+ export declare const RZ_OUTLINE_WIDTH_OFFSET = 24;
12
+ /** Where the scene-wide colour override sits, in bytes: rgb and a weight, 0
13
+ * for the material's own edge colour (Engine.setOutlineColor). */
14
+ export declare const RZ_OUTLINE_COLOR_OFFSET = 32;
15
+ /** Where a style graph's outline (ShaderGraph.outline) sits, in bytes: the
16
+ * textured flag at 28, then its shadow colour at 48. Unset, a hull draws its
17
+ * PMX edge colour flat, as MMD does. */
18
+ export declare const RZ_OUTLINE_TEXTURED_OFFSET = 28;
19
+ export declare const RZ_OUTLINE_SHADOW_OFFSET = 48;
20
+ /** The hull's own block, in floats. */
21
+ export declare const RZ_OUTLINE_UNIFORM_FLOATS = 16;
9
22
  export declare function outlineShaderWgsl(): string;
10
23
  //# sourceMappingURL=outline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/outline.ts"],"names":[],"mappings":"AAqBA;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAE5C,wBAAgB,iBAAiB,IAAI,MAAM,CA6L1C"}
1
+ {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/outline.ts"],"names":[],"mappings":"AAqBA;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAC5C;yCACyC;AACzC,eAAO,MAAM,uBAAuB,KAAK,CAAA;AACzC;mEACmE;AACnE,eAAO,MAAM,uBAAuB,KAAK,CAAA;AACzC;;yCAEyC;AACzC,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAC5C,eAAO,MAAM,wBAAwB,KAAK,CAAA;AAC1C,uCAAuC;AACvC,eAAO,MAAM,yBAAyB,KAAK,CAAA;AAE3C,wBAAgB,iBAAiB,IAAI,MAAM,CAgP1C"}
@@ -24,6 +24,19 @@ import { DISSOLVE_WGSL } from "../materials/common";
24
24
  * edgeSize 16..20, so this is 20.
25
25
  */
26
26
  export const RZ_OUTLINE_DISSOLVE_OFFSET = 20;
27
+ /** Where the scene-wide width multiplier sits, in bytes — the float after
28
+ * dissolve (Engine.setOutlineWidth). */
29
+ export const RZ_OUTLINE_WIDTH_OFFSET = 24;
30
+ /** Where the scene-wide colour override sits, in bytes: rgb and a weight, 0
31
+ * for the material's own edge colour (Engine.setOutlineColor). */
32
+ export const RZ_OUTLINE_COLOR_OFFSET = 32;
33
+ /** Where a style graph's outline (ShaderGraph.outline) sits, in bytes: the
34
+ * textured flag at 28, then its shadow colour at 48. Unset, a hull draws its
35
+ * PMX edge colour flat, as MMD does. */
36
+ export const RZ_OUTLINE_TEXTURED_OFFSET = 28;
37
+ export const RZ_OUTLINE_SHADOW_OFFSET = 48;
38
+ /** The hull's own block, in floats. */
39
+ export const RZ_OUTLINE_UNIFORM_FLOATS = 16;
27
40
  export function outlineShaderWgsl() {
28
41
  return /* wgsl */ `
29
42
  ${DISSOLVE_WGSL}
@@ -36,7 +49,7 @@ struct CameraUniforms {
36
49
  viewportHeight: f32,
37
50
  };
38
51
 
39
- // THE HULL'S OWN BLOCK, not the material's. 32 bytes of edge data, which is all
52
+ // THE HULL'S OWN BLOCK, not the material's. 48 bytes of edge data, which is all
40
53
  // this pass shades with — and dissolve, which it has to obey.
41
54
  //
42
55
  // It reached for the material block's own layout first, declaring the skipped
@@ -49,15 +62,34 @@ struct MaterialUniforms {
49
62
  edgeColor: vec4f,
50
63
  edgeSize: f32,
51
64
  dissolve: f32,
52
- _padding2: f32,
53
- _padding3: f32,
65
+ /** The scene's width multiplier (setOutlineWidth), the same on every hull:
66
+ * scales the author's edgeSize rather than replacing it, so a model's thin
67
+ * and thick lines keep their proportion. */
68
+ widthScale: f32,
69
+ /** 1: a style graph's outline, the game's (Uber pass "Outline"): edgeSize is
70
+ * a world width, held between 1.2 and 3 px of a 1920-wide frame, and the
71
+ * colour is the picture × lerp(shadowColor, edgeColor, the key's
72
+ * half-Lambert). 0: the PMX edge, flat. */
73
+ textured: f32,
74
+ /** The scene's colour in place of edgeColor.rgb, by .a (0 or 1). The
75
+ * material's own alpha still holds — a line its author made sheer stays so. */
76
+ colorOverride: vec4f,
77
+ shadowColor: vec4f,
54
78
  };
55
79
 
80
+ struct OutlineLight { direction: vec4f, color: vec4f, };
81
+ /** The head of the scene's light block: the key is lights[0]. */
82
+ struct OutlineLights { ambientColor: vec4f, lights: array<OutlineLight, 4>, };
83
+
56
84
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
57
85
  @group(0) @binding(1) var edgeSampler: sampler;
86
+ @group(0) @binding(2) var<uniform> light: OutlineLights;
58
87
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
59
88
  @group(2) @binding(0) var<uniform> material: MaterialUniforms;
60
89
  @group(2) @binding(1) var diffuseTexture: texture_2d<f32>;
90
+ /** A look's outline tint (ShaderGraph.outline.tint): one of its images, white
91
+ * when it names none. */
92
+ @group(2) @binding(2) var tintTexture: texture_2d<f32>;
61
93
 
62
94
  struct VertexOutput {
63
95
  @builtin(position) position: vec4f,
@@ -72,6 +104,7 @@ struct VertexOutput {
72
104
  /** How much of a pixel-wide line this one really is (1 = all of it): a line
73
105
  * held at the minimum width fades by what it was short. */
74
106
  @location(3) coverage: f32,
107
+ @location(4) normal: vec3f,
75
108
  };
76
109
 
77
110
  /**
@@ -94,6 +127,11 @@ const RZ_OUTLINE_FULL_FIGURE = 12.5;
94
127
  * the shortfall, so far lines thin out without aliasing into dashes. */
95
128
  const RZ_OUTLINE_MIN_PX = 1.5;
96
129
 
130
+ fn safe_normal2(v: vec2f) -> vec2f {
131
+ let l = length(v);
132
+ return select(vec2f(0.0), v / l, l > 1e-6);
133
+ }
134
+
97
135
  @vertex fn vs(
98
136
  @location(0) position: vec3f,
99
137
  @location(1) normal: vec3f,
@@ -153,21 +191,40 @@ const RZ_OUTLINE_MIN_PX = 1.5;
153
191
  // once the frame is taller than a figure. The PMX per-vertex edge scale
154
192
  // multiplies it all, as vertex colour alpha does in the game.
155
193
  let w = max(clipPos.w, 1e-4);
156
- let breakDepth = RZ_OUTLINE_FULL_FIGURE * camera.projection[1][1];
157
- let nearNdc = viewNormal.xy * (material.edgeSize * outlineNormal.w * 4.0 / refViewport);
158
- var ndc = nearNdc * (min(w, breakDepth) / w);
159
-
160
- // Antialiased minimum (the game's _OutlineAntialias): held at the minimum
161
- // width and faded by the shortfall, never thinner. The game fades by
162
- // sqrt(coverage.x · coverage.y), per axis; for one width that is the ratio
163
- // itself.
164
- let px = length(ndc * deviceViewport * 0.5);
165
- let minPx = RZ_OUTLINE_MIN_PX * max(1.0, camera.viewportHeight / 1080.0);
194
+ var ndc: vec2f;
166
195
  var coverage = 1.0;
167
- if (px < minPx) {
168
- coverage = px / minPx;
169
- ndc = select(vec2f(0.0), ndc * (minPx / px), px > 1e-6);
196
+ if (material.textured > 0.5) {
197
+ // The game's: a view-space offset of edgeSize world units along the view
198
+ // normal, projected, then each axis held between 1.2 px and 1.2 px ×
199
+ // (1 + _OutlineMaxOffsetMultiplier, 1.5) of a 1920-wide frame; a line the
200
+ // minimum had to push fades by how far it was pushed.
201
+ let off = viewNormal.xy * material.edgeSize * material.widthScale * outlineNormal.w;
202
+ let d1 = vec2f(off.x * camera.projection[0][0], off.y * camera.projection[1][1]) / w;
203
+ // the minimum along the push direction (the game's dir · 2/screen · 1.2)
204
+ let pxNdc = 2.0 / refViewport * 1.2 * safe_normal2(viewNormal.xy);
205
+ let mx = abs(pxNdc) * 2.5;
206
+ let o = min(mx, max(abs(d1), abs(pxNdc)));
207
+ // fadeOut = min(length(saturate(off − |d1|))·75, 1), on clip offsets (× w)
208
+ // in game units: w here is MMD units, 8 to the game's metre
209
+ coverage = 1.0 - min(length(saturate((o - abs(d1)) * w / 8.0)) * 75.0, 1.0);
210
+ ndc = o * select(vec2f(1.0), sign(d1), abs(d1) > vec2f(0.0));
211
+ } else {
212
+ let breakDepth = RZ_OUTLINE_FULL_FIGURE * camera.projection[1][1];
213
+ let nearNdc = viewNormal.xy * (material.edgeSize * material.widthScale * outlineNormal.w * 4.0 / refViewport);
214
+ ndc = nearNdc * (min(w, breakDepth) / w);
215
+
216
+ // Antialiased minimum (the game's _OutlineAntialias): held at the minimum
217
+ // width and faded by the shortfall, never thinner. The game fades by
218
+ // sqrt(coverage.x · coverage.y), per axis; for one width that is the ratio
219
+ // itself.
220
+ let px = length(ndc * deviceViewport * 0.5);
221
+ let minPx = RZ_OUTLINE_MIN_PX * max(1.0, camera.viewportHeight / 1080.0);
222
+ if (px < minPx) {
223
+ coverage = px / minPx;
224
+ ndc = select(vec2f(0.0), ndc * (minPx / px), px > 1e-6);
225
+ }
170
226
  }
227
+ output.normal = worldNormal;
171
228
  output.coverage = coverage;
172
229
  output.position = vec4f(clipPos.xy + ndc * w, clipPos.z, clipPos.w);
173
230
  output.uv = uv;
@@ -199,7 +256,8 @@ ${sceneFsOutWgsl({ name: "FSOut", aux: "mask" })}
199
256
  // bind-pose position — three passes, one rule, or they disagree about which
200
257
  // pieces are still there.
201
258
  if (material.dissolve < 0.9995 && input.faceT > material.dissolve) { discard; }
202
- let texA = textureSample(diffuseTexture, edgeSampler, input.uv).a;
259
+ let tex = textureSample(diffuseTexture, edgeSampler, input.uv);
260
+ let texA = tex.a;
203
261
  if (texA < 0.05) {
204
262
  discard;
205
263
  }
@@ -208,7 +266,13 @@ ${sceneFsOutWgsl({ name: "FSOut", aux: "mask" })}
208
266
  // alpha above), it is drawn right after the surface it traces so what lies
209
267
  // under a faded line is already there, and 4× MSAA would quantize a fade to
210
268
  // four steps.
211
- out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA * input.coverage);
269
+ var rgb = mix(material.edgeColor.rgb, material.colorOverride.rgb, material.colorOverride.a);
270
+ if (material.textured > 0.5) {
271
+ let lit = dot(-light.lights[0].direction.xyz, normalize(input.normal)) * 0.5 + 0.5;
272
+ let tint = textureSample(tintTexture, edgeSampler, input.uv).rgb;
273
+ rgb = tex.rgb * tint * mix(material.shadowColor.rgb, rgb, lit);
274
+ }
275
+ out.color = vec4f(rgb, material.edgeColor.a * texA * input.coverage);
212
276
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
213
277
  ${sceneIdPadWgsl("out")} return out;
214
278
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.66.4",
3
+ "version": "0.67.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
@@ -79,7 +79,14 @@ import {
79
79
  } from "./shaders/lights"
80
80
  import { buildLightGrid } from "./light-grid"
81
81
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
82
- import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/outline"
82
+ import {
83
+ outlineShaderWgsl,
84
+ RZ_OUTLINE_COLOR_OFFSET,
85
+ RZ_OUTLINE_DISSOLVE_OFFSET,
86
+ RZ_OUTLINE_SHADOW_OFFSET,
87
+ RZ_OUTLINE_TEXTURED_OFFSET,
88
+ RZ_OUTLINE_WIDTH_OFFSET,
89
+ } from "./shaders/passes/outline"
83
90
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
84
91
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
85
92
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
@@ -238,7 +245,7 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
238
245
  "hair", "ahoge", "bang",
239
246
  ],
240
247
  ],
241
- ["body", ["肌", "皮肤", "skin"]],
248
+ ["body", ["肌", "皮肤", "skin", "指甲", "nail"]],
242
249
  ["metal", ["金属", "メタル", "metal", "earring", "耳环", "耳環"]],
243
250
  [
244
251
  "cloth_smooth",
@@ -284,6 +291,11 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
284
291
  ],
285
292
  ]
286
293
 
294
+ // Whole names that are body parts — skin by another name. Whole, never
295
+ // substrings: 手 alone is a hand, inside 手套 (gloves) or 手镯 (a bracelet) it is
296
+ // what the hand wears; 足 is a leg, 足袋 a sock.
297
+ const BODY_PART_NAMES = new Set(["手", "手部", "腕", "足", "脚", "腿", "爪", "hand", "hands", "arm", "arms", "leg", "legs", "body"])
298
+
287
299
  // Resolve a material name to a style category (override map first, then name hints), or
288
300
  // null if nothing matches — a null-resolving material stays ungrouped (neutral default).
289
301
  function resolvePreset(materialName: string, map: MaterialPresetMap | undefined): MaterialPreset | null {
@@ -293,6 +305,7 @@ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined)
293
305
  }
294
306
  }
295
307
  const lower = materialName.toLowerCase()
308
+ if (BODY_PART_NAMES.has(lower.replace(/[\s_.\-0-9]+$/, ""))) return "body"
296
309
  for (const [preset, hints] of PRESET_NAME_HINTS) {
297
310
  for (const hint of hints) {
298
311
  if (lower.includes(hint)) return preset
@@ -641,10 +654,16 @@ export type ViewTransformOptions = {
641
654
  exposure: number
642
655
  /** After the transform, display gamma (`pow(rgb, 1/gamma)`). */
643
656
  gamma: number
657
+ /**
658
+ * The "soft" curve's power, (1 - e^(-2.5x))^contrast. Aether Gazer sets it per
659
+ * scene (SceneSetting._contrast): 1.4 is its common value, the character page
660
+ * runs 1.7. A stage brings its own; the other transforms ignore it.
661
+ */
662
+ contrast: number
644
663
  /**
645
664
  * Which display transform the frame is formed with (composite.ts, viewTransform).
646
665
  *
647
- * "soft" — Aether Gazer's own final curve, (1 - e^(-2.5x))^1.4. The default.
666
+ * "soft" — Aether Gazer's own final curve, (1 - e^(-2.5x))^contrast. The default.
648
667
  * "neutral" — Unity URP's Neutral, the Khronos PBR Neutral operator: colour
649
668
  * passes through until the top fifth, which rolls off.
650
669
  * "aces" — Unity URP's ACES (RRT + ODT approximation from the Core RP).
@@ -660,6 +679,7 @@ export type ViewTransformName = "soft" | "neutral" | "aces" | "none"
660
679
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
661
680
  exposure: 0,
662
681
  gamma: 1.0,
682
+ contrast: 1.4,
663
683
  transform: "soft",
664
684
  }
665
685
 
@@ -823,7 +843,9 @@ interface DrawCall {
823
843
  /** Edge-flagged materials: interleaved inverted-hull outline drawn right after
824
844
  * this material with the outline pipeline. Shares this call's index range;
825
845
  * own bind group (edge uniforms + diffuse texture for the alpha test). */
826
- outline?: { bindGroup: GPUBindGroup }
846
+ /** The inverted hull: `pmx` when the PMX asked for an edge, `drawn` when it
847
+ * is drawn — the PMX's, or its style group's look's outline. */
848
+ outline?: { bindGroup: GPUBindGroup; pmx: boolean; drawn: boolean }
827
849
  /** MODEL-SPACE AABB over this material's index range, computed at load:
828
850
  * [minX, minY, minZ, maxX, maxY, maxZ]. Usable for culling only while the
829
851
  * owning model is rigid (see ModelInstance.rigid) — animation moves vertices
@@ -930,6 +952,9 @@ interface ModelInstance {
930
952
  * material buffers never reached it and a dissolved character kept her
931
953
  * outline. Kept here so that write has somewhere to go. */
932
954
  outlineUniformBuffers: GPUBuffer[]
955
+ /** Each outlined material's hull block and its PMX edge (colour, size), for
956
+ * a style graph's outline to take over and give back. */
957
+ outlineByMaterial: Map<string, { buffer: GPUBuffer; edge: Float32Array<ArrayBuffer>; picture: GPUTextureView }>
933
958
  model: Model
934
959
  /**
935
960
  * Keep simulating this model's cloth while it is HIDDEN.
@@ -2632,7 +2657,7 @@ export class Engine {
2632
2657
  // 15 × vec4f — see the viewU comment in composite.ts. The last one is the
2633
2658
  // camera's world position, which is what lets a foreground effect turn the
2634
2659
  // depth it is handed into a PLACE (bgWorldPos) rather than a distance.
2635
- private readonly compositeUniformData = new Float32Array(60)
2660
+ private readonly compositeUniformData = new Float32Array(64)
2636
2661
  /** Composite background (display-space sRGB 0–1) — null = transparent canvas. */
2637
2662
  private backgroundColor: Vec3 | null = null
2638
2663
  // 360 backdrop (equirectangular skybox, sampled by view ray in composite).
@@ -3054,6 +3079,7 @@ export class Engine {
3054
3079
  return {
3055
3080
  exposure: partial?.exposure ?? d.exposure,
3056
3081
  gamma: partial?.gamma ?? d.gamma,
3082
+ contrast: partial?.contrast ?? d.contrast,
3057
3083
  transform: partial?.transform ?? d.transform,
3058
3084
  }
3059
3085
  }
@@ -3072,7 +3098,7 @@ export class Engine {
3072
3098
 
3073
3099
  getViewTransformOptions(): ViewTransformOptions {
3074
3100
  const v = this.viewTransform
3075
- return { exposure: v.exposure, gamma: v.gamma, transform: v.transform }
3101
+ return { exposure: v.exposure, gamma: v.gamma, contrast: v.contrast, transform: v.transform }
3076
3102
  }
3077
3103
 
3078
3104
  private colorGrading: ColorGradingOptions = {
@@ -3188,6 +3214,7 @@ export class Engine {
3188
3214
  const v = this.viewTransform
3189
3215
  if (patch.exposure !== undefined) v.exposure = patch.exposure
3190
3216
  if (patch.gamma !== undefined) v.gamma = patch.gamma
3217
+ if (patch.contrast !== undefined) v.contrast = patch.contrast
3191
3218
  if (patch.transform !== undefined) v.transform = patch.transform
3192
3219
  if (this.device && this.compositeUniformBuffer) {
3193
3220
  this.writeCompositeViewUniforms()
@@ -3266,6 +3293,9 @@ export class Engine {
3266
3293
  u[25] = Engine.VIEW_TRANSFORM_ID[v.transform] ?? 0
3267
3294
  u[26] = this.canvas.width
3268
3295
  u[27] = this.canvas.height
3296
+ // The soft curve's power (viewU[15].x), the scene's own (SceneSetting._contrast).
3297
+ // Its own vec4: viewU[11..14] are the cast's positions whenever an effect runs.
3298
+ u[60] = v.contrast
3269
3299
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
3270
3300
  // on the CPU, so the shader only ever sees slope/offset/power. Mid-gray is
3271
3301
  // neutral in all three; the signed distance from it is the amount.
@@ -3324,6 +3354,51 @@ export class Engine {
3324
3354
  this.bundlesDirty = true
3325
3355
  }
3326
3356
 
3357
+ /**
3358
+ * Every outline's width, as a multiple of what each PMX material asked for
3359
+ * (its edgeSize): 1 is the author's own, 0 draws none. A multiplier rather
3360
+ * than a width because a model's lines are not one width — the hair's rim
3361
+ * and a sleeve's crease differ on purpose, and scaling keeps that.
3362
+ *
3363
+ * A write, not a re-record: one float in each hull's uniform, so a slider
3364
+ * can drive it. Hulls built later take the current value at creation.
3365
+ */
3366
+ private outlineWidth = 1
3367
+ setOutlineWidth(scale: number): void {
3368
+ const v = Number.isFinite(scale) ? Math.max(0, scale) : 1
3369
+ if (this.outlineWidth === v) return
3370
+ this.outlineWidth = v
3371
+ if (!this.device) return
3372
+ const one = new Float32Array([v])
3373
+ for (const inst of this.modelInstances.values()) {
3374
+ for (const buffer of inst.outlineUniformBuffers) {
3375
+ this.device.queue.writeBuffer(buffer, RZ_OUTLINE_WIDTH_OFFSET, one)
3376
+ }
3377
+ }
3378
+ }
3379
+
3380
+ /**
3381
+ * One colour for every outline, in place of each PMX material's edge colour;
3382
+ * null hands each material its own back. Written as the edge colour is —
3383
+ * the values a PMX would carry — so a picked black is MMD's black. The
3384
+ * material's edge ALPHA is kept: only the hue is the scene's.
3385
+ *
3386
+ * A write, like setOutlineWidth: hulls built later take it at creation.
3387
+ */
3388
+ private outlineColor: [number, number, number, number] = [0, 0, 0, 0]
3389
+ setOutlineColor(color: Vec3 | { x: number; y: number; z: number } | null): void {
3390
+ const next: [number, number, number, number] = color ? [color.x, color.y, color.z, 1] : [0, 0, 0, 0]
3391
+ if (next.every((v, i) => v === this.outlineColor[i])) return
3392
+ this.outlineColor = next
3393
+ if (!this.device) return
3394
+ const data = new Float32Array(next)
3395
+ for (const inst of this.modelInstances.values()) {
3396
+ for (const buffer of inst.outlineUniformBuffers) {
3397
+ this.device.queue.writeBuffer(buffer, RZ_OUTLINE_COLOR_OFFSET, data)
3398
+ }
3399
+ }
3400
+ }
3401
+
3327
3402
  /**
3328
3403
  * Draw the id attachment instead of the scene.
3329
3404
  *
@@ -8423,6 +8498,8 @@ export class Engine {
8423
8498
  entries: [
8424
8499
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8425
8500
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
8501
+ // the key light, for a style graph's outline colour (see outline.ts)
8502
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8426
8503
  ],
8427
8504
  })
8428
8505
  // Outline per-instance reuses mainPerInstanceBindGroupLayout (same skinMats binding)
@@ -8431,6 +8508,7 @@ export class Engine {
8431
8508
  entries: [
8432
8509
  { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8433
8510
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
8511
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
8434
8512
  ],
8435
8513
  })
8436
8514
 
@@ -8449,6 +8527,7 @@ export class Engine {
8449
8527
  entries: [
8450
8528
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
8451
8529
  { binding: 1, resource: this.materialSampler },
8530
+ { binding: 2, resource: { buffer: this.lightUniformBuffer } },
8452
8531
  ],
8453
8532
  })
8454
8533
 
@@ -8462,6 +8541,7 @@ export class Engine {
8462
8541
  entries: [
8463
8542
  { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
8464
8543
  { binding: 1, resource: this.materialSampler },
8544
+ { binding: 2, resource: { buffer: this.lightUniformBuffer } },
8465
8545
  ],
8466
8546
  })
8467
8547
 
@@ -8649,13 +8729,14 @@ export class Engine {
8649
8729
  // strength belong to the combine step.
8650
8730
  this.compositeUniformBuffer = this.device.createBuffer({
8651
8731
  label: "composite view uniforms",
8652
- // 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
8732
+ // 16 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
8653
8733
  // (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
8654
8734
  // (time, _, canvas width, canvas height) for user effects · three grade
8655
8735
  // vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
8656
8736
  // world position, for an effect placing itself in the scene · four
8657
- // character positions, for one that wants to respond to the cast.
8658
- size: 240,
8737
+ // character positions, for one that wants to respond to the cast · the
8738
+ // soft curve's contrast. Shaders that stop at the cast declare 15.
8739
+ size: 256,
8659
8740
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8660
8741
  })
8661
8742
  this.dofUniformBuffer = this.device.createBuffer({
@@ -9981,6 +10062,46 @@ export class Engine {
9981
10062
  if (this.cameraPoseOverride) this.camera.setVmdPose(this.cameraPoseOverride)
9982
10063
  }
9983
10064
 
10065
+ /**
10066
+ * The host's adjustment to a camera track, added to every pose it samples.
10067
+ *
10068
+ * A downloaded camera motion is framed for whoever it was made with: a
10069
+ * shorter model wants it lower, a wider stage wants it further back. This
10070
+ * moves the shot without touching its ANGLE — the motion's rotation and roll
10071
+ * are its choreography, and they stay the track's.
10072
+ *
10073
+ * - `target`: world units, added to the look-at point, so the whole shot
10074
+ * slides with the angle unchanged.
10075
+ * - `distance`: positive pulls the eye further back along its own line of
10076
+ * sight. VMD distances are signed (negative is behind the target, the usual
10077
+ * case), so "further" means away from zero on whichever side it is.
10078
+ * - `fov`: radians, added to the track's lens, never below a sliver.
10079
+ *
10080
+ * Only a loaded track reads it: the orbit has its own sliders, and a pose
10081
+ * forced through setCameraPose is a statement of where the camera IS.
10082
+ */
10083
+ private cameraTrackOffset = { target: new Vec3(0, 0, 0), distance: 0, fov: 0 }
10084
+ setCameraTrackOffset(offset: { target?: { x: number; y: number; z: number }; distance?: number; fov?: number } | null): void {
10085
+ const t = offset?.target
10086
+ this.cameraTrackOffset = {
10087
+ target: new Vec3(t?.x ?? 0, t?.y ?? 0, t?.z ?? 0),
10088
+ distance: offset?.distance ?? 0,
10089
+ fov: offset?.fov ?? 0,
10090
+ }
10091
+ }
10092
+
10093
+ /** A sampled track pose with the host's offset laid on. */
10094
+ private offsetTrackPose(pose: CameraPose): CameraPose {
10095
+ const o = this.cameraTrackOffset
10096
+ if (o.distance === 0 && o.fov === 0 && o.target.x === 0 && o.target.y === 0 && o.target.z === 0) return pose
10097
+ return {
10098
+ target: new Vec3(pose.target.x + o.target.x, pose.target.y + o.target.y, pose.target.z + o.target.z),
10099
+ rotation: pose.rotation,
10100
+ distance: pose.distance + Math.sign(pose.distance || -1) * o.distance,
10101
+ fov: Math.min(Math.PI * 0.95, Math.max(0.01, pose.fov + o.fov)),
10102
+ }
10103
+ }
10104
+
9984
10105
  /** The pose currently forced from outside, or null when nothing is. */
9985
10106
  getCameraPoseOverride(): CameraPose | null {
9986
10107
  return this.cameraPoseOverride
@@ -10974,6 +11095,12 @@ export class Engine {
10974
11095
  { binding: 12, resource: { buffer: this.probeUniformBuffer } },
10975
11096
  ],
10976
11097
  })
11098
+ // The recorded phases bind these groups by value (sceneView), so a rebuild
11099
+ // they never hear about leaves them drawing with the old one: the first
11100
+ // light cookie landed on the ground (re-bound each frame) and not on the
11101
+ // cast, and a swapped sky kept reflecting the one it replaced. Only init,
11102
+ // a world install and the first cookie get here, so re-recording is cheap.
11103
+ this.bundlesDirty = true
10977
11104
  return true
10978
11105
  }
10979
11106
 
@@ -14803,6 +14930,7 @@ export class Engine {
14803
14930
  dissolve: 1,
14804
14931
  materialUniformBuffers: [],
14805
14932
  outlineUniformBuffers: [],
14933
+ outlineByMaterial: new Map(),
14806
14934
  cullModelIndex: 0,
14807
14935
  // Seeded false: the first skin-matrix upload decides it, and until then the
14808
14936
  // sphere path is the safe answer (it never culls something it should not).
@@ -15444,8 +15572,12 @@ export class Engine {
15444
15572
  // every material — on the heaviest mesh in the scene that doubles the
15445
15573
  // geometry submitted per frame to draw cartoon outlines around
15446
15574
  // architecture, which is not the look anyone is after.
15575
+ // Every character material gets its hull: the PMX edge flag draws it as
15576
+ // MMD does, and a style group whose look carries an outline draws it on
15577
+ // any material (assignDrawCallGroups sets `drawn`).
15447
15578
  let outline: DrawCall["outline"]
15448
- if (!inst.isStage && (mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
15579
+ const pmxEdge = (mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0
15580
+ if (!inst.isStage) {
15449
15581
  const materialUniformData = new Float32Array([
15450
15582
  mat.edgeColor[0],
15451
15583
  mat.edgeColor[1],
@@ -15458,21 +15590,31 @@ export class Engine {
15458
15590
  // block's layout would be 32 bytes of padding per outlined material
15459
15591
  // to carry one float. See RZ_OUTLINE_DISSOLVE_OFFSET.
15460
15592
  1,
15593
+ // The scene's width multiplier, at RZ_OUTLINE_WIDTH_OFFSET.
15594
+ this.outlineWidth,
15595
+ // A style graph's outline, at RZ_OUTLINE_TEXTURED_OFFSET: none yet.
15596
+ 0,
15597
+ // The scene's colour override, at RZ_OUTLINE_COLOR_OFFSET.
15598
+ this.outlineColor[0],
15599
+ this.outlineColor[1],
15600
+ this.outlineColor[2],
15601
+ this.outlineColor[3],
15602
+ // The graph outline's shadow colour, at RZ_OUTLINE_SHADOW_OFFSET.
15603
+ 0,
15604
+ 0,
15461
15605
  0,
15462
15606
  0,
15463
15607
  ])
15464
15608
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
15465
15609
  inst.gpuBuffers.push(outlineUniformBuffer)
15466
15610
  inst.outlineUniformBuffers.push(outlineUniformBuffer)
15467
- const outlineBindGroup = this.device.createBindGroup({
15468
- label: `${prefix}outline: ${mat.name}`,
15469
- layout: this.outlinePerMaterialBindGroupLayout,
15470
- entries: [
15471
- { binding: 0, resource: { buffer: outlineUniformBuffer } },
15472
- { binding: 1, resource: textureView },
15473
- ],
15611
+ inst.outlineByMaterial.set(mat.name, {
15612
+ buffer: outlineUniformBuffer,
15613
+ edge: materialUniformData.slice(0, 5),
15614
+ picture: textureView,
15474
15615
  })
15475
- outline = { bindGroup: outlineBindGroup }
15616
+ const outlineBindGroup = this.outlineBindGroup(`${prefix}outline: ${mat.name}`, outlineUniformBuffer, textureView, null)
15617
+ outline = { bindGroup: outlineBindGroup, pmx: pmxEdge, drawn: pmxEdge }
15476
15618
  if (!inst.outlineVertexBuffer) {
15477
15619
  const data = inst.model.getOutlineVertices()
15478
15620
  const buf = this.device.createBuffer({
@@ -17035,7 +17177,7 @@ export class Engine {
17035
17177
  } else if (this.camera.vmdDriven && this.cameraAnimation) {
17036
17178
  // Drive the shot from the camera VMD (synced to the animated model's clock).
17037
17179
  const pose = this.cameraAnimation.sample(this.transportTime())
17038
- if (pose) this.camera.setVmdPose(pose)
17180
+ if (pose) this.camera.setVmdPose(this.offsetTrackPose(pose))
17039
17181
  }
17040
17182
 
17041
17183
  // Before the cast is written, which carries every subject's dissolve to the
@@ -18012,8 +18154,45 @@ export class Engine {
18012
18154
 
18013
18155
  // Rebind each material draw call to its (successfully-installed) group's uniform buffer,
18014
18156
  // or the zero buffer when ungrouped, then re-sort by render-class draw order.
18157
+ /** A hull's per-material group: its block, the picture, the look's tint. */
18158
+ private outlineBindGroup(label: string, buffer: GPUBuffer, picture: GPUTextureView, tint: GPUTexture | null): GPUBindGroup {
18159
+ return this.device.createBindGroup({
18160
+ label,
18161
+ layout: this.outlinePerMaterialBindGroupLayout,
18162
+ entries: [
18163
+ { binding: 0, resource: { buffer } },
18164
+ { binding: 1, resource: picture },
18165
+ { binding: 2, resource: (tint ?? this.fallbackMaterialTexture).createView() },
18166
+ ],
18167
+ })
18168
+ }
18169
+
18015
18170
  private assignDrawCallGroups(inst: ModelInstance, claimed: Map<string, string>): void {
18016
18171
  inst.materialToGroup.clear()
18172
+ // The hulls: a group whose look carries an outline (ShaderGraph.outline)
18173
+ // draws it in place of the PMX edge; the rest keep, or get back, their own.
18174
+ for (const [name, hull] of inst.outlineByMaterial) {
18175
+ const id = claimed.get(name)
18176
+ const install = id ? inst.styleGroups.get(id) : undefined
18177
+ const o = install?.group.graph.outline
18178
+ const block = o
18179
+ ? new Float32Array([o.color[0], o.color[1], o.color[2], 1, o.width])
18180
+ : hull.edge
18181
+ this.device.queue.writeBuffer(hull.buffer, 0, block)
18182
+ this.device.queue.writeBuffer(hull.buffer, RZ_OUTLINE_TEXTURED_OFFSET, new Float32Array([o ? 1 : 0]))
18183
+ if (o) this.device.queue.writeBuffer(hull.buffer, RZ_OUTLINE_SHADOW_OFFSET, new Float32Array([...o.shadowColor, 1]))
18184
+ const tint = o?.tint != null ? (install?.imagesByMaterial?.[name] ?? install?.images)?.[o.tint] ?? null : null
18185
+ for (const dc of inst.drawCalls) {
18186
+ if (dc.materialName !== name || !dc.outline) continue
18187
+ dc.outline.bindGroup = this.outlineBindGroup(`outline: ${name}`, hull.buffer, hull.picture, tint)
18188
+ this.bundlesDirty = true
18189
+ const drawn = o ? o.width > 0 : dc.outline.pmx
18190
+ if (dc.outline.drawn !== drawn) {
18191
+ dc.outline.drawn = drawn
18192
+ this.bundlesDirty = true
18193
+ }
18194
+ }
18195
+ }
18017
18196
  for (const dc of inst.drawCalls) {
18018
18197
  if (!dc.baseBindGroupEntries) continue // outlines/ground are never grouped
18019
18198
  const wantId = claimed.get(dc.materialName)
@@ -18512,7 +18691,7 @@ export class Engine {
18512
18691
  }
18513
18692
  pass.setBindGroup(2, draw.bindGroup)
18514
18693
  this.issueDraw(pass, draw, view.args)
18515
- if (draw.outline && this.outlineEnabled && view.outlines) outlined.push(draw)
18694
+ if (draw.outline?.drawn && this.outlineEnabled && view.outlines) outlined.push(draw)
18516
18695
  }
18517
18696
  // THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
18518
18697
  // at the minimum width is faded, and it still writes depth: drawn between
@@ -24,7 +24,7 @@ export const BODY_GRAPH: ShaderGraph = {
24
24
  { id: "lit_tint", type: "hue_sat", inputs: { hue: 0.5, saturation: 1.5, value: 1.0, fac: 1.0 } },
25
25
  { id: "toon_color", type: "mix/blend" },
26
26
  { id: "bc", type: "bright_contrast", inputs: { bright: 0.1, contrast: 0.2 } },
27
- { id: "emission3", type: "vector_math/scale", inputs: { scale: 4.0 } },
27
+ { id: "emission3", type: "vector_math/scale", inputs: { scale: 3.2 } },
28
28
  { id: "warm_add", type: "math/add", inputs: { b: 0.5 } },
29
29
  { id: "warm_clamp", type: "math/clamp01" },
30
30
  {
@@ -30,7 +30,7 @@ export const CLOTH_ROUGH_GRAPH: ShaderGraph = {
30
30
  { id: "mix_003", type: "mix/blend" },
31
31
  { id: "hue_004", type: "hue_sat", inputs: { hue: 0.5, saturation: 0.800000011920929, value: 2.0, fac: 1.0 } },
32
32
  { id: "npr_overlay", type: "mix/overlay", inputs: { fac: 1.0 } },
33
- { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 18.200000762939453 } },
33
+ { id: "npr_emit", type: "vector_math/scale", inputs: { scale: 14.56 } },
34
34
  { id: "noise", type: "tex_noise", inputs: { scale: 17.7 } },
35
35
  { id: "noise_ramp", type: "ramp_linear", inputs: { pos0: 0.0, pos1: 1.0 } },
36
36
  { id: "bump", type: "bump", inputs: { strength: 1.0 } },