reze-engine 0.55.0 → 0.55.1

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.js CHANGED
@@ -1053,6 +1053,8 @@ export class Engine {
1053
1053
  contrast: DEFAULT_COLOR_GRADING.contrast,
1054
1054
  saturation: DEFAULT_COLOR_GRADING.saturation,
1055
1055
  };
1056
+ /** Sensor grain: how much, and whether it moves. */
1057
+ this.grain = { amount: 0, animated: true };
1056
1058
  /** Debug/diagnostic: skip every inverted-hull outline draw. */
1057
1059
  // OFF by default — the product aesthetic. Modern high-detail models read
1058
1060
  // better without hulls (babylon-mmd's own demos disable its outline renderer
@@ -1065,6 +1067,17 @@ export class Engine {
1065
1067
  /** When set, render resolution is pinned to this size instead of tracking the
1066
1068
  * canvas's CSS size × devicePixelRatio (see setRenderSize). */
1067
1069
  this.fixedRenderSize = null;
1070
+ // ── VMD camera track ──
1071
+ // A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
1072
+ // via model.loadVmd never touch the camera — the camera shot is opt-in through here.
1073
+ /** Whether a loaded camera track is allowed to drive (setCameraVmdEnabled).
1074
+ * Held separately from `camera.vmdDriven` because that flag now answers to
1075
+ * two sources, and a track switched off must stay off when the other one
1076
+ * releases the camera. */
1077
+ this.cameraVmdEnabled = true;
1078
+ /** A pose pushed in from outside — see setCameraPose. Reapplied every frame,
1079
+ * so it outranks the orbit AND a loaded track for as long as it is set. */
1080
+ this.cameraPoseOverride = null;
1068
1081
  /** Per cascade: does its map currently hold nothing but the cleared far plane?
1069
1082
  * Set by the cascade loop, which skips a cascade that is unwanted and already
1070
1083
  * cleared rather than re-clearing it every frame. */
@@ -1400,6 +1413,30 @@ export class Engine {
1400
1413
  saturation: g.saturation,
1401
1414
  };
1402
1415
  }
