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/dist/camera.d.ts +13 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +53 -2
- package/dist/engine.d.ts +121 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +231 -24
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +18 -1
- package/dist/shaders/passes/ground.d.ts +5 -1
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +83 -18
- package/package.json +1 -1
- package/src/camera.ts +60 -2
- package/src/engine.ts +248 -20
- package/src/shaders/passes/composite.ts +18 -1
- package/src/shaders/passes/ground.ts +87 -18
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
|
-
|
|
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
|
-
|
|
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] =
|
|
2440
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
11859
|
-
|
|
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
|
-
//
|
|
12337
|
-
|
|
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,
|
|
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.
|