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/dist/engine.js
CHANGED
|
@@ -675,6 +675,13 @@ export class Engine {
|
|
|
675
675
|
this.lightCount = 0;
|
|
676
676
|
this.resizeObserver = null;
|
|
677
677
|
this.resizePending = false;
|
|
678
|
+
/** The soft-edge variant, built the first time a scene asks for one. Null while
|
|
679
|
+
* no scene has, which is most of them — a pipeline nobody draws with is still
|
|
680
|
+
* a shader compile at load. */
|
|
681
|
+
this.groundShadowSoftPipeline = null;
|
|
682
|
+
/** How the ground's own pipeline is chosen, kept beside the uniform that sets
|
|
683
|
+
* it so the draw does not have to read the buffer back. */
|
|
684
|
+
this.groundSoft = false;
|
|
678
685
|
this.selectedMaterial = null;
|
|
679
686
|
this.overlayInstanceBuffer = null;
|
|
680
687
|
this.overlayInstanceCapacity = 0;
|
|
@@ -1053,6 +1060,8 @@ export class Engine {
|
|
|
1053
1060
|
contrast: DEFAULT_COLOR_GRADING.contrast,
|
|
1054
1061
|
saturation: DEFAULT_COLOR_GRADING.saturation,
|
|
1055
1062
|
};
|
|
1063
|
+
/** Sensor grain: how much, and whether it moves. */
|
|
1064
|
+
this.grain = { amount: 0, animated: true };
|
|
1056
1065
|
/** Debug/diagnostic: skip every inverted-hull outline draw. */
|
|
1057
1066
|
// OFF by default — the product aesthetic. Modern high-detail models read
|
|
1058
1067
|
// better without hulls (babylon-mmd's own demos disable its outline renderer
|
|
@@ -1065,6 +1074,17 @@ export class Engine {
|
|
|
1065
1074
|
/** When set, render resolution is pinned to this size instead of tracking the
|
|
1066
1075
|
* canvas's CSS size × devicePixelRatio (see setRenderSize). */
|
|
1067
1076
|
this.fixedRenderSize = null;
|
|
1077
|
+
// ── VMD camera track ──
|
|
1078
|
+
// A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
|
|
1079
|
+
// via model.loadVmd never touch the camera — the camera shot is opt-in through here.
|
|
1080
|
+
/** Whether a loaded camera track is allowed to drive (setCameraVmdEnabled).
|
|
1081
|
+
* Held separately from `camera.vmdDriven` because that flag now answers to
|
|
1082
|
+
* two sources, and a track switched off must stay off when the other one
|
|
1083
|
+
* releases the camera. */
|
|
1084
|
+
this.cameraVmdEnabled = true;
|
|
1085
|
+
/** A pose pushed in from outside — see setCameraPose. Reapplied every frame,
|
|
1086
|
+
* so it outranks the orbit AND a loaded track for as long as it is set. */
|
|
1087
|
+
this.cameraPoseOverride = null;
|
|
1068
1088
|
/** Per cascade: does its map currently hold nothing but the cleared far plane?
|
|
1069
1089
|
* Set by the cascade loop, which skips a cascade that is unwanted and already
|
|
1070
1090
|
* cleared rather than re-clearing it every frame. */
|
|
@@ -1400,6 +1420,30 @@ export class Engine {
|
|
|
1400
1420
|
saturation: g.saturation,
|
|
1401
1421
|
};
|
|
1402
1422
|
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Film grain over the rendered scene, 0–1.
|
|
1425
|
+
*
|
|
1426
|
+
* A property of a SENSOR, so it belongs to the camera rather than to any one
|
|
1427
|
+
* subject, and it lands on what the engine drew and on nothing else — never on
|
|
1428
|
+
* a background image or a backdrop video, which arrived with grain of their
|
|
1429
|
+
* own and would be graded rather than matched by a second helping.
|
|
1430
|
+
*
|
|
1431
|
+
* `animated` false freezes it. A still photograph's grain does not move, and
|
|
1432
|
+
* noise crawling over a frozen picture makes the rendering look more alive
|
|
1433
|
+
* than the thing it is standing in.
|
|
1434
|
+
*
|
|
1435
|
+
* Costs one hash per pixel in a pass that already runs, and nothing at all at
|
|
1436
|
+
* zero — the branch is on a uniform.
|
|
1437
|
+
*/
|
|
1438
|
+
setFilmGrain(amount, animated = true) {
|
|
1439
|
+
this.grain.amount = Math.min(Math.max(amount, 0), 1);
|
|
1440
|
+
this.grain.animated = animated;
|
|
1441
|
+
if (this.device && this.compositeUniformBuffer)
|
|
1442
|
+
this.writeCompositeViewUniforms();
|
|
1443
|
+
}
|
|
1444
|
+
getFilmGrain() {
|
|
1445
|
+
return this.grain;
|
|
1446
|
+
}
|
|
1403
1447
|
setViewTransformOptions(patch) {
|
|
1404
1448
|
const v = this.viewTransform;
|
|
1405
1449
|
if (patch.exposure !== undefined)
|
|
@@ -1442,8 +1486,11 @@ export class Engine {
|
|
|
1442
1486
|
// compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
|
|
1443
1487
|
// a uniform branch that skips the pow entirely in the common case.
|
|
1444
1488
|
u[1] = 1.0 / Math.max(v.gamma, 1e-4);
|
|
1445
|
-
u[2] =
|
|
1446
|
-
|
|
1489
|
+
u[2] = this.grain.amount;
|
|
1490
|
+
// The seed. Zero means STILL: a plate that is one photograph has grain that
|
|
1491
|
+
// does not move, and CG noise crawling over a frozen picture makes the CG
|
|
1492
|
+
// look more alive than the footage — the opposite of the point.
|
|
1493
|
+
u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0;
|
|
1447
1494
|
u[4] = b.color.x;
|
|
1448
1495
|
u[5] = b.color.y;
|
|
1449
1496
|
u[6] = b.color.z;
|
|
@@ -4878,14 +4925,9 @@ export class Engine {
|
|
|
4878
4925
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
4879
4926
|
],
|
|
4880
4927
|
});
|
|
4881
|
-
|
|
4882
|
-
label: "ground shadow",
|
|
4883
|
-
code: groundShaderWgsl(),
|
|
4884
|
-
});
|
|
4885
|
-
this.groundShadowPipeline = this.createRenderPipeline({
|
|
4928
|
+
this.groundShadowPipelineDesc = {
|
|
4886
4929
|
label: "ground shadow pipeline",
|
|
4887
4930
|
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
|
|
4888
|
-
shaderModule: groundShadowShader,
|
|
4889
4931
|
// Slot 0 only — the ground has no skinning, and declaring the full
|
|
4890
4932
|
// 3-slot layout while renderGround binds one buffer is a WebGPU
|
|
4891
4933
|
// validation error that invalidates the whole command buffer.
|
|
@@ -4893,7 +4935,8 @@ export class Engine {
|
|
|
4893
4935
|
fragmentTargets: sceneTargetsFor("ground", this.sceneFormats),
|
|
4894
4936
|
cullMode: "back",
|
|
4895
4937
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
4896
|
-
}
|
|
4938
|
+
};
|
|
4939
|
+
this.groundShadowPipeline = this.buildGroundPipeline(false);
|
|
4897
4940
|
// Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
|
|
4898
4941
|
this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
4899
4942
|
label: "outline per-frame bind group layout",
|
|
@@ -6323,21 +6366,60 @@ export class Engine {
|
|
|
6323
6366
|
this.cameraFollowSmoothing = Math.max(0, smoothing ?? 0);
|
|
6324
6367
|
this.cameraFollowSeeded = false;
|
|
6325
6368
|
}
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6369
|
+
/** The one place that decides who is holding the camera. An external pose
|
|
6370
|
+
* wins; a track drives when it is loaded and enabled; otherwise orbit. */
|
|
6371
|
+
refreshCameraDrive() {
|
|
6372
|
+
this.camera.setVmdDriven(this.cameraPoseOverride !== null || (this.cameraVmdEnabled && this.cameraAnimation !== null));
|
|
6373
|
+
}
|
|
6374
|
+
/**
|
|
6375
|
+
* Aim the camera from outside — a solved match-move, a saved shot, a rig
|
|
6376
|
+
* driving the view from the host's own clock.
|
|
6377
|
+
*
|
|
6378
|
+
* The exact partner of `getCameraPose`, and the same five channels: the shot
|
|
6379
|
+
* as MMD states it, roll included. Orbit cannot express roll, so this is the
|
|
6380
|
+
* only way a tilted camera reaches the engine.
|
|
6381
|
+
*
|
|
6382
|
+
* Reapplied every frame while set, which makes it authoritative rather than
|
|
6383
|
+
* advisory — nothing the transport or a loaded track does moves it. Pass null
|
|
6384
|
+
* to release, and whatever was driving before takes the camera back.
|
|
6385
|
+
*/
|
|
6386
|
+
setCameraPose(pose) {
|
|
6387
|
+
if (pose) {
|
|
6388
|
+
// Copied, not held: a host reusing one object per frame is the normal
|
|
6389
|
+
// shape of a track, and storing the reference would make the value we
|
|
6390
|
+
// reapply depend on when the caller next touched theirs.
|
|
6391
|
+
this.cameraPoseOverride = {
|
|
6392
|
+
target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
|
|
6393
|
+
rotation: new Vec3(pose.rotation.x, pose.rotation.y, pose.rotation.z),
|
|
6394
|
+
distance: pose.distance,
|
|
6395
|
+
fov: pose.fov,
|
|
6396
|
+
};
|
|
6397
|
+
}
|
|
6398
|
+
else {
|
|
6399
|
+
this.cameraPoseOverride = null;
|
|
6400
|
+
}
|
|
6401
|
+
this.refreshCameraDrive();
|
|
6402
|
+
if (this.cameraPoseOverride)
|
|
6403
|
+
this.camera.setVmdPose(this.cameraPoseOverride);
|
|
6404
|
+
}
|
|
6405
|
+
/** The pose currently forced from outside, or null when nothing is. */
|
|
6406
|
+
getCameraPoseOverride() {
|
|
6407
|
+
return this.cameraPoseOverride;
|
|
6408
|
+
}
|
|
6329
6409
|
/** Load a camera VMD (dedicated camera file, or any VMD's camera block) and drive the shot
|
|
6330
6410
|
* from it. Default-on once a non-empty track loads; toggle with setCameraVmdEnabled. */
|
|
6331
6411
|
async loadCameraVmd(url) {
|
|
6332
6412
|
const frames = await VMDLoader.loadCamera(url);
|
|
6333
6413
|
this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null;
|
|
6334
|
-
this.
|
|
6414
|
+
this.cameraVmdEnabled = true;
|
|
6415
|
+
this.refreshCameraDrive();
|
|
6335
6416
|
}
|
|
6336
6417
|
/** Load a camera VMD from an already-fetched buffer (e.g. a File the user dropped). */
|
|
6337
6418
|
loadCameraVmdFromBuffer(buffer) {
|
|
6338
6419
|
const frames = VMDLoader.loadCameraFromBuffer(buffer);
|
|
6339
6420
|
this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null;
|
|
6340
|
-
this.
|
|
6421
|
+
this.cameraVmdEnabled = true;
|
|
6422
|
+
this.refreshCameraDrive();
|
|
6341
6423
|
}
|
|
6342
6424
|
/**
|
|
6343
6425
|
* Drive the shot from camera keyframes built in JS — the camera's answer to
|
|
@@ -6354,7 +6436,8 @@ export class Engine {
|
|
|
6354
6436
|
*/
|
|
6355
6437
|
loadCameraClip(frames) {
|
|
6356
6438
|
this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null;
|
|
6357
|
-
this.
|
|
6439
|
+
this.cameraVmdEnabled = true;
|
|
6440
|
+
this.refreshCameraDrive();
|
|
6358
6441
|
}
|
|
6359
6442
|
/** The loaded camera track as editable keyframes, or [] with none loaded.
|
|
6360
6443
|
* Copies — mutating them does not reach the track being sampled. */
|
|
@@ -6372,7 +6455,8 @@ export class Engine {
|
|
|
6372
6455
|
}
|
|
6373
6456
|
/** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
|
|
6374
6457
|
setCameraVmdEnabled(enabled) {
|
|
6375
|
-
this.
|
|
6458
|
+
this.cameraVmdEnabled = enabled;
|
|
6459
|
+
this.refreshCameraDrive();
|
|
6376
6460
|
if (!enabled && this.cameraTargetModel) {
|
|
6377
6461
|
// Follow resumes with a clean snap to bone + configured offset — one
|
|
6378
6462
|
// predictable cut to the scene's framing, no easing from the shot.
|
|
@@ -6630,7 +6714,7 @@ export class Engine {
|
|
|
6630
6714
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
6631
6715
|
clearCameraVmd() {
|
|
6632
6716
|
this.cameraAnimation = null;
|
|
6633
|
-
this.
|
|
6717
|
+
this.refreshCameraDrive();
|
|
6634
6718
|
}
|
|
6635
6719
|
/**
|
|
6636
6720
|
* THE TRANSPORT'S CLOCK — where the scene is in its own playback.
|
|
@@ -6673,6 +6757,28 @@ export class Engine {
|
|
|
6673
6757
|
getCameraPosition() {
|
|
6674
6758
|
return this.camera.getPosition();
|
|
6675
6759
|
}
|
|
6760
|
+
/**
|
|
6761
|
+
* The live orbit, read in ONE call.
|
|
6762
|
+
*
|
|
6763
|
+
* A host that stores the shot has to be able to ask where the camera actually
|
|
6764
|
+
* IS, because a drag on the canvas moves this and nothing else — and a
|
|
6765
|
+
* document that never asks will happily write back the angle it last set,
|
|
6766
|
+
* discarding whatever the person just did with the mouse. Reading the four
|
|
6767
|
+
* separately invites a torn set across a frame boundary; this cannot tear.
|
|
6768
|
+
*
|
|
6769
|
+
* `target` is the orbit's own centre. While the engine is following a bone
|
|
6770
|
+
* that point rides the bone, so a caller storing a FOLLOW offset must keep its
|
|
6771
|
+
* own and take only the angles from here.
|
|
6772
|
+
*/
|
|
6773
|
+
getCameraOrbit() {
|
|
6774
|
+
const c = this.camera;
|
|
6775
|
+
return {
|
|
6776
|
+
alpha: c.alpha,
|
|
6777
|
+
beta: c.beta,
|
|
6778
|
+
distance: c.radius,
|
|
6779
|
+
target: new Vec3(c.target.x, c.target.y, c.target.z),
|
|
6780
|
+
};
|
|
6781
|
+
}
|
|
6676
6782
|
getCameraDistance() {
|
|
6677
6783
|
return this.camera.radius;
|
|
6678
6784
|
}
|
|
@@ -6691,6 +6797,21 @@ export class Engine {
|
|
|
6691
6797
|
setCameraBeta(b) {
|
|
6692
6798
|
this.camera.beta = b;
|
|
6693
6799
|
}
|
|
6800
|
+
/**
|
|
6801
|
+
* Roll the orbiting shot, radians — the lean alpha and beta cannot state.
|
|
6802
|
+
*
|
|
6803
|
+
* Tips the up vector about the eye→target line, so the camera stays exactly
|
|
6804
|
+
* where it was and keeps looking at exactly what it looked at. Everything the
|
|
6805
|
+
* orbit does still works underneath it: following a bone, dragging, zooming.
|
|
6806
|
+
*
|
|
6807
|
+
* A camera VMD carries its own roll and ignores this while it drives.
|
|
6808
|
+
*/
|
|
6809
|
+
setCameraRoll(r) {
|
|
6810
|
+
this.camera.roll = r;
|
|
6811
|
+
}
|
|
6812
|
+
getCameraRoll() {
|
|
6813
|
+
return this.camera.roll;
|
|
6814
|
+
}
|
|
6694
6815
|
/** Vertical field of view in radians (default π/4). While a camera VMD
|
|
6695
6816
|
* drives the view it animates fov itself; the orbit value set here is
|
|
6696
6817
|
* restored when the VMD releases the camera. */
|
|
@@ -6822,6 +6943,7 @@ export class Engine {
|
|
|
6822
6943
|
opacity: 1.0,
|
|
6823
6944
|
mirror: false,
|
|
6824
6945
|
mirrorBlur: 0,
|
|
6946
|
+
shadowSoftness: 0,
|
|
6825
6947
|
...options,
|
|
6826
6948
|
};
|
|
6827
6949
|
this.createGroundGeometry(opts.width, opts.height);
|
|
@@ -9014,8 +9136,25 @@ export class Engine {
|
|
|
9014
9136
|
});
|
|
9015
9137
|
this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
|
|
9016
9138
|
}
|
|
9139
|
+
buildGroundPipeline(soft) {
|
|
9140
|
+
return this.createRenderPipeline({
|
|
9141
|
+
...this.groundShadowPipelineDesc,
|
|
9142
|
+
label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
|
|
9143
|
+
shaderModule: this.device.createShaderModule({
|
|
9144
|
+
label: soft ? "ground shadow (soft)" : "ground shadow",
|
|
9145
|
+
code: groundShaderWgsl(soft),
|
|
9146
|
+
}),
|
|
9147
|
+
});
|
|
9148
|
+
}
|
|
9149
|
+
/** Built on the first frame that actually needs it. A shader compile costs
|
|
9150
|
+
* load time, and the overwhelming majority of scenes never soften a shadow. */
|
|
9151
|
+
ensureGroundSoftPipeline() {
|
|
9152
|
+
if (!this.groundShadowSoftPipeline)
|
|
9153
|
+
this.groundShadowSoftPipeline = this.buildGroundPipeline(true);
|
|
9154
|
+
return this.groundShadowSoftPipeline;
|
|
9155
|
+
}
|
|
9017
9156
|
createShadowGroundResources(opts) {
|
|
9018
|
-
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, } = opts;
|
|
9157
|
+
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, shadowSoftness, } = opts;
|
|
9019
9158
|
// Shadow map is already created in setupPipelines()
|
|
9020
9159
|
// 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
|
|
9021
9160
|
// keeping the uniform vec4-aligned.
|
|
@@ -9040,6 +9179,12 @@ export class Engine {
|
|
|
9040
9179
|
this.groundMirror = gb[15];
|
|
9041
9180
|
gb[16] = Math.min(Math.max(mirrorBlur, 0), 1);
|
|
9042
9181
|
this.groundMirrorBlur = gb[16];
|
|
9182
|
+
// gb[18] — shadow edge softness. Was padding; the shader reads it as the
|
|
9183
|
+
// Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
|
|
9184
|
+
gb[18] = Math.min(Math.max(shadowSoftness, 0), 1);
|
|
9185
|
+
// Which variant the draw picks. Zero is the sharp shader, which is the one
|
|
9186
|
+
// that existed before softness did.
|
|
9187
|
+
this.groundSoft = gb[18] > 0;
|
|
9043
9188
|
// gb[17] — does the FAR cascade hold anything?
|
|
9044
9189
|
//
|
|
9045
9190
|
// It holds something only when a stage is loaded; that is what it exists for
|
|
@@ -9677,7 +9822,7 @@ export class Engine {
|
|
|
9677
9822
|
return;
|
|
9678
9823
|
if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
|
|
9679
9824
|
return;
|
|
9680
|
-
pass.setPipeline(this.groundShadowPipeline);
|
|
9825
|
+
pass.setPipeline(this.groundSoft ? this.ensureGroundSoftPipeline() : this.groundShadowPipeline);
|
|
9681
9826
|
pass.setVertexBuffer(0, this.groundVertexBuffer);
|
|
9682
9827
|
pass.setIndexBuffer(this.groundIndexBuffer, "uint16");
|
|
9683
9828
|
pass.setBindGroup(0, this.groundDrawCall.bindGroup);
|
|
@@ -10200,15 +10345,65 @@ export class Engine {
|
|
|
10200
10345
|
return new Vec3(x / w, y / w, z / w);
|
|
10201
10346
|
}
|
|
10202
10347
|
// World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
|
|
10348
|
+
/**
|
|
10349
|
+
* Where a point on the canvas lands on a horizontal plane.
|
|
10350
|
+
*
|
|
10351
|
+
* `px,py` are canvas-relative pixels, top-left origin — what a pointer event
|
|
10352
|
+
* gives you after subtracting the element's rect. Returns null when the ray
|
|
10353
|
+
* cannot reach the plane: parallel to it, or pointing the other way, which is
|
|
10354
|
+
* what a click on the sky above the horizon is.
|
|
10355
|
+
*
|
|
10356
|
+
* The one primitive a placement UI needs. Dragging a thing across the floor is
|
|
10357
|
+
* otherwise three sliders in world units, which asks someone to guess numbers
|
|
10358
|
+
* that have no visible relation to the picture they are looking at — and it
|
|
10359
|
+
* throws away the property that makes pointing work at all: under perspective,
|
|
10360
|
+
* moving something further away makes it smaller by exactly the right amount,
|
|
10361
|
+
* so position and size stop being two controls to tune against each other.
|
|
10362
|
+
*/
|
|
10363
|
+
groundPointAt(px, py, planeY = 0) {
|
|
10364
|
+
const ray = this.buildMouseRay(px, py);
|
|
10365
|
+
if (!ray)
|
|
10366
|
+
return null;
|
|
10367
|
+
// Parallel to the plane: no intersection, and a huge one is not an answer.
|
|
10368
|
+
if (Math.abs(ray.dir.y) < 1e-6)
|
|
10369
|
+
return null;
|
|
10370
|
+
const t = (planeY - ray.origin.y) / ray.dir.y;
|
|
10371
|
+
// Behind the camera — the plane is there, but not in this shot.
|
|
10372
|
+
if (!(t > 0) || !isFinite(t))
|
|
10373
|
+
return null;
|
|
10374
|
+
return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t);
|
|
10375
|
+
}
|
|
10376
|
+
/** Hand the pointer to something else — a placement drag, a gizmo, a host's own
|
|
10377
|
+
* overlay — so the orbit does not also act on it. */
|
|
10378
|
+
setCameraInputLocked(locked) {
|
|
10379
|
+
this.camera?.setInputLocked(locked);
|
|
10380
|
+
}
|
|
10203
10381
|
buildMouseRay(px, py) {
|
|
10204
10382
|
if (!this.camera)
|
|
10205
10383
|
return null;
|
|
10206
10384
|
const width = this.canvas.clientWidth;
|
|
10207
10385
|
const height = this.canvas.clientHeight;
|
|
10208
|
-
if (width <= 0 || height <= 0)
|
|
10386
|
+
if (width <= 0 || height <= 0 || this.canvas.width <= 0 || this.canvas.height <= 0)
|
|
10209
10387
|
return null;
|
|
10210
|
-
|
|
10211
|
-
|
|
10388
|
+
// THE PICTURE, NOT THE ELEMENT.
|
|
10389
|
+
//
|
|
10390
|
+
// The projection's aspect comes from the DRAWING BUFFER, while a pointer
|
|
10391
|
+
// arrives in the CSS box — and the two do not have to agree. The canvas is
|
|
10392
|
+
// laid out `object-contain`, so whenever they differ the rendered image sits
|
|
10393
|
+
// letterboxed inside the element with bars either side of it, and dividing
|
|
10394
|
+
// by the element's own size lands the ray somewhere the picture is not.
|
|
10395
|
+
// They disagree on every resize until the observer catches up, and
|
|
10396
|
+
// permanently wherever a host frames the canvas to a shape of its own.
|
|
10397
|
+
//
|
|
10398
|
+
// So: work out where the image actually sits, and take the ray from that.
|
|
10399
|
+
const bufAspect = this.canvas.width / this.canvas.height;
|
|
10400
|
+
const boxAspect = width / height;
|
|
10401
|
+
const imgW = bufAspect > boxAspect ? width : height * bufAspect;
|
|
10402
|
+
const imgH = bufAspect > boxAspect ? width / bufAspect : height;
|
|
10403
|
+
const ox = (width - imgW) / 2;
|
|
10404
|
+
const oy = (height - imgH) / 2;
|
|
10405
|
+
const ndcX = ((px - ox) / imgW) * 2 - 1;
|
|
10406
|
+
const ndcY = -(((py - oy) / imgH) * 2 - 1);
|
|
10212
10407
|
const view = this.camera.getViewMatrix();
|
|
10213
10408
|
const proj = this.camera.getProjectionMatrix();
|
|
10214
10409
|
const invVP = proj.multiply(view).inverse();
|
|
@@ -10491,8 +10686,14 @@ export class Engine {
|
|
|
10491
10686
|
}
|
|
10492
10687
|
}
|
|
10493
10688
|
}
|
|
10494
|
-
//
|
|
10495
|
-
|
|
10689
|
+
// Who holds the shot this frame. An external pose is a statement about
|
|
10690
|
+
// where the camera IS, so it is reapplied rather than sampled — and it
|
|
10691
|
+
// outranks a loaded track, which is scene data.
|
|
10692
|
+
if (this.cameraPoseOverride) {
|
|
10693
|
+
this.camera.setVmdPose(this.cameraPoseOverride);
|
|
10694
|
+
}
|
|
10695
|
+
else if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
10696
|
+
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
10496
10697
|
const pose = this.cameraAnimation.sample(this.transportTime());
|
|
10497
10698
|
if (pose)
|
|
10498
10699
|
this.camera.setVmdPose(pose);
|
|
@@ -11724,6 +11925,12 @@ export class Engine {
|
|
|
11724
11925
|
// clock is already per effect, which is the one that actually breaks
|
|
11725
11926
|
// things (rzGridFrame()==0 is a grid's only chance to seed).
|
|
11726
11927
|
u[24] = this.sceneClock - (this.effects[0]?.epochScene ?? 0);
|
|
11928
|
+
// The grain's seed rides the same per-frame refresh, because it is the
|
|
11929
|
+
// only thing that makes it move — a seed written once by its setter is a
|
|
11930
|
+
// still pattern welded to the picture. On the SCENE clock like everything
|
|
11931
|
+
// else here, so an export reproduces the editor exactly rather than
|
|
11932
|
+
// scattering differently at whatever rate the encoder ran.
|
|
11933
|
+
u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0;
|
|
11727
11934
|
u[26] = this.canvas.width;
|
|
11728
11935
|
u[27] = this.canvas.height;
|
|
11729
11936
|
// 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;
|
|
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,
|
|
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,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The ground's shader. `soft` selects the shadow-edge variant — see pcfWgsl for
|
|
3
|
+
* why this is a compiled flag and not a uniform the shader branches on.
|
|
4
|
+
*/
|
|
5
|
+
export declare function groundShaderWgsl(soft?: boolean): string;
|
|
2
6
|
/**
|
|
3
7
|
* The ground belongs to no model instance, so it takes ids of its own — at the
|
|
4
8
|
* TOP of the u16 range, not at the bottom.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAgFA;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,UAAQ,GAAG,MAAM,CA+TrD;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,7 +8,77 @@ 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
|
-
|
|
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, soft) {
|
|
36
|
+
const golden = 2.39996323;
|
|
37
|
+
// ONE BRANCH IS COMPILED, NOT BOTH.
|
|
38
|
+
//
|
|
39
|
+
// A runtime `if` on the softness uniform was the first cut, and it costs a
|
|
40
|
+
// scene that never softens a shadow. Both bodies land in the module, the wide
|
|
41
|
+
// one needs more live registers than the narrow one, and occupancy on this
|
|
42
|
+
// draw is set by the worst of them — so the frame gets slower whether or not
|
|
43
|
+
// the branch is ever taken. That is expensive HERE in particular: this file
|
|
44
|
+
// already records that the ground is a full-coverage draw costing tens of
|
|
45
|
+
// millions of shadow fetches, and that bisection on a slow device pinned the
|
|
46
|
+
// whole cost to it.
|
|
47
|
+
//
|
|
48
|
+
// So the flag is the COMPILED VARIANT, which is the idiom the composite pass
|
|
49
|
+
// already uses for its effects. A scene at softness 0 gets byte-for-byte the
|
|
50
|
+
// shader it had before any of this existed, and cannot pay for a feature it
|
|
51
|
+
// is not using.
|
|
52
|
+
const sharp = [
|
|
53
|
+
`let st = ${texel} * 2.0;`,
|
|
54
|
+
`for (var y = -1; y <= 1; y++) {`,
|
|
55
|
+
` for (var x = -1; x <= 1; x++) {`,
|
|
56
|
+
// ...Level, not the implicit-derivative form: identical on a single-mip
|
|
57
|
+
// shadow map, and legal inside a branch.
|
|
58
|
+
` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(f32(x), f32(y)) * st, ${z});`,
|
|
59
|
+
` }`,
|
|
60
|
+
`}`,
|
|
61
|
+
`${acc} *= ${1 / 9};`,
|
|
62
|
+
];
|
|
63
|
+
const wide = [
|
|
64
|
+
`let radius = ${texel} * 2.0 * (1.0 + material.shadowSoftness * ${SOFT_MAX_SPREAD}.0);`,
|
|
65
|
+
`for (var s = 0; s < ${SOFT_TAPS}; s++) {`,
|
|
66
|
+
` let fs = f32(s);`,
|
|
67
|
+
// sqrt of the index spaces the ring radii evenly by AREA; the golden angle
|
|
68
|
+
// keeps successive taps from lining up into spokes.
|
|
69
|
+
` let r = sqrt((fs + 0.5) * ${1 / SOFT_TAPS});`,
|
|
70
|
+
` let a = fs * ${golden} + rot;`,
|
|
71
|
+
` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(cos(a), sin(a)) * (r * radius), ${z});`,
|
|
72
|
+
`}`,
|
|
73
|
+
`${acc} *= ${1 / SOFT_TAPS};`,
|
|
74
|
+
];
|
|
75
|
+
return (soft ? wide : sharp).map((l) => pad + l).join("\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The ground's shader. `soft` selects the shadow-edge variant — see pcfWgsl for
|
|
79
|
+
* why this is a compiled flag and not a uniform the shader branches on.
|
|
80
|
+
*/
|
|
81
|
+
export function groundShaderWgsl(soft = false) {
|
|
12
82
|
return /* wgsl */ `
|
|
13
83
|
struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
|
|
14
84
|
struct Light { direction: vec4f, color: vec4f, };
|
|
@@ -20,7 +90,7 @@ struct GroundShadowMat {
|
|
|
20
90
|
gridLineColor: vec3f, mirror: f32,
|
|
21
91
|
// farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
|
|
22
92
|
// with no stage the far map is never drawn into, so its taps are known.
|
|
23
|
-
mirrorBlur: f32, farCascade: f32,
|
|
93
|
+
mirrorBlur: f32, farCascade: f32, shadowSoftness: f32, _mb2: f32,
|
|
24
94
|
// Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
|
|
25
95
|
// nothing casts, negative means "do not use this" (a rigid caster has no
|
|
26
96
|
// sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
|
|
@@ -150,6 +220,13 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
|
|
|
150
220
|
// The same reasoning the noise tint below already got, applied to the term
|
|
151
221
|
// that costs a hundred times more.
|
|
152
222
|
if (material.shadowStrength > 0.0 && shadowPossible) {
|
|
223
|
+
${soft
|
|
224
|
+
? ` // Per-pixel rotation for the soft disk, so its rings break up into fine
|
|
225
|
+
// noise rather than banding. Interleaved gradient noise: a function of the
|
|
226
|
+
// pixel alone, so a still camera gives a still shadow.
|
|
227
|
+
let ign = fract(52.9829189 * fract(dot(i.position.xy, vec2f(0.06711056, 0.00583715))));
|
|
228
|
+
let rot = ign * 6.28318530718;`
|
|
229
|
+
: ""}
|
|
153
230
|
// The far cascade's taps, skipped entirely when nothing ever drew into it.
|
|
154
231
|
//
|
|
155
232
|
// This branch is the expensive one on a wide floor: it runs wherever the NEAR
|
|
@@ -163,32 +240,20 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
|
|
|
163
240
|
if (material.farCascade > 0.0 && frustum < 1.0 && frustum1 > 0.0) {
|
|
164
241
|
let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
|
|
165
242
|
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
243
|
let compareZ1 = ndc1.z - 0.0035;
|
|
168
244
|
var acc1 = 0.0;
|
|
169
|
-
|
|
170
|
-
|
|
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);
|
|
245
|
+
${pcfWgsl("shadowMapFar", "suv1_c", `${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize}`, "compareZ1", "acc1", " ", soft)}
|
|
246
|
+
vis = mix(1.0, acc1, frustum1);
|
|
175
247
|
}
|
|
176
248
|
if (frustum > 0.0) {
|
|
177
249
|
let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
|
178
250
|
let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
|
|
179
|
-
let st = material.pcfTexel * 2.0;
|
|
180
251
|
let compareZ = ndc.z - 0.0035;
|
|
181
252
|
var acc = 0.0;
|
|
182
|
-
|
|
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
|
-
}
|
|
253
|
+
${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ", soft)}
|
|
189
254
|
// The base is whatever the far cascade decided, so the near border blends
|
|
190
255
|
// cascade to cascade rather than snapping to lit mid-floor.
|
|
191
|
-
vis = mix(vis, acc
|
|
256
|
+
vis = mix(vis, acc, frustum);
|
|
192
257
|
}
|
|
193
258
|
}
|
|
194
259
|
|