1416
+ /**
1417
+ * Film grain over the rendered scene, 0–1.
1418
+ *
1419
+ * A property of a SENSOR, so it belongs to the camera rather than to any one
1420
+ * subject, and it lands on what the engine drew and on nothing else — never on
1421
+ * a background image or a backdrop video, which arrived with grain of their
1422
+ * own and would be graded rather than matched by a second helping.
1423
+ *
1424
+ * `animated` false freezes it. A still photograph's grain does not move, and
1425
+ * noise crawling over a frozen picture makes the rendering look more alive
1426
+ * than the thing it is standing in.
1427
+ *
1428
+ * Costs one hash per pixel in a pass that already runs, and nothing at all at
1429
+ * zero — the branch is on a uniform.
1430
+ */
1431
+ setFilmGrain(amount, animated = true) {
1432
+ this.grain.amount = Math.min(Math.max(amount, 0), 1);
1433
+ this.grain.animated = animated;
1434
+ if (this.device && this.compositeUniformBuffer)
1435
+ this.writeCompositeViewUniforms();
1436
+ }
1437
+ getFilmGrain() {
1438
+ return this.grain;
1439
+ }
1403
1440
  setViewTransformOptions(patch) {
1404
1441
  const v = this.viewTransform;
1405
1442
  if (patch.exposure !== undefined)
@@ -1442,8 +1479,11 @@ export class Engine {
1442
1479
  // compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
1443
1480
  // a uniform branch that skips the pow entirely in the common case.
1444
1481
  u[1] = 1.0 / Math.max(v.gamma, 1e-4);
1445
- u[2] = 0.0;
1446
- u[3] = 0.0;
1482
+ u[2] = this.grain.amount;
1483
+ // The seed. Zero means STILL: a plate that is one photograph has grain that
1484
+ // does not move, and CG noise crawling over a frozen picture makes the CG
1485
+ // look more alive than the footage — the opposite of the point.
1486
+ u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0;
1447
1487
  u[4] = b.color.x;
1448
1488
  u[5] = b.color.y;
1449
1489
  u[6] = b.color.z;
@@ -6323,21 +6363,60 @@ export class Engine {
6323
6363
  this.cameraFollowSmoothing = Math.max(0, smoothing ?? 0);
6324
6364
  this.cameraFollowSeeded = false;
6325
6365
  }
6326
- // ── VMD camera track ──
6327
- // A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
6328
- // via model.loadVmd never touch the camera — the camera shot is opt-in through here.
6366
+ /** The one place that decides who is holding the camera. An external pose
6367
+ * wins; a track drives when it is loaded and enabled; otherwise orbit. */
6368
+ refreshCameraDrive() {
6369
+ this.camera.setVmdDriven(this.cameraPoseOverride !== null || (this.cameraVmdEnabled && this.cameraAnimation !== null));
6370
+ }
6371
+ /**
6372
+ * Aim the camera from outside — a solved match-move, a saved shot, a rig
6373
+ * driving the view from the host's own clock.
6374
+ *
6375
+ * The exact partner of `getCameraPose`, and the same five channels: the shot
6376
+ * as MMD states it, roll included. Orbit cannot express roll, so this is the
6377
+ * only way a tilted camera reaches the engine.
6378
+ *
6379
+ * Reapplied every frame while set, which makes it authoritative rather than
6380
+ * advisory — nothing the transport or a loaded track does moves it. Pass null
6381
+ * to release, and whatever was driving before takes the camera back.
6382
+ */
6383
+ setCameraPose(pose) {
6384
+ if (pose) {
6385
+ // Copied, not held: a host reusing one object per frame is the normal
6386
+ // shape of a track, and storing the reference would make the value we
6387
+ // reapply depend on when the caller next touched theirs.
6388
+ this.cameraPoseOverride = {
6389
+ target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
6390
+ rotation: new Vec3(pose.rotation.x, pose.rotation.y, pose.rotation.z),
6391
+ distance: pose.distance,
6392
+ fov: pose.fov,
6393
+ };
6394
+ }
6395
+ else {
6396
+ this.cameraPoseOverride = null;
6397
+ }
6398
+ this.refreshCameraDrive();
6399
+ if (this.cameraPoseOverride)
6400
+ this.camera.setVmdPose(this.cameraPoseOverride);
6401
+ }
6402
+ /** The pose currently forced from outside, or null when nothing is. */
6403
+ getCameraPoseOverride() {
6404
+ return this.cameraPoseOverride;
6405
+ }
6329
6406
  /** Load a camera VMD (dedicated camera file, or any VMD's camera block) and drive the shot
6330
6407
  * from it. Default-on once a non-empty track loads; toggle with setCameraVmdEnabled. */
6331
6408
  async loadCameraVmd(url) {
6332
6409
  const frames = await VMDLoader.loadCamera(url);
6333
6410
  this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null;
6334
- this.camera.setVmdDriven(this.cameraAnimation !== null);
6411
+ this.cameraVmdEnabled = true;
6412
+ this.refreshCameraDrive();
6335
6413
  }
6336
6414
  /** Load a camera VMD from an already-fetched buffer (e.g. a File the user dropped). */
6337
6415
  loadCameraVmdFromBuffer(buffer) {
6338
6416
  const frames = VMDLoader.loadCameraFromBuffer(buffer);
6339
6417
  this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null;
6340
- this.camera.setVmdDriven(this.cameraAnimation !== null);
6418
+ this.cameraVmdEnabled = true;
6419
+ this.refreshCameraDrive();
6341
6420
  }
6342
6421
  /**
6343
6422
  * Drive the shot from camera keyframes built in JS — the camera's answer to
@@ -6354,7 +6433,8 @@ export class Engine {
6354
6433
  */
6355
6434
  loadCameraClip(frames) {
6356
6435
  this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null;
6357
- this.camera.setVmdDriven(this.cameraAnimation !== null);
6436
+ this.cameraVmdEnabled = true;
6437
+ this.refreshCameraDrive();
6358
6438
  }
6359
6439
  /** The loaded camera track as editable keyframes, or [] with none loaded.
6360
6440
  * Copies — mutating them does not reach the track being sampled. */
@@ -6372,7 +6452,8 @@ export class Engine {
6372
6452
  }
6373
6453
  /** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
6374
6454
  setCameraVmdEnabled(enabled) {
6375
- this.camera.setVmdDriven(enabled && this.cameraAnimation !== null);
6455
+ this.cameraVmdEnabled = enabled;
6456
+ this.refreshCameraDrive();
6376
6457
  if (!enabled && this.cameraTargetModel) {
6377
6458
  // Follow resumes with a clean snap to bone + configured offset — one
6378
6459
  // predictable cut to the scene's framing, no easing from the shot.
@@ -6630,7 +6711,7 @@ export class Engine {
6630
6711
  /** Drop the loaded camera VMD and return to orbit control. */
6631
6712
  clearCameraVmd() {
6632
6713
  this.cameraAnimation = null;
6633
- this.camera.setVmdDriven(false);
6714
+ this.refreshCameraDrive();
6634
6715
  }
6635
6716
  /**
6636
6717
  * THE TRANSPORT'S CLOCK — where the scene is in its own playback.
@@ -6673,6 +6754,28 @@ export class Engine {
6673
6754
  getCameraPosition() {
6674
6755
  return this.camera.getPosition();
6675
6756
  }
6757
+ /**
6758
+ * The live orbit, read in ONE call.
6759
+ *
6760
+ * A host that stores the shot has to be able to ask where the camera actually
6761
+ * IS, because a drag on the canvas moves this and nothing else — and a
6762
+ * document that never asks will happily write back the angle it last set,
6763
+ * discarding whatever the person just did with the mouse. Reading the four
6764
+ * separately invites a torn set across a frame boundary; this cannot tear.
6765
+ *
6766
+ * `target` is the orbit's own centre. While the engine is following a bone
6767
+ * that point rides the bone, so a caller storing a FOLLOW offset must keep its
6768
+ * own and take only the angles from here.
6769
+ */
6770
+ getCameraOrbit() {
6771
+ const c = this.camera;
6772
+ return {
6773
+ alpha: c.alpha,
6774
+ beta: c.beta,
6775
+ distance: c.radius,
6776
+ target: new Vec3(c.target.x, c.target.y, c.target.z),
6777
+ };
6778
+ }
6676
6779
  getCameraDistance() {
6677
6780
  return this.camera.radius;
6678
6781
  }
@@ -6691,6 +6794,21 @@ export class Engine {
6691
6794
  setCameraBeta(b) {
6692
6795
  this.camera.beta = b;
6693
6796
  }
6797
+ /**
6798
+ * Roll the orbiting shot, radians — the lean alpha and beta cannot state.
6799
+ *
6800
+ * Tips the up vector about the eye→target line, so the camera stays exactly
6801
+ * where it was and keeps looking at exactly what it looked at. Everything the
6802
+ * orbit does still works underneath it: following a bone, dragging, zooming.
6803
+ *
6804
+ * A camera VMD carries its own roll and ignores this while it drives.
6805
+ */
6806
+ setCameraRoll(r) {
6807
+ this.camera.roll = r;
6808
+ }
6809
+ getCameraRoll() {
6810
+ return this.camera.roll;
6811
+ }
6694
6812
  /** Vertical field of view in radians (default π/4). While a camera VMD
6695
6813
  * drives the view it animates fov itself; the orbit value set here is
6696
6814
  * restored when the VMD releases the camera. */
@@ -6822,6 +6940,7 @@ export class Engine {
6822
6940
  opacity: 1.0,
6823
6941
  mirror: false,
6824
6942
  mirrorBlur: 0,
6943
+ shadowSoftness: 0,
6825
6944
  ...options,
6826
6945
  };
6827
6946
  this.createGroundGeometry(opts.width, opts.height);
@@ -9015,7 +9134,7 @@ export class Engine {
9015
9134
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
9016
9135
  }
9017
9136
  createShadowGroundResources(opts) {
9018
- const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, } = opts;
9137
+ const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, shadowSoftness, } = opts;
9019
9138
  // Shadow map is already created in setupPipelines()
9020
9139
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
9021
9140
  // keeping the uniform vec4-aligned.
@@ -9040,6 +9159,9 @@ export class Engine {
9040
9159
  this.groundMirror = gb[15];
9041
9160
  gb[16] = Math.min(Math.max(mirrorBlur, 0), 1);
9042
9161
  this.groundMirrorBlur = gb[16];
9162
+ // gb[18] — shadow edge softness. Was padding; the shader reads it as the
9163
+ // Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
9164
+ gb[18] = Math.min(Math.max(shadowSoftness, 0), 1);
9043
9165
  // gb[17] — does the FAR cascade hold anything?
9044
9166
  //
9045
9167
  // It holds something only when a stage is loaded; that is what it exists for
@@ -10200,15 +10322,65 @@ export class Engine {
10200
10322
  return new Vec3(x / w, y / w, z / w);
10201
10323
  }
10202
10324
  // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
10325
+ /**
10326
+ * Where a point on the canvas lands on a horizontal plane.
10327
+ *
10328
+ * `px,py` are canvas-relative pixels, top-left origin — what a pointer event
10329
+ * gives you after subtracting the element's rect. Returns null when the ray
10330
+ * cannot reach the plane: parallel to it, or pointing the other way, which is
10331
+ * what a click on the sky above the horizon is.
10332
+ *
10333
+ * The one primitive a placement UI needs. Dragging a thing across the floor is
10334
+ * otherwise three sliders in world units, which asks someone to guess numbers
10335
+ * that have no visible relation to the picture they are looking at — and it
10336
+ * throws away the property that makes pointing work at all: under perspective,
10337
+ * moving something further away makes it smaller by exactly the right amount,
10338
+ * so position and size stop being two controls to tune against each other.
10339
+ */
10340
+ groundPointAt(px, py, planeY = 0) {
10341
+ const ray = this.buildMouseRay(px, py);
10342
+ if (!ray)
10343
+ return null;
10344
+ // Parallel to the plane: no intersection, and a huge one is not an answer.
10345
+ if (Math.abs(ray.dir.y) < 1e-6)
10346
+ return null;
10347
+ const t = (planeY - ray.origin.y) / ray.dir.y;
10348
+ // Behind the camera — the plane is there, but not in this shot.
10349
+ if (!(t > 0) || !isFinite(t))
10350
+ return null;
10351
+ return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t);
10352
+ }
10353
+ /** Hand the pointer to something else — a placement drag, a gizmo, a host's own
10354
+ * overlay — so the orbit does not also act on it. */
10355
+ setCameraInputLocked(locked) {
10356
+ this.camera?.setInputLocked(locked);
10357
+ }
10203
10358
  buildMouseRay(px, py) {
10204
10359
  if (!this.camera)
10205
10360
  return null;
10206
10361
  const width = this.canvas.clientWidth;
10207
10362
  const height = this.canvas.clientHeight;
10208
- if (width <= 0 || height <= 0)
10363
+ if (width <= 0 || height <= 0 || this.canvas.width <= 0 || this.canvas.height <= 0)
10209
10364
  return null;
10210
- const ndcX = (px / width) * 2 - 1;
10211
- const ndcY = -((py / height) * 2 - 1);
10365
+ // THE PICTURE, NOT THE ELEMENT.
10366
+ //
10367
+ // The projection's aspect comes from the DRAWING BUFFER, while a pointer
10368
+ // arrives in the CSS box — and the two do not have to agree. The canvas is
10369
+ // laid out `object-contain`, so whenever they differ the rendered image sits
10370
+ // letterboxed inside the element with bars either side of it, and dividing
10371
+ // by the element's own size lands the ray somewhere the picture is not.
10372
+ // They disagree on every resize until the observer catches up, and
10373
+ // permanently wherever a host frames the canvas to a shape of its own.
10374
+ //
10375
+ // So: work out where the image actually sits, and take the ray from that.
10376
+ const bufAspect = this.canvas.width / this.canvas.height;
10377
+ const boxAspect = width / height;
10378
+ const imgW = bufAspect > boxAspect ? width : height * bufAspect;
10379
+ const imgH = bufAspect > boxAspect ? width / bufAspect : height;
10380
+ const ox = (width - imgW) / 2;
10381
+ const oy = (height - imgH) / 2;
10382
+ const ndcX = ((px - ox) / imgW) * 2 - 1;
10383
+ const ndcY = -(((py - oy) / imgH) * 2 - 1);
10212
10384
  const view = this.camera.getViewMatrix();
10213
10385
  const proj = this.camera.getProjectionMatrix();
10214
10386
  const invVP = proj.multiply(view).inverse();
@@ -10491,8 +10663,14 @@ export class Engine {
10491
10663
  }
10492
10664
  }
10493
10665
  }
10494
- // Drive the shot from the camera VMD (synced to the animated model's clock).
10495
- if (this.camera.vmdDriven && this.cameraAnimation) {
10666
+ // Who holds the shot this frame. An external pose is a statement about
10667
+ // where the camera IS, so it is reapplied rather than sampled — and it
10668
+ // outranks a loaded track, which is scene data.
10669
+ if (this.cameraPoseOverride) {
10670
+ this.camera.setVmdPose(this.cameraPoseOverride);
10671
+ }
10672
+ else if (this.camera.vmdDriven && this.cameraAnimation) {
10673
+ // Drive the shot from the camera VMD (synced to the animated model's clock).
10496
10674
  const pose = this.cameraAnimation.sample(this.transportTime());
10497
10675
  if (pose)
10498
10676
  this.camera.setVmdPose(pose);
@@ -11724,6 +11902,12 @@ export class Engine {
11724
11902
  // clock is already per effect, which is the one that actually breaks
11725
11903
  // things (rzGridFrame()==0 is a grid's only chance to seed).
11726
11904
  u[24] = this.sceneClock - (this.effects[0]?.epochScene ?? 0);
11905
+ // The grain's seed rides the same per-frame refresh, because it is the
11906
+ // only thing that makes it move — a seed written once by its setter is a
11907
+ // still pattern welded to the picture. On the SCENE clock like everything
11908
+ // else here, so an export reproduces the editor exactly rather than
11909
+ // scattering differently at whatever rate the encoder ran.
11910
+ u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0;
11727
11911
  u[26] = this.canvas.width;
11728
11912
  u[27] = this.canvas.height;
11729
11913
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA6LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,yqaA+H5B,CAAA;AA4LD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAQlF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAiFtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA6LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,yqaA+H5B,CAAA;AA6MD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAQlF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAiFtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -109,7 +109,7 @@ override APPLY_GAMMA: bool = true;
109
109
  // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
110
110
  // continuity kills the banding — sampled with hardware linear filtering.
111
111
  @group(0) @binding(5) var filmicLut: texture_2d<f32>;
112
- // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
112
+ // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
113
113
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
114
114
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
115
115
  // 1 solid color, 2 = 360 equirect skybox sampled by view ray. A user
@@ -495,6 +495,23 @@ const COMPOSITE_BODY = /* wgsl */ `
495
495
  if (APPLY_GAMMA) {
496
496
  disp = pow(disp, vec3f(viewU[0].y));
497
497
  }
498
+ // ── Film grain, on the SCENE ONLY ─────────────────────────────────────────
499
+ //
500
+ // Applied here, before the background is composited under, so it rides on what
501
+ // the engine drew and nothing else. That placement is the whole point when the
502
+ // background is footage: the plate came off a real sensor and already carries
503
+ // its own grain, and a second helping over the top would grade the photograph
504
+ // rather than match it. A clean CG figure on a grainy plate is one of the
505
+ // loudest tells there is — the noise gives it away long before the geometry.
506
+ //
507
+ // Multiplicative and weighted toward the mid-tones, which is how film behaves:
508
+ // little grain in the blacks, and the highlights clip it off.
509
+ if (viewU[0].z > 0.0) {
510
+ let gp = fragCoord.xy + vec2f(viewU[0].w, viewU[0].w * 1.7);
511
+ let gn = fract(sin(dot(gp, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5;
512
+ let glum = dot(disp, vec3f(0.2126, 0.7152, 0.0722));
513
+ disp = max(disp * (1.0 + gn * viewU[0].z * 4.0 * glum * (1.0 - glum)), vec3f(0.0));
514
+ }
498
515
  // Composite over the background in display space (premultiplied out). The
499
516
  // background is TWO layers: a base (transparent / solid color / 360 equirect)
500
517
  // and an optional user WGSL effect over-composited onto it.
@@ -1 +1 @@
1
- {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAYA,wBAAgB,gBAAgB,IAAI,MAAM,CAkUzC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC;;;oEAGoE;AACpE,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,+oCAiClC,CAAA"}
1
+ {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAmEA,wBAAgB,gBAAgB,IAAI,MAAM,CA2TzC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC;;;oEAGoE;AACpE,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,+oCAiClC,CAAA"}
@@ -8,6 +8,59 @@ import { WORLD_AMBIENT_WGSL } from "../lights";
8
8
  // A FUNCTION, not a constant, since the id attachment is a device capability:
9
9
  // the struct this returns depends on what the probe at init found, and a string
10
10
  // baked at import time cannot know that. Called once, when the module is built.
11
+ // How far the softest setting spreads the taps, in units of the sharp kernel's
12
+ // own step, and how many taps carry that spread. Sixteen is what keeps a wide
13
+ // disk reading as a penumbra instead of sixteen shadows.
14
+ const SOFT_TAPS = 16;
15
+ const SOFT_MAX_SPREAD = 14;
16
+ /**
17
+ * PCF taps for one cascade, emitted per shadow map.
18
+ *
19
+ * At softness 0 this is the 3x3 box the ground has always used — same offsets,
20
+ * same weights, same result to the bit. Above it the taps spread over a Vogel
21
+ * disk whose radius IS the penumbra: an overcast sky throws no edge, and a
22
+ * razor-edged shadow under one is the loudest thing wrong in a composite.
23
+ *
24
+ * The branch is on a uniform, so a scene at softness 0 genuinely takes the
25
+ * nine-tap side rather than masking the wide one — the same reasoning the
26
+ * shadowStrength branch above it already documents.
27
+ *
28
+ * A string rather than a WGSL function because a texture is the one handle type
29
+ * this shader has never passed as a parameter, and the file is already a
30
+ * template that interpolates its constants.
31
+ *
32
+ * `acc` must already be declared and zeroed; the caller reads it back
33
+ * normalised, so there is no `/ 9.0` left at the call site.
34
+ */
35
+ function pcfWgsl(map, uv, texel, z, acc, pad) {
36
+ const golden = 2.39996323;
37
+ return [
38
+ `if (material.shadowSoftness <= 0.0) {`,
39
+ ` let st = ${texel} * 2.0;`,
40
+ ` for (var y = -1; y <= 1; y++) {`,
41
+ ` for (var x = -1; x <= 1; x++) {`,
42
+ // ...Level, not the implicit-derivative form: identical on a single-mip
43
+ // shadow map, and legal inside a branch.
44
+ ` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(f32(x), f32(y)) * st, ${z});`,
45
+ ` }`,
46
+ ` }`,
47
+ ` ${acc} *= ${1 / 9};`,
48
+ `} else {`,
49
+ ` let radius = ${texel} * 2.0 * (1.0 + material.shadowSoftness * ${SOFT_MAX_SPREAD}.0);`,
50
+ ` for (var s = 0; s < ${SOFT_TAPS}; s++) {`,
51
+ ` let fs = f32(s);`,
52
+ // sqrt of the index spaces the ring radii evenly by AREA; the golden angle
53
+ // keeps successive taps from lining up into spokes.
54
+ ` let r = sqrt((fs + 0.5) * ${1 / SOFT_TAPS});`,
55
+ ` let a = fs * ${golden} + rot;`,
56
+ ` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(cos(a), sin(a)) * (r * radius), ${z});`,
57
+ ` }`,
58
+ ` ${acc} *= ${1 / SOFT_TAPS};`,
59
+ `}`,
60
+ ]
61
+ .map((l) => pad + l)
62
+ .join("\n");
63
+ }
11
64
  export function groundShaderWgsl() {
12
65
  return /* wgsl */ `
13
66
  struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
@@ -20,7 +73,7 @@ struct GroundShadowMat {
20
73
  gridLineColor: vec3f, mirror: f32,
21
74
  // farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
22
75
  // with no stage the far map is never drawn into, so its taps are known.
23
- mirrorBlur: f32, farCascade: f32, _mb1: f32, _mb2: f32,
76
+ mirrorBlur: f32, farCascade: f32, shadowSoftness: f32, _mb2: f32,
24
77
  // Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
25
78
  // nothing casts, negative means "do not use this" (a rigid caster has no
26
79
  // sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
@@ -150,6 +203,11 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
150
203
  // The same reasoning the noise tint below already got, applied to the term
151
204
  // that costs a hundred times more.
152
205
  if (material.shadowStrength > 0.0 && shadowPossible) {
206
+ // Per-pixel rotation for the soft disk, so its rings break up into fine noise
207
+ // rather than banding. Interleaved gradient noise: a function of the pixel
208
+ // alone, so a still camera gives a still shadow — the sharp path ignores it.
209
+ let ign = fract(52.9829189 * fract(dot(i.position.xy, vec2f(0.06711056, 0.00583715))));
210
+ let rot = ign * 6.28318530718;
153
211
  // The far cascade's taps, skipped entirely when nothing ever drew into it.
154
212
  //
155
213
  // This branch is the expensive one on a wide floor: it runs wherever the NEAR
@@ -163,32 +221,20 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
163
221
  if (material.farCascade > 0.0 && frustum < 1.0 && frustum1 > 0.0) {
164
222
  let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
165
223
  let suv1_c = clamp(suv1, vec2f(0.02), vec2f(0.98));
166
- let st1 = ${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize} * 2.0;
167
224
  let compareZ1 = ndc1.z - 0.0035;
168
225
  var acc1 = 0.0;
169
- for (var y = -1; y <= 1; y++) {
170
- for (var x = -1; x <= 1; x++) {
171
- acc1 += textureSampleCompareLevel(shadowMapFar, shadowSampler, suv1_c + vec2f(f32(x), f32(y)) * st1, compareZ1);
172
- }
173
- }
174
- vis = mix(1.0, acc1 * (1.0 / 9.0), frustum1);
226
+ ${pcfWgsl("shadowMapFar", "suv1_c", `${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize}`, "compareZ1", "acc1", " ")}
227
+ vis = mix(1.0, acc1, frustum1);
175
228
  }
176
229
  if (frustum > 0.0) {
177
230
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
178
231
  let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
179
- let st = material.pcfTexel * 2.0;
180
232
  let compareZ = ndc.z - 0.0035;
181
233
  var acc = 0.0;
182
- for (var y = -1; y <= 1; y++) {
183
- for (var x = -1; x <= 1; x++) {
184
- // ...Level, not the implicit-derivative form: identical on a single-mip
185
- // shadow map, and legal inside this branch.
186
- acc += textureSampleCompareLevel(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);
187
- }
188
- }
234
+ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ")}
189
235
  // The base is whatever the far cascade decided, so the near border blends
190
236
  // cascade to cascade rather than snapping to lit mid-floor.
191
- vis = mix(vis, acc * (1.0 / 9.0), frustum);
237
+ vis = mix(vis, acc, frustum);
192
238
  }
193
239
  }
194
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.55.0",
3
+ "version": "0.55.1",
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/camera.ts CHANGED
@@ -63,6 +63,19 @@ export class Camera {
63
63
  maxZ: number = FAR_CAP
64
64
  lowerBetaLimit: number = 0.001
65
65
  upperBetaLimit: number = Math.PI - 0.001
66
+ /**
67
+ * Orbit roll, radians — the lean the orbit itself cannot state.
68
+ *
69
+ * alpha and beta are a yaw and a pitch about an upright axis, and lookAt is
70
+ * handed world up, so an orbiting shot is level by construction. This tips
71
+ * that up vector about the eye→target line, which leaves WHERE the camera is
72
+ * and WHAT it looks at exactly as they were.
73
+ *
74
+ * That is the whole reason it lives here rather than in a pose pushed from
75
+ * outside: a rolled shot still follows a bone, still orbits, still zooms. A
76
+ * pose replaces all of that with one frozen answer.
77
+ */
78
+ roll: number = 0
66
79
 
67
80
  // Reused each frame so getViewMatrix/getProjectionMatrix don't allocate a Mat4 per call.
68
81
  private _viewMat = new Mat4(new Float32Array(16))
@@ -140,7 +153,10 @@ export class Camera {
140
153
  // NEGATIVE to match: in a VMD the camera sits behind its target.
141
154
  return {
142
155
  target: new Vec3(this.target.x, this.target.y, this.target.z),
143
- rotation: new Vec3(this.beta - Math.PI / 2, -this.alpha, 0),
156
+ // z carries the roll, so a rolled orbit exports and reads back as the
157
+ // same shot rather than a level one — the AE rig and the VMD writer both
158
+ // take this channel.
159
+ rotation: new Vec3(this.beta - Math.PI / 2, -this.alpha, this.roll),
144
160
  distance: -this.radius,
145
161
  fov: this.fov,
146
162
  }
@@ -208,7 +224,49 @@ export class Camera {
208
224
  }
209
225
  const eye = this.getPosition()
210
226
  const t = this.target
211
- Mat4.lookAtInto(this._viewMat.values, eye.x, eye.y, eye.z, t.x, t.y, t.z, 0, 1, 0)
227
+ if (this.roll === 0) {
228
+ Mat4.lookAtInto(this._viewMat.values, eye.x, eye.y, eye.z, t.x, t.y, t.z, 0, 1, 0)
229
+ return this._viewMat
230
+ }
231
+ // Roll = the up vector, turned about the view axis. Build the shot's own
232
+ // basis first (forward, then right, then a true up), because world up is
233
+ // only the camera's up while the shot is level — which is the thing this is
234
+ // about to stop being.
235
+ let fx = t.x - eye.x
236
+ let fy = t.y - eye.y
237
+ let fz = t.z - eye.z
238
+ const fl = Math.hypot(fx, fy, fz) || 1
239
+ fx /= fl
240
+ fy /= fl
241
+ fz /= fl
242
+ // right = forward × worldUp, with worldUp = (0,1,0), which is (−fz, 0, fx).
243
+ //
244
+ // WRITTEN THE OTHER WAY ROUND ONCE, and it did not fail quietly: that is
245
+ // worldUp × forward, so `right` pointed left, `up` below came out as right ×
246
+ // forward = DOWN, and the camera turned upside down the moment roll left
247
+ // zero. Degenerate only when the shot looks straight up or down, where
248
+ // beta's own limits already keep it from arriving.
249
+ let rx = -fz
250
+ let ry = 0
251
+ let rz = fx
252
+ const rl = Math.hypot(rx, ry, rz) || 1
253
+ rx /= rl
254
+ ry /= rl
255
+ rz /= rl
256
+ // up = right × forward
257
+ const ux = ry * fz - rz * fy
258
+ const uy = rz * fx - rx * fz
259
+ const uz = rx * fy - ry * fx
260
+ const c = Math.cos(this.roll)
261
+ const sn = Math.sin(this.roll)
262
+ Mat4.lookAtInto(
263
+ this._viewMat.values,
264
+ eye.x, eye.y, eye.z,
265
+ t.x, t.y, t.z,
266
+ ux * c + rx * sn,
267
+ uy * c + ry * sn,
268
+ uz * c + rz * sn,
269
+ )
212
270
  return this._viewMat
213
271
  }
214
272