reze-engine 0.55.0 → 0.55.2

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/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
 
package/src/engine.ts CHANGED
@@ -1510,6 +1510,13 @@ export class Engine {
1510
1510
  // tone (bottom). Stand-in for MMD's toon01–10.bmp, which we can't ship.
1511
1511
  private defaultToonRampTexture!: GPUTexture
1512
1512
  private groundShadowPipeline!: GPURenderPipeline
1513
+ /** The soft-edge variant, built the first time a scene asks for one. Null while
1514
+ * no scene has, which is most of them — a pipeline nobody draws with is still
1515
+ * a shader compile at load. */
1516
+ private groundShadowSoftPipeline: GPURenderPipeline | null = null
1517
+ /** How the ground's own pipeline is chosen, kept beside the uniform that sets
1518
+ * it so the draw does not have to read the buffer back. */
1519
+ private groundSoft = false
1513
1520
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
1514
1521
  private outlinePipeline!: GPURenderPipeline
1515
1522
  private selectedMaterial: { modelName: string; materialName: string } | null = null
@@ -2395,6 +2402,33 @@ export class Engine {
2395
2402
  }
2396
2403
  }
2397
2404
 
2405
+ /** Sensor grain: how much, and whether it moves. */
2406
+ private grain = { amount: 0, animated: true }
2407
+
2408
+ /**
2409
+ * Film grain over the rendered scene, 0–1.
2410
+ *
2411
+ * A property of a SENSOR, so it belongs to the camera rather than to any one
2412
+ * subject, and it lands on what the engine drew and on nothing else — never on
2413
+ * a background image or a backdrop video, which arrived with grain of their
2414
+ * own and would be graded rather than matched by a second helping.
2415
+ *
2416
+ * `animated` false freezes it. A still photograph's grain does not move, and
2417
+ * noise crawling over a frozen picture makes the rendering look more alive
2418
+ * than the thing it is standing in.
2419
+ *
2420
+ * Costs one hash per pixel in a pass that already runs, and nothing at all at
2421
+ * zero — the branch is on a uniform.
2422
+ */
2423
+ setFilmGrain(amount: number, animated = true): void {
2424
+ this.grain.amount = Math.min(Math.max(amount, 0), 1)
2425
+ this.grain.animated = animated
2426
+ if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
2427
+ }
2428
+ getFilmGrain(): Readonly<{ amount: number; animated: boolean }> {
2429
+ return this.grain
2430
+ }
2431
+
2398
2432
  setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
2399
2433
  const v = this.viewTransform
2400
2434
  if (patch.exposure !== undefined) v.exposure = patch.exposure
@@ -2436,8 +2470,11 @@ export class Engine {
2436
2470
  // compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
2437
2471
  // a uniform branch that skips the pow entirely in the common case.
2438
2472
  u[1] = 1.0 / Math.max(v.gamma, 1e-4)
2439
- u[2] = 0.0
2440
- u[3] = 0.0
2473
+ u[2] = this.grain.amount
2474
+ // The seed. Zero means STILL: a plate that is one photograph has grain that
2475
+ // does not move, and CG noise crawling over a frozen picture makes the CG
2476
+ // look more alive than the footage — the opposite of the point.
2477
+ u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0
2441
2478
  u[4] = b.color.x
2442
2479
  u[5] = b.color.y
2443
2480
  u[6] = b.color.z
@@ -6038,14 +6075,9 @@ export class Engine {
6038
6075
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6039
6076
  ],
6040
6077
  })
6041
- const groundShadowShader = this.device.createShaderModule({
6042
- label: "ground shadow",
6043
- code: groundShaderWgsl(),
6044
- })
6045
- this.groundShadowPipeline = this.createRenderPipeline({
6078
+ this.groundShadowPipelineDesc = {
6046
6079
  label: "ground shadow pipeline",
6047
6080
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
6048
- shaderModule: groundShadowShader,
6049
6081
  // Slot 0 only — the ground has no skinning, and declaring the full
6050
6082
  // 3-slot layout while renderGround binds one buffer is a WebGPU
6051
6083
  // validation error that invalidates the whole command buffer.
@@ -6053,7 +6085,8 @@ export class Engine {
6053
6085
  fragmentTargets: sceneTargetsFor("ground", this.sceneFormats),
6054
6086
  cullMode: "back",
6055
6087
  depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
6056
- })
6088
+ }
6089
+ this.groundShadowPipeline = this.buildGroundPipeline(false)
6057
6090
 
6058
6091
  // Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
6059
6092
  this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
@@ -7580,19 +7613,73 @@ export class Engine {
7580
7613
  // A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
7581
7614
  // via model.loadVmd never touch the camera — the camera shot is opt-in through here.
7582
7615
 
7616
+ /** Whether a loaded camera track is allowed to drive (setCameraVmdEnabled).
7617
+ * Held separately from `camera.vmdDriven` because that flag now answers to
7618
+ * two sources, and a track switched off must stay off when the other one
7619
+ * releases the camera. */
7620
+ private cameraVmdEnabled = true
7621
+ /** A pose pushed in from outside — see setCameraPose. Reapplied every frame,
7622
+ * so it outranks the orbit AND a loaded track for as long as it is set. */
7623
+ private cameraPoseOverride: CameraPose | null = null
7624
+
7625
+ /** The one place that decides who is holding the camera. An external pose
7626
+ * wins; a track drives when it is loaded and enabled; otherwise orbit. */
7627
+ private refreshCameraDrive(): void {
7628
+ this.camera.setVmdDriven(
7629
+ this.cameraPoseOverride !== null || (this.cameraVmdEnabled && this.cameraAnimation !== null),
7630
+ )
7631
+ }
7632
+
7633
+ /**
7634
+ * Aim the camera from outside — a solved match-move, a saved shot, a rig
7635
+ * driving the view from the host's own clock.
7636
+ *
7637
+ * The exact partner of `getCameraPose`, and the same five channels: the shot
7638
+ * as MMD states it, roll included. Orbit cannot express roll, so this is the
7639
+ * only way a tilted camera reaches the engine.
7640
+ *
7641
+ * Reapplied every frame while set, which makes it authoritative rather than
7642
+ * advisory — nothing the transport or a loaded track does moves it. Pass null
7643
+ * to release, and whatever was driving before takes the camera back.
7644
+ */
7645
+ setCameraPose(pose: CameraPose | null): void {
7646
+ if (pose) {
7647
+ // Copied, not held: a host reusing one object per frame is the normal
7648
+ // shape of a track, and storing the reference would make the value we
7649
+ // reapply depend on when the caller next touched theirs.
7650
+ this.cameraPoseOverride = {
7651
+ target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
7652
+ rotation: new Vec3(pose.rotation.x, pose.rotation.y, pose.rotation.z),
7653
+ distance: pose.distance,
7654
+ fov: pose.fov,
7655
+ }
7656
+ } else {
7657
+ this.cameraPoseOverride = null
7658
+ }
7659
+ this.refreshCameraDrive()
7660
+ if (this.cameraPoseOverride) this.camera.setVmdPose(this.cameraPoseOverride)
7661
+ }
7662
+
7663
+ /** The pose currently forced from outside, or null when nothing is. */
7664
+ getCameraPoseOverride(): CameraPose | null {
7665
+ return this.cameraPoseOverride
7666
+ }
7667
+
7583
7668
  /** Load a camera VMD (dedicated camera file, or any VMD's camera block) and drive the shot
7584
7669
  * from it. Default-on once a non-empty track loads; toggle with setCameraVmdEnabled. */
7585
7670
  async loadCameraVmd(url: string): Promise<void> {
7586
7671
  const frames = await VMDLoader.loadCamera(url)
7587
7672
  this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null
7588
- this.camera.setVmdDriven(this.cameraAnimation !== null)
7673
+ this.cameraVmdEnabled = true
7674
+ this.refreshCameraDrive()
7589
7675
  }
7590
7676
 
7591
7677
  /** Load a camera VMD from an already-fetched buffer (e.g. a File the user dropped). */
7592
7678
  loadCameraVmdFromBuffer(buffer: ArrayBuffer): void {
7593
7679
  const frames = VMDLoader.loadCameraFromBuffer(buffer)
7594
7680
  this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null
7595
- this.camera.setVmdDriven(this.cameraAnimation !== null)
7681
+ this.cameraVmdEnabled = true
7682
+ this.refreshCameraDrive()
7596
7683
  }
7597
7684
 
7598
7685
  /**
@@ -7610,7 +7697,8 @@ export class Engine {
7610
7697
  */
7611
7698
  loadCameraClip(frames: CameraKeyframe[]): void {
7612
7699
  this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null
7613
- this.camera.setVmdDriven(this.cameraAnimation !== null)
7700
+ this.cameraVmdEnabled = true
7701
+ this.refreshCameraDrive()
7614
7702
  }
7615
7703
 
7616
7704
  /** The loaded camera track as editable keyframes, or [] with none loaded.
@@ -7630,7 +7718,8 @@ export class Engine {
7630
7718
 
7631
7719
  /** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
7632
7720
  setCameraVmdEnabled(enabled: boolean): void {
7633
- this.camera.setVmdDriven(enabled && this.cameraAnimation !== null)
7721
+ this.cameraVmdEnabled = enabled
7722
+ this.refreshCameraDrive()
7634
7723
  if (!enabled && this.cameraTargetModel) {
7635
7724
  // Follow resumes with a clean snap to bone + configured offset — one
7636
7725
  // predictable cut to the scene's framing, no easing from the shot.
@@ -7891,7 +7980,7 @@ export class Engine {
7891
7980
  /** Drop the loaded camera VMD and return to orbit control. */
7892
7981
  clearCameraVmd(): void {
7893
7982
  this.cameraAnimation = null
7894
- this.camera.setVmdDriven(false)
7983
+ this.refreshCameraDrive()
7895
7984
  }
7896
7985
 
7897
7986
  /**
@@ -7934,6 +8023,29 @@ export class Engine {
7934
8023
  return this.camera.getPosition()
7935
8024
  }
7936
8025
 
8026
+ /**
8027
+ * The live orbit, read in ONE call.
8028
+ *
8029
+ * A host that stores the shot has to be able to ask where the camera actually
8030
+ * IS, because a drag on the canvas moves this and nothing else — and a
8031
+ * document that never asks will happily write back the angle it last set,
8032
+ * discarding whatever the person just did with the mouse. Reading the four
8033
+ * separately invites a torn set across a frame boundary; this cannot tear.
8034
+ *
8035
+ * `target` is the orbit's own centre. While the engine is following a bone
8036
+ * that point rides the bone, so a caller storing a FOLLOW offset must keep its
8037
+ * own and take only the angles from here.
8038
+ */
8039
+ getCameraOrbit(): { alpha: number; beta: number; distance: number; target: Vec3 } {
8040
+ const c = this.camera
8041
+ return {
8042
+ alpha: c.alpha,
8043
+ beta: c.beta,
8044
+ distance: c.radius,
8045
+ target: new Vec3(c.target.x, c.target.y, c.target.z),
8046
+ }
8047
+ }
8048
+
7937
8049
  getCameraDistance(): number {
7938
8050
  return this.camera.radius
7939
8051
  }
@@ -7952,6 +8064,21 @@ export class Engine {
7952
8064
  setCameraBeta(b: number): void {
7953
8065
  this.camera.beta = b
7954
8066
  }
8067
+ /**
8068
+ * Roll the orbiting shot, radians — the lean alpha and beta cannot state.
8069
+ *
8070
+ * Tips the up vector about the eye→target line, so the camera stays exactly
8071
+ * where it was and keeps looking at exactly what it looked at. Everything the
8072
+ * orbit does still works underneath it: following a bone, dragging, zooming.
8073
+ *
8074
+ * A camera VMD carries its own roll and ignores this while it drives.
8075
+ */
8076
+ setCameraRoll(r: number): void {
8077
+ this.camera.roll = r
8078
+ }
8079
+ getCameraRoll(): number {
8080
+ return this.camera.roll
8081
+ }
7955
8082
  /** Vertical field of view in radians (default π/4). While a camera VMD
7956
8083
  * drives the view it animates fov itself; the orbit value set here is
7957
8084
  * restored when the VMD releases the camera. */
@@ -8081,6 +8208,16 @@ export class Engine {
8081
8208
  /** Mirror softness, 0–1: 0 a polished mirror, 1 the softest blur level,
8082
8209
  * scaled by how far the reflected geometry sits behind the surface. */
8083
8210
  mirrorBlur?: number
8211
+ /** How soft the received shadow's edge is, 0–1. 0 (default) is the sharp
8212
+ * kernel this has always used, to the bit; 1 spreads the taps fourteen
8213
+ * times as wide, which is the edge an overcast sky throws.
8214
+ *
8215
+ * A property of the LIGHT, applied where the light is received: the sun
8216
+ * in a scene is either a point source with a hard edge or a sky with
8217
+ * none, and a floor that always answers "hard" can only match one of
8218
+ * them. Above 0 the taps go from nine to sixteen, so leave it at 0 for
8219
+ * scenes that want the sharp edge and pay nothing. */
8220
+ shadowSoftness?: number
8084
8221
  }): void {
8085
8222
  // NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
8086
8223
  // deferred a frame by useSceneSync's own rAF batching, and a hot reload
@@ -8104,6 +8241,7 @@ export class Engine {
8104
8241
  opacity: 1.0,
8105
8242
  mirror: false,
8106
8243
  mirrorBlur: 0,
8244
+ shadowSoftness: 0,
8107
8245
  ...options,
8108
8246
  }
8109
8247
  this.createGroundGeometry(opts.width, opts.height)
@@ -10507,6 +10645,28 @@ export class Engine {
10507
10645
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices)
10508
10646
  }
10509
10647
 
10648
+ /** Everything about the ground's pipeline except which shadow variant it
10649
+ * compiles, so the two are built from one description and cannot drift. */
10650
+ private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
10651
+
10652
+ private buildGroundPipeline(soft: boolean): GPURenderPipeline {
10653
+ return this.createRenderPipeline({
10654
+ ...this.groundShadowPipelineDesc,
10655
+ label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
10656
+ shaderModule: this.device.createShaderModule({
10657
+ label: soft ? "ground shadow (soft)" : "ground shadow",
10658
+ code: groundShaderWgsl(soft),
10659
+ }),
10660
+ })
10661
+ }
10662
+
10663
+ /** Built on the first frame that actually needs it. A shader compile costs
10664
+ * load time, and the overwhelming majority of scenes never soften a shadow. */
10665
+ private ensureGroundSoftPipeline(): GPURenderPipeline {
10666
+ if (!this.groundShadowSoftPipeline) this.groundShadowSoftPipeline = this.buildGroundPipeline(true)
10667
+ return this.groundShadowSoftPipeline
10668
+ }
10669
+
10510
10670
  private createShadowGroundResources(opts: {
10511
10671
  diffuseColor: Vec3
10512
10672
  fadeStart: number
@@ -10520,6 +10680,7 @@ export class Engine {
10520
10680
  opacity: number
10521
10681
  mirror: boolean
10522
10682
  mirrorBlur: number
10683
+ shadowSoftness: number
10523
10684
  }) {
10524
10685
  const {
10525
10686
  diffuseColor,
@@ -10534,6 +10695,7 @@ export class Engine {
10534
10695
  opacity,
10535
10696
  mirror,
10536
10697
  mirrorBlur,
10698
+ shadowSoftness,
10537
10699
  } = opts
10538
10700
  // Shadow map is already created in setupPipelines()
10539
10701
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
@@ -10559,6 +10721,12 @@ export class Engine {
10559
10721
  this.groundMirror = gb[15]
10560
10722
  gb[16] = Math.min(Math.max(mirrorBlur, 0), 1)
10561
10723
  this.groundMirrorBlur = gb[16]
10724
+ // gb[18] — shadow edge softness. Was padding; the shader reads it as the
10725
+ // Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
10726
+ gb[18] = Math.min(Math.max(shadowSoftness, 0), 1)
10727
+ // Which variant the draw picks. Zero is the sharp shader, which is the one
10728
+ // that existed before softness did.
10729
+ this.groundSoft = gb[18] > 0
10562
10730
  // gb[17] — does the FAR cascade hold anything?
10563
10731
  //
10564
10732
  // It holds something only when a stage is loaded; that is what it exists for
@@ -11258,7 +11426,7 @@ export class Engine {
11258
11426
  // hasGround is left alone: remove the stage and the ground comes back.
11259
11427
  if (this.groundIsSuppressed()) return
11260
11428
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
11261
- pass.setPipeline(this.groundShadowPipeline)
11429
+ pass.setPipeline(this.groundSoft ? this.ensureGroundSoftPipeline() : this.groundShadowPipeline)
11262
11430
  pass.setVertexBuffer(0, this.groundVertexBuffer)
11263
11431
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
11264
11432
  pass.setBindGroup(0, this.groundDrawCall.bindGroup)
@@ -11850,13 +12018,62 @@ export class Engine {
11850
12018
  }
11851
12019
 
11852
12020
  // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
12021
+ /**
12022
+ * Where a point on the canvas lands on a horizontal plane.
12023
+ *
12024
+ * `px,py` are canvas-relative pixels, top-left origin — what a pointer event
12025
+ * gives you after subtracting the element's rect. Returns null when the ray
12026
+ * cannot reach the plane: parallel to it, or pointing the other way, which is
12027
+ * what a click on the sky above the horizon is.
12028
+ *
12029
+ * The one primitive a placement UI needs. Dragging a thing across the floor is
12030
+ * otherwise three sliders in world units, which asks someone to guess numbers
12031
+ * that have no visible relation to the picture they are looking at — and it
12032
+ * throws away the property that makes pointing work at all: under perspective,
12033
+ * moving something further away makes it smaller by exactly the right amount,
12034
+ * so position and size stop being two controls to tune against each other.
12035
+ */
12036
+ groundPointAt(px: number, py: number, planeY = 0): Vec3 | null {
12037
+ const ray = this.buildMouseRay(px, py)
12038
+ if (!ray) return null
12039
+ // Parallel to the plane: no intersection, and a huge one is not an answer.
12040
+ if (Math.abs(ray.dir.y) < 1e-6) return null
12041
+ const t = (planeY - ray.origin.y) / ray.dir.y
12042
+ // Behind the camera — the plane is there, but not in this shot.
12043
+ if (!(t > 0) || !isFinite(t)) return null
12044
+ return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t)
12045
+ }
12046
+
12047
+ /** Hand the pointer to something else — a placement drag, a gizmo, a host's own
12048
+ * overlay — so the orbit does not also act on it. */
12049
+ setCameraInputLocked(locked: boolean): void {
12050
+ this.camera?.setInputLocked(locked)
12051
+ }
12052
+
11853
12053
  private buildMouseRay(px: number, py: number): { origin: Vec3; dir: Vec3 } | null {
11854
12054
  if (!this.camera) return null
11855
12055
  const width = this.canvas.clientWidth
11856
12056
  const height = this.canvas.clientHeight
11857
- if (width <= 0 || height <= 0) return null
11858
- const ndcX = (px / width) * 2 - 1
11859
- const ndcY = -((py / height) * 2 - 1)
12057
+ if (width <= 0 || height <= 0 || this.canvas.width <= 0 || this.canvas.height <= 0) return null
12058
+ // THE PICTURE, NOT THE ELEMENT.
12059
+ //
12060
+ // The projection's aspect comes from the DRAWING BUFFER, while a pointer
12061
+ // arrives in the CSS box — and the two do not have to agree. The canvas is
12062
+ // laid out `object-contain`, so whenever they differ the rendered image sits
12063
+ // letterboxed inside the element with bars either side of it, and dividing
12064
+ // by the element's own size lands the ray somewhere the picture is not.
12065
+ // They disagree on every resize until the observer catches up, and
12066
+ // permanently wherever a host frames the canvas to a shape of its own.
12067
+ //
12068
+ // So: work out where the image actually sits, and take the ray from that.
12069
+ const bufAspect = this.canvas.width / this.canvas.height
12070
+ const boxAspect = width / height
12071
+ const imgW = bufAspect > boxAspect ? width : height * bufAspect
12072
+ const imgH = bufAspect > boxAspect ? width / bufAspect : height
12073
+ const ox = (width - imgW) / 2
12074
+ const oy = (height - imgH) / 2
12075
+ const ndcX = ((px - ox) / imgW) * 2 - 1
12076
+ const ndcY = -(((py - oy) / imgH) * 2 - 1)
11860
12077
  const view = this.camera.getViewMatrix()
11861
12078
  const proj = this.camera.getProjectionMatrix()
11862
12079
  const invVP = proj.multiply(view).inverse()
@@ -12333,8 +12550,13 @@ export class Engine {
12333
12550
  }
12334
12551
  }
12335
12552
 
12336
- // Drive the shot from the camera VMD (synced to the animated model's clock).
12337
- if (this.camera.vmdDriven && this.cameraAnimation) {
12553
+ // Who holds the shot this frame. An external pose is a statement about
12554
+ // where the camera IS, so it is reapplied rather than sampled — and it
12555
+ // outranks a loaded track, which is scene data.
12556
+ if (this.cameraPoseOverride) {
12557
+ this.camera.setVmdPose(this.cameraPoseOverride)
12558
+ } else if (this.camera.vmdDriven && this.cameraAnimation) {
12559
+ // Drive the shot from the camera VMD (synced to the animated model's clock).
12338
12560
  const pose = this.cameraAnimation.sample(this.transportTime())
12339
12561
  if (pose) this.camera.setVmdPose(pose)
12340
12562
  }
@@ -13635,6 +13857,12 @@ export class Engine {
13635
13857
  // clock is already per effect, which is the one that actually breaks
13636
13858
  // things (rzGridFrame()==0 is a grid's only chance to seed).
13637
13859
  u[24] = this.sceneClock - (this.effects[0]?.epochScene ?? 0)
13860
+ // The grain's seed rides the same per-frame refresh, because it is the
13861
+ // only thing that makes it move — a seed written once by its setter is a
13862
+ // still pattern welded to the picture. On the SCENE clock like everything
13863
+ // else here, so an export reproduces the editor exactly rather than
13864
+ // scattering differently at whatever rate the encoder ran.
13865
+ u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0
13638
13866
  u[26] = this.canvas.width
13639
13867
  u[27] = this.canvas.height
13640
13868
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -138,7 +138,7 @@ override APPLY_GAMMA: bool = true;
138
138
  // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
139
139
  // continuity kills the banding — sampled with hardware linear filtering.
140
140
  @group(0) @binding(5) var filmicLut: texture_2d<f32>;
141
- // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
141
+ // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
142
142
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
143
143
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
144
144
  // 1 solid color, 2 = 360 equirect skybox sampled by view ray. A user
@@ -526,6 +526,23 @@ const COMPOSITE_BODY = /* wgsl */ `
526
526
  if (APPLY_GAMMA) {
527
527
  disp = pow(disp, vec3f(viewU[0].y));
528
528
  }
529
+ // ── Film grain, on the SCENE ONLY ─────────────────────────────────────────
530
+ //
531
+ // Applied here, before the background is composited under, so it rides on what
532
+ // the engine drew and nothing else. That placement is the whole point when the
533
+ // background is footage: the plate came off a real sensor and already carries
534
+ // its own grain, and a second helping over the top would grade the photograph
535
+ // rather than match it. A clean CG figure on a grainy plate is one of the
536
+ // loudest tells there is — the noise gives it away long before the geometry.
537
+ //
538
+ // Multiplicative and weighted toward the mid-tones, which is how film behaves:
539
+ // little grain in the blacks, and the highlights clip it off.
540
+ if (viewU[0].z > 0.0) {
541
+ let gp = fragCoord.xy + vec2f(viewU[0].w, viewU[0].w * 1.7);
542
+ let gn = fract(sin(dot(gp, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5;
543
+ let glum = dot(disp, vec3f(0.2126, 0.7152, 0.0722));
544
+ disp = max(disp * (1.0 + gn * viewU[0].z * 4.0 * glum * (1.0 - glum)), vec3f(0.0));
545
+ }
529
546
  // Composite over the background in display space (premultiplied out). The
530
547
  // background is TWO layers: a base (transparent / solid color / 360 equirect)
531
548
  // and an optional user WGSL effect over-composited onto it.