reze-engine 0.54.16 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/camera.d.ts +13 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +53 -2
- package/dist/engine.d.ts +317 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1263 -27
- package/dist/graph/presets/pool_floor.d.ts +3 -0
- package/dist/graph/presets/pool_floor.d.ts.map +1 -0
- package/dist/graph/presets/pool_floor.js +42 -0
- package/dist/graph/presets/water.d.ts +3 -0
- package/dist/graph/presets/water.d.ts.map +1 -0
- package/dist/graph/presets/water.js +31 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +10 -0
- package/dist/overlay.d.ts +200 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +806 -0
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -0
- package/dist/pmx-document.d.ts +218 -0
- package/dist/pmx-document.d.ts.map +1 -0
- package/dist/pmx-document.js +685 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +18 -1
- package/dist/shaders/passes/field-blit.d.ts +26 -0
- package/dist/shaders/passes/field-blit.d.ts.map +1 -0
- package/dist/shaders/passes/field-blit.js +65 -0
- package/dist/shaders/passes/ground-noise.d.ts +7 -0
- package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
- package/dist/shaders/passes/ground-noise.js +88 -0
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +63 -17
- package/dist/shaders/passes/overlay.d.ts +3 -0
- package/dist/shaders/passes/overlay.d.ts.map +1 -0
- package/dist/shaders/passes/overlay.js +164 -0
- package/dist/shaders/passes/sim.d.ts +34 -0
- package/dist/shaders/passes/sim.d.ts.map +1 -0
- package/dist/shaders/passes/sim.js +169 -0
- package/dist/shaders/passes/wireframe.d.ts +2 -0
- package/dist/shaders/passes/wireframe.d.ts.map +1 -0
- package/dist/shaders/passes/wireframe.js +107 -0
- package/dist/shaders/score-api.d.ts +10 -0
- package/dist/shaders/score-api.d.ts.map +1 -0
- package/dist/shaders/score-api.js +114 -0
- package/package.json +1 -1
- package/src/camera.ts +60 -2
- package/src/engine.ts +1359 -25
- package/src/index.ts +50 -0
- package/src/model.ts +9 -0
- package/src/overlay.ts +1000 -0
- package/src/pmx-document.ts +882 -0
- package/src/shaders/passes/composite.ts +18 -1
- package/src/shaders/passes/ground.ts +65 -17
- package/src/shaders/passes/overlay.ts +166 -0
- package/src/shaders/passes/wireframe.ts +108 -0
package/dist/engine.js
CHANGED
|
@@ -32,6 +32,9 @@ import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/
|
|
|
32
32
|
import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
|
|
33
33
|
import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
|
|
34
34
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
|
|
35
|
+
import { OVERLAY_SHADER_WGSL, OVERLAY_COMPOSITE_SHADER_WGSL } from "./shaders/passes/overlay";
|
|
36
|
+
import { WIREFRAME_SHADER_WGSL } from "./shaders/passes/wireframe";
|
|
37
|
+
import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, rigidbodyOverlay, writeOverlayInstance, OVERLAY_INSTANCE_FLOATS, OVERLAY_VERTEX_FLOATS, OVERLAY_SHAPES, OVERLAY_SOLID_SHAPES, DEFAULT_VERTEX_COLOR, OVERLAY_STYLE, } from "./overlay";
|
|
35
38
|
import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
|
|
36
39
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
|
|
37
40
|
import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
|
|
@@ -673,8 +676,37 @@ export class Engine {
|
|
|
673
676
|
this.resizeObserver = null;
|
|
674
677
|
this.resizePending = false;
|
|
675
678
|
this.selectedMaterial = null;
|
|
679
|
+
this.overlayInstanceBuffer = null;
|
|
680
|
+
this.overlayInstanceCapacity = 0;
|
|
681
|
+
this.overlayDepthTexture = null;
|
|
682
|
+
this.overlayMsaaTexture = null;
|
|
683
|
+
this.overlayResolveTexture = null;
|
|
684
|
+
this.overlayUniformData = new Float32Array(4);
|
|
685
|
+
this.overlayCompositeBindGroup = null;
|
|
686
|
+
this.overlayTargetSize = [0, 0];
|
|
687
|
+
this.overlayLayers = new Map();
|
|
688
|
+
this.overlayBones = null;
|
|
689
|
+
this.overlayBodies = null;
|
|
690
|
+
this.overlayJoints = null;
|
|
691
|
+
this.overlayVertices = null;
|
|
692
|
+
this.wireframeColorData = new Float32Array(8);
|
|
693
|
+
/** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
|
|
694
|
+
this.overlayByShape = new Map();
|
|
695
|
+
this.overlayScratch = [];
|
|
696
|
+
this.bonePickScratch = new Float32Array(0);
|
|
697
|
+
this.overlayInstanceData = new Float32Array(0);
|
|
676
698
|
// ─── Transform gizmo ───────────────────────────────────────────────
|
|
677
699
|
this.selectedBone = null;
|
|
700
|
+
/** The material a pointer is currently over, or null. Cheap and separate from
|
|
701
|
+
* setVertexOverlay on purpose — the same split setSelectedBone takes from
|
|
702
|
+
* setBoneOverlay — because this is written every frame the pointer moves and
|
|
703
|
+
* the overlay's own option object is not something to reconstruct that often. */
|
|
704
|
+
this.hoverMaterial = null;
|
|
705
|
+
/** The transform gizmo follows setSelectedBone, which is also what selects a
|
|
706
|
+
* bone to INSPECT. A model editor selects bones constantly and poses them
|
|
707
|
+
* rarely, so the two need separating: off leaves selection working and takes
|
|
708
|
+
* the handles away. */
|
|
709
|
+
this.gizmoEnabled = true;
|
|
678
710
|
this.gizmoColorBindGroups = [];
|
|
679
711
|
// Drag state — set on mousedown if the pointer is over a gizmo handle; cleared
|
|
680
712
|
// on mouseup. While non-null, the camera is locked and mousemove/up are routed
|
|
@@ -1021,6 +1053,8 @@ export class Engine {
|
|
|
1021
1053
|
contrast: DEFAULT_COLOR_GRADING.contrast,
|
|
1022
1054
|
saturation: DEFAULT_COLOR_GRADING.saturation,
|
|
1023
1055
|
};
|
|
1056
|
+
/** Sensor grain: how much, and whether it moves. */
|
|
1057
|
+
this.grain = { amount: 0, animated: true };
|
|
1024
1058
|
/** Debug/diagnostic: skip every inverted-hull outline draw. */
|
|
1025
1059
|
// OFF by default — the product aesthetic. Modern high-detail models read
|
|
1026
1060
|
// better without hulls (babylon-mmd's own demos disable its outline renderer
|
|
@@ -1033,10 +1067,24 @@ export class Engine {
|
|
|
1033
1067
|
/** When set, render resolution is pinned to this size instead of tracking the
|
|
1034
1068
|
* canvas's CSS size × devicePixelRatio (see setRenderSize). */
|
|
1035
1069
|
this.fixedRenderSize = null;
|
|
1070
|
+
// ── VMD camera track ──
|
|
1071
|
+
// A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
|
|
1072
|
+
// via model.loadVmd never touch the camera — the camera shot is opt-in through here.
|
|
1073
|
+
/** Whether a loaded camera track is allowed to drive (setCameraVmdEnabled).
|
|
1074
|
+
* Held separately from `camera.vmdDriven` because that flag now answers to
|
|
1075
|
+
* two sources, and a track switched off must stay off when the other one
|
|
1076
|
+
* releases the camera. */
|
|
1077
|
+
this.cameraVmdEnabled = true;
|
|
1078
|
+
/** A pose pushed in from outside — see setCameraPose. Reapplied every frame,
|
|
1079
|
+
* so it outranks the orbit AND a loaded track for as long as it is set. */
|
|
1080
|
+
this.cameraPoseOverride = null;
|
|
1036
1081
|
/** Per cascade: does its map currently hold nothing but the cleared far plane?
|
|
1037
1082
|
* Set by the cascade loop, which skips a cascade that is unwanted and already
|
|
1038
1083
|
* cleared rather than re-clearing it every frame. */
|
|
1039
1084
|
this.shadowCascadeCleared = [];
|
|
1085
|
+
/** Skinned positions for picking, grown on demand. One click's worth of work
|
|
1086
|
+
* reused across clicks — a model's vertex count does not change. */
|
|
1087
|
+
this.materialPickScratch = null;
|
|
1040
1088
|
// CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
|
|
1041
1089
|
// milliseconds actually go — animation/IK/blending vs physics vs everything
|
|
1042
1090
|
// else on the render thread. The first question of any perf report.
|
|
@@ -1098,8 +1146,9 @@ export class Engine {
|
|
|
1098
1146
|
this.lastTouchTime = currentTime;
|
|
1099
1147
|
}
|
|
1100
1148
|
};
|
|
1149
|
+
this.boneOptionsScratch = {};
|
|
1101
1150
|
this.handleGizmoMouseDown = (e) => {
|
|
1102
|
-
if (!this.selectedBone || !this.camera || !this.device || e.button !== 0)
|
|
1151
|
+
if (!this.gizmoEnabled || !this.selectedBone || !this.camera || !this.device || e.button !== 0)
|
|
1103
1152
|
return;
|
|
1104
1153
|
const inst = this.modelInstances.get(this.selectedBone.modelName);
|
|
1105
1154
|
if (!inst)
|
|
@@ -1364,6 +1413,30 @@ export class Engine {
|
|
|
1364
1413
|
saturation: g.saturation,
|
|
1365
1414
|
};
|
|
1366
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Film grain over the rendered scene, 0–1.
|
|
1418
|
+
*
|
|
1419
|
+
* A property of a SENSOR, so it belongs to the camera rather than to any one
|
|
1420
|
+
* subject, and it lands on what the engine drew and on nothing else — never on
|
|
1421
|
+
* a background image or a backdrop video, which arrived with grain of their
|
|
1422
|
+
* own and would be graded rather than matched by a second helping.
|
|
1423
|
+
*
|
|
1424
|
+
* `animated` false freezes it. A still photograph's grain does not move, and
|
|
1425
|
+
* noise crawling over a frozen picture makes the rendering look more alive
|
|
1426
|
+
* than the thing it is standing in.
|
|
1427
|
+
*
|
|
1428
|
+
* Costs one hash per pixel in a pass that already runs, and nothing at all at
|
|
1429
|
+
* zero — the branch is on a uniform.
|
|
1430
|
+
*/
|
|
1431
|
+
setFilmGrain(amount, animated = true) {
|
|
1432
|
+
this.grain.amount = Math.min(Math.max(amount, 0), 1);
|
|
1433
|
+
this.grain.animated = animated;
|
|
1434
|
+
if (this.device && this.compositeUniformBuffer)
|
|
1435
|
+
this.writeCompositeViewUniforms();
|
|
1436
|
+
}
|
|
1437
|
+
getFilmGrain() {
|
|
1438
|
+
return this.grain;
|
|
1439
|
+
}
|
|
1367
1440
|
setViewTransformOptions(patch) {
|
|
1368
1441
|
const v = this.viewTransform;
|
|
1369
1442
|
if (patch.exposure !== undefined)
|
|
@@ -1406,8 +1479,11 @@ export class Engine {
|
|
|
1406
1479
|
// compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
|
|
1407
1480
|
// a uniform branch that skips the pow entirely in the common case.
|
|
1408
1481
|
u[1] = 1.0 / Math.max(v.gamma, 1e-4);
|
|
1409
|
-
u[2] =
|
|
1410
|
-
|
|
1482
|
+
u[2] = this.grain.amount;
|
|
1483
|
+
// The seed. Zero means STILL: a plate that is one photograph has grain that
|
|
1484
|
+
// does not move, and CG noise crawling over a frozen picture makes the CG
|
|
1485
|
+
// look more alive than the footage — the opposite of the point.
|
|
1486
|
+
u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0;
|
|
1411
1487
|
u[4] = b.color.x;
|
|
1412
1488
|
u[5] = b.color.y;
|
|
1413
1489
|
u[6] = b.color.z;
|
|
@@ -4036,13 +4112,6 @@ export class Engine {
|
|
|
4036
4112
|
this.createPipelines();
|
|
4037
4113
|
this.setupResize();
|
|
4038
4114
|
Engine.instance = this;
|
|
4039
|
-
// One line, at init, naming the three answers that differ between two
|
|
4040
|
-
// browsers on the same machine. Not a debug flag and not a readout — it is
|
|
4041
|
-
// the identity of the renderer that was actually built, and on a device that
|
|
4042
|
-
// cannot be attached to a debugger it is the only way to know which of the
|
|
4043
|
-
// three paths is running. Every graphics application prints this.
|
|
4044
|
-
const r = this.gpuReport();
|
|
4045
|
-
console.info(`[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`);
|
|
4046
4115
|
}
|
|
4047
4116
|
/**
|
|
4048
4117
|
* Bake the ground's frost noise once — the same fbm the shader used to run
|
|
@@ -5012,6 +5081,8 @@ export class Engine {
|
|
|
5012
5081
|
this.device.queue.writeBuffer(this.selectionEdgeUniformBuffer, 0, new Float32Array([5.0, 0, 0, 0]));
|
|
5013
5082
|
// ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
|
|
5014
5083
|
this.setupGizmo();
|
|
5084
|
+
// ─── Editor overlays (instanced wireframe primitives) ────────────
|
|
5085
|
+
this.setupOverlay();
|
|
5015
5086
|
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
5016
5087
|
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
5017
5088
|
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
@@ -5917,6 +5988,322 @@ export class Engine {
|
|
|
5917
5988
|
],
|
|
5918
5989
|
};
|
|
5919
5990
|
}
|
|
5991
|
+
// Builds the overlay pipeline and the one vertex buffer holding every unit
|
|
5992
|
+
// wireframe. The instance buffer is grown on demand in renderOverlayPass — a
|
|
5993
|
+
// scene with no overlays on never allocates one.
|
|
5994
|
+
setupOverlay() {
|
|
5995
|
+
this.overlayGeometry = buildOverlayShapes();
|
|
5996
|
+
const verts = this.overlayGeometry.vertices;
|
|
5997
|
+
this.overlayVertexBuffer = this.device.createBuffer({
|
|
5998
|
+
label: "overlay vertex buffer",
|
|
5999
|
+
size: verts.byteLength,
|
|
6000
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
6001
|
+
});
|
|
6002
|
+
this.device.queue.writeBuffer(this.overlayVertexBuffer, 0, verts);
|
|
6003
|
+
this.overlayUniformBuffer = this.device.createBuffer({
|
|
6004
|
+
label: "overlay uniforms",
|
|
6005
|
+
size: 16, // vec2 viewport + dash period + pad
|
|
6006
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6007
|
+
});
|
|
6008
|
+
const bgLayout = this.device.createBindGroupLayout({
|
|
6009
|
+
label: "overlay group 0 layout (camera + overlay)",
|
|
6010
|
+
entries: [
|
|
6011
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
6012
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
6013
|
+
],
|
|
6014
|
+
});
|
|
6015
|
+
const shader = this.device.createShaderModule({ label: "overlay shader", code: OVERLAY_SHADER_WGSL });
|
|
6016
|
+
const overlayPipelineDescriptor = {
|
|
6017
|
+
label: "overlay pipeline",
|
|
6018
|
+
layout: this.device.createPipelineLayout({
|
|
6019
|
+
label: "overlay pipeline layout",
|
|
6020
|
+
bindGroupLayouts: [bgLayout],
|
|
6021
|
+
}),
|
|
6022
|
+
vertex: {
|
|
6023
|
+
module: shader,
|
|
6024
|
+
entryPoint: "vs",
|
|
6025
|
+
buffers: [
|
|
6026
|
+
{
|
|
6027
|
+
arrayStride: OVERLAY_VERTEX_FLOATS * 4,
|
|
6028
|
+
attributes: [
|
|
6029
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" }, // pos
|
|
6030
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" }, // dir
|
|
6031
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" }, // caps
|
|
6032
|
+
{ shaderLocation: 3, offset: 8 * 4, format: "float32" }, // side
|
|
6033
|
+
{ shaderLocation: 4, offset: 9 * 4, format: "float32" }, // t
|
|
6034
|
+
{ shaderLocation: 5, offset: 10 * 4, format: "float32" }, // mode
|
|
6035
|
+
],
|
|
6036
|
+
},
|
|
6037
|
+
{
|
|
6038
|
+
arrayStride: OVERLAY_INSTANCE_FLOATS * 4,
|
|
6039
|
+
stepMode: "instance",
|
|
6040
|
+
attributes: [
|
|
6041
|
+
{ shaderLocation: 6, offset: 0, format: "float32x4" }, // rotation
|
|
6042
|
+
{ shaderLocation: 7, offset: 4 * 4, format: "float32x4" }, // position + extent
|
|
6043
|
+
{ shaderLocation: 8, offset: 8 * 4, format: "float32x4" }, // scale + thickness
|
|
6044
|
+
{ shaderLocation: 9, offset: 12 * 4, format: "float32x4" }, // color
|
|
6045
|
+
],
|
|
6046
|
+
},
|
|
6047
|
+
],
|
|
6048
|
+
},
|
|
6049
|
+
fragment: {
|
|
6050
|
+
module: shader,
|
|
6051
|
+
entryPoint: "fs",
|
|
6052
|
+
targets: [
|
|
6053
|
+
{
|
|
6054
|
+
format: this.presentationFormat,
|
|
6055
|
+
// Premultiplied: the FS already scaled rgb by alpha. See the shader.
|
|
6056
|
+
blend: {
|
|
6057
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6058
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6059
|
+
},
|
|
6060
|
+
},
|
|
6061
|
+
],
|
|
6062
|
+
},
|
|
6063
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6064
|
+
// The rig ignores depth entirely. It shares this pass's buffer with the
|
|
6065
|
+
// wireframe's mesh prepass, and that prepass exists to hide the far side
|
|
6066
|
+
// of the BODY — not to hide the skeleton inside it. An editor wants the
|
|
6067
|
+
// rig in front of the mesh, which is what "always" says. The cost is that
|
|
6068
|
+
// the rig no longer sorts against itself; for line work a few pixels wide,
|
|
6069
|
+
// draw order reads the same.
|
|
6070
|
+
depthStencil: {
|
|
6071
|
+
format: "depth24plus",
|
|
6072
|
+
depthWriteEnabled: false,
|
|
6073
|
+
depthCompare: "always",
|
|
6074
|
+
},
|
|
6075
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
6076
|
+
};
|
|
6077
|
+
this.overlayPipeline = this.device.createRenderPipeline(overlayPipelineDescriptor);
|
|
6078
|
+
// The solid volumes: the same shader and layout, with no depth write and no
|
|
6079
|
+
// culling. A translucent body must not hide the rig behind it, and you have
|
|
6080
|
+
// to see its far wall for it to read as a volume rather than a silhouette.
|
|
6081
|
+
this.overlaySolidPipeline = this.device.createRenderPipeline({
|
|
6082
|
+
...overlayPipelineDescriptor,
|
|
6083
|
+
label: "overlay solid pipeline",
|
|
6084
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6085
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
|
|
6086
|
+
});
|
|
6087
|
+
const compositeShader = this.device.createShaderModule({
|
|
6088
|
+
label: "overlay composite shader",
|
|
6089
|
+
code: OVERLAY_COMPOSITE_SHADER_WGSL,
|
|
6090
|
+
});
|
|
6091
|
+
this.overlayCompositeLayout = this.device.createBindGroupLayout({
|
|
6092
|
+
label: "overlay composite layout",
|
|
6093
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }],
|
|
6094
|
+
});
|
|
6095
|
+
this.overlayCompositePipeline = this.device.createRenderPipeline({
|
|
6096
|
+
label: "overlay composite pipeline",
|
|
6097
|
+
layout: this.device.createPipelineLayout({
|
|
6098
|
+
label: "overlay composite pipeline layout",
|
|
6099
|
+
bindGroupLayouts: [this.overlayCompositeLayout],
|
|
6100
|
+
}),
|
|
6101
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
6102
|
+
fragment: {
|
|
6103
|
+
module: compositeShader,
|
|
6104
|
+
entryPoint: "fs",
|
|
6105
|
+
targets: [
|
|
6106
|
+
{
|
|
6107
|
+
format: this.presentationFormat,
|
|
6108
|
+
blend: {
|
|
6109
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6110
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6111
|
+
},
|
|
6112
|
+
},
|
|
6113
|
+
],
|
|
6114
|
+
},
|
|
6115
|
+
primitive: { topology: "triangle-list" },
|
|
6116
|
+
multisample: { count: 1 },
|
|
6117
|
+
});
|
|
6118
|
+
this.overlayCompositePassDescriptor = {
|
|
6119
|
+
label: "overlay composite pass",
|
|
6120
|
+
colorAttachments: [
|
|
6121
|
+
{ view: undefined, loadOp: "load", storeOp: "store" },
|
|
6122
|
+
],
|
|
6123
|
+
};
|
|
6124
|
+
this.overlayBindGroup = this.device.createBindGroup({
|
|
6125
|
+
label: "overlay bind group",
|
|
6126
|
+
layout: bgLayout,
|
|
6127
|
+
entries: [
|
|
6128
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6129
|
+
{ binding: 1, resource: { buffer: this.overlayUniformBuffer } },
|
|
6130
|
+
],
|
|
6131
|
+
});
|
|
6132
|
+
// The mesh wireframe: the same line-list target, its own pipeline, because it
|
|
6133
|
+
// draws the model's OWN vertex buffer through the model's OWN skin matrices.
|
|
6134
|
+
// That is the whole reason it exists rather than emitting lines from the
|
|
6135
|
+
// loader's positions — those are bind pose, and a wireframe built from them
|
|
6136
|
+
// sits perfectly on a T-posed model and slides off every animated one.
|
|
6137
|
+
this.wireframeUniformBuffer = this.device.createBuffer({
|
|
6138
|
+
label: "wireframe color",
|
|
6139
|
+
size: 32, // vec4 colour + vec2 viewport + thickness + pad
|
|
6140
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6141
|
+
});
|
|
6142
|
+
this.wireframeSeamUniformBuffer = this.device.createBuffer({
|
|
6143
|
+
label: "wireframe color (material borders)",
|
|
6144
|
+
size: 32,
|
|
6145
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6146
|
+
});
|
|
6147
|
+
this.wireframeHoverUniformBuffer = this.device.createBuffer({
|
|
6148
|
+
label: "wireframe color (hovered material)",
|
|
6149
|
+
size: 32,
|
|
6150
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6151
|
+
});
|
|
6152
|
+
const wireBg0 = this.device.createBindGroupLayout({
|
|
6153
|
+
label: "wireframe group 0 layout (camera + wire)",
|
|
6154
|
+
entries: [
|
|
6155
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
6156
|
+
// Both stages: the FS takes the colour, the VS takes the viewport and
|
|
6157
|
+
// the stroke width it extrudes each edge quad to.
|
|
6158
|
+
{
|
|
6159
|
+
binding: 1,
|
|
6160
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
6161
|
+
buffer: { type: "uniform" },
|
|
6162
|
+
},
|
|
6163
|
+
],
|
|
6164
|
+
});
|
|
6165
|
+
// Spelled out rather than mapped over a range: tests/bindings.test.mjs reads
|
|
6166
|
+
// these statically to check every bind group covers its layout, and a loop
|
|
6167
|
+
// hides the bindings from it.
|
|
6168
|
+
this.wireframeSkinLayout = this.device.createBindGroupLayout({
|
|
6169
|
+
label: "wireframe group 1 layout (mesh + skin)",
|
|
6170
|
+
entries: [
|
|
6171
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6172
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6173
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6174
|
+
{ binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6175
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6176
|
+
],
|
|
6177
|
+
});
|
|
6178
|
+
const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL });
|
|
6179
|
+
this.wireframePipeline = this.device.createRenderPipeline({
|
|
6180
|
+
label: "wireframe pipeline",
|
|
6181
|
+
layout: this.device.createPipelineLayout({
|
|
6182
|
+
label: "wireframe pipeline layout",
|
|
6183
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
6184
|
+
}),
|
|
6185
|
+
// No vertex stream: an edge quad's corners come from two different model
|
|
6186
|
+
// vertices, so the mesh is read through storage instead.
|
|
6187
|
+
vertex: { module: wireShader, entryPoint: "vs" },
|
|
6188
|
+
fragment: {
|
|
6189
|
+
module: wireShader,
|
|
6190
|
+
entryPoint: "fs",
|
|
6191
|
+
targets: [
|
|
6192
|
+
{
|
|
6193
|
+
format: this.presentationFormat,
|
|
6194
|
+
blend: {
|
|
6195
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6196
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6197
|
+
},
|
|
6198
|
+
},
|
|
6199
|
+
],
|
|
6200
|
+
},
|
|
6201
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6202
|
+
// Depth-TESTED but not written: the mesh is a haze the rig reads against,
|
|
6203
|
+
// so a bone behind a triangle must not be punched out by it.
|
|
6204
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
6205
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
6206
|
+
});
|
|
6207
|
+
// The mesh's own depth, so the wireframe can be occluded by the body it
|
|
6208
|
+
// belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
|
|
6209
|
+
// three's and Babylon's wireframe materials all depth-test, and X-ray is a
|
|
6210
|
+
// toggle beside them. Seeing both walls of a 30k-triangle body at once is
|
|
6211
|
+
// moire, not information.
|
|
6212
|
+
//
|
|
6213
|
+
// It writes depth and nothing else — but it still DECLARES the colour
|
|
6214
|
+
// target, at writeMask 0. A pipeline's attachment state has to match its
|
|
6215
|
+
// pass's, and a pass with a colour attachment will not take a pipeline that
|
|
6216
|
+
// has none. Same trick the scene's own depth prepass uses.
|
|
6217
|
+
//
|
|
6218
|
+
// Its own pass rather than the scene's, because the scene's depth is
|
|
6219
|
+
// multisampled and discarded before the composite.
|
|
6220
|
+
this.wireframeDepthPipeline = this.device.createRenderPipeline({
|
|
6221
|
+
label: "wireframe depth prepass pipeline",
|
|
6222
|
+
layout: this.device.createPipelineLayout({
|
|
6223
|
+
label: "wireframe depth prepass layout",
|
|
6224
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
6225
|
+
}),
|
|
6226
|
+
vertex: {
|
|
6227
|
+
module: wireShader,
|
|
6228
|
+
entryPoint: "vsDepth",
|
|
6229
|
+
buffers: [
|
|
6230
|
+
{ arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }] },
|
|
6231
|
+
{ arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" }] },
|
|
6232
|
+
{ arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" }] },
|
|
6233
|
+
],
|
|
6234
|
+
},
|
|
6235
|
+
fragment: {
|
|
6236
|
+
module: wireShader,
|
|
6237
|
+
entryPoint: "fs",
|
|
6238
|
+
targets: [{ format: this.presentationFormat, writeMask: 0 }],
|
|
6239
|
+
},
|
|
6240
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6241
|
+
depthStencil: {
|
|
6242
|
+
format: "depth24plus",
|
|
6243
|
+
depthWriteEnabled: true,
|
|
6244
|
+
depthCompare: this.depthAhead,
|
|
6245
|
+
// The wireframe lies exactly ON the surface this writes, so every edge
|
|
6246
|
+
// ties with its own triangles and loses wherever rounding goes the wrong
|
|
6247
|
+
// way — lines that break up and shift as the camera turns. Push the
|
|
6248
|
+
// solid mesh back so the edges win their own ties. The slope term is
|
|
6249
|
+
// what handles a surface seen at a grazing angle, where a pixel spans
|
|
6250
|
+
// far more depth than a constant bias can cover.
|
|
6251
|
+
//
|
|
6252
|
+
// SIGNED BY CONVENTION, as the outline hulls are: bias adds to the depth
|
|
6253
|
+
// VALUE, and reversed-Z inverts what a larger value means.
|
|
6254
|
+
depthBias: this.reversedZ ? -64 : 64,
|
|
6255
|
+
depthBiasSlopeScale: this.reversedZ ? -2 : 2,
|
|
6256
|
+
depthBiasClamp: 0,
|
|
6257
|
+
},
|
|
6258
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
6259
|
+
});
|
|
6260
|
+
this.wireframeBindGroup = this.device.createBindGroup({
|
|
6261
|
+
label: "wireframe bind group",
|
|
6262
|
+
layout: wireBg0,
|
|
6263
|
+
entries: [
|
|
6264
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6265
|
+
{ binding: 1, resource: { buffer: this.wireframeUniformBuffer } },
|
|
6266
|
+
],
|
|
6267
|
+
});
|
|
6268
|
+
this.wireframeSeamBindGroup = this.device.createBindGroup({
|
|
6269
|
+
label: "wireframe bind group (material borders)",
|
|
6270
|
+
layout: wireBg0,
|
|
6271
|
+
entries: [
|
|
6272
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6273
|
+
{ binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
|
|
6274
|
+
],
|
|
6275
|
+
});
|
|
6276
|
+
this.wireframeHoverBindGroup = this.device.createBindGroup({
|
|
6277
|
+
label: "wireframe bind group (hovered material)",
|
|
6278
|
+
layout: wireBg0,
|
|
6279
|
+
entries: [
|
|
6280
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6281
|
+
{ binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
|
|
6282
|
+
],
|
|
6283
|
+
});
|
|
6284
|
+
this.overlayPassDescriptor = {
|
|
6285
|
+
label: "overlay pass",
|
|
6286
|
+
timestampWrites: this.stamps("overlay"),
|
|
6287
|
+
colorAttachments: [
|
|
6288
|
+
{
|
|
6289
|
+
view: undefined,
|
|
6290
|
+
resolveTarget: undefined,
|
|
6291
|
+
// Transparent, because this layer is composited over the frame rather
|
|
6292
|
+
// than drawn into it. storeOp discard keeps the 4 samples in tile
|
|
6293
|
+
// memory on a TBDR part — only the resolve reaches RAM.
|
|
6294
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
6295
|
+
loadOp: "clear",
|
|
6296
|
+
storeOp: "discard",
|
|
6297
|
+
},
|
|
6298
|
+
],
|
|
6299
|
+
depthStencilAttachment: {
|
|
6300
|
+
view: undefined,
|
|
6301
|
+
depthClearValue: this.depthClear,
|
|
6302
|
+
depthLoadOp: "clear",
|
|
6303
|
+
depthStoreOp: "discard",
|
|
6304
|
+
},
|
|
6305
|
+
};
|
|
6306
|
+
}
|
|
5920
6307
|
// Step 4: Create camera and uniform buffer
|
|
5921
6308
|
setupCamera() {
|
|
5922
6309
|
this.cameraUniformBuffer = this.device.createBuffer({
|
|
@@ -5976,21 +6363,60 @@ export class Engine {
|
|
|
5976
6363
|
this.cameraFollowSmoothing = Math.max(0, smoothing ?? 0);
|
|
5977
6364
|
this.cameraFollowSeeded = false;
|
|
5978
6365
|
}
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
6366
|
+
/** The one place that decides who is holding the camera. An external pose
|
|
6367
|
+
* wins; a track drives when it is loaded and enabled; otherwise orbit. */
|
|
6368
|
+
refreshCameraDrive() {
|
|
6369
|
+
this.camera.setVmdDriven(this.cameraPoseOverride !== null || (this.cameraVmdEnabled && this.cameraAnimation !== null));
|
|
6370
|
+
}
|
|
6371
|
+
/**
|
|
6372
|
+
* Aim the camera from outside — a solved match-move, a saved shot, a rig
|
|
6373
|
+
* driving the view from the host's own clock.
|
|
6374
|
+
*
|
|
6375
|
+
* The exact partner of `getCameraPose`, and the same five channels: the shot
|
|
6376
|
+
* as MMD states it, roll included. Orbit cannot express roll, so this is the
|
|
6377
|
+
* only way a tilted camera reaches the engine.
|
|
6378
|
+
*
|
|
6379
|
+
* Reapplied every frame while set, which makes it authoritative rather than
|
|
6380
|
+
* advisory — nothing the transport or a loaded track does moves it. Pass null
|
|
6381
|
+
* to release, and whatever was driving before takes the camera back.
|
|
6382
|
+
*/
|
|
6383
|
+
setCameraPose(pose) {
|
|
6384
|
+
if (pose) {
|
|
6385
|
+
// Copied, not held: a host reusing one object per frame is the normal
|
|
6386
|
+
// shape of a track, and storing the reference would make the value we
|
|
6387
|
+
// reapply depend on when the caller next touched theirs.
|
|
6388
|
+
this.cameraPoseOverride = {
|
|
6389
|
+
target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
|
|
6390
|
+
rotation: new Vec3(pose.rotation.x, pose.rotation.y, pose.rotation.z),
|
|
6391
|
+
distance: pose.distance,
|
|
6392
|
+
fov: pose.fov,
|
|
6393
|
+
};
|
|
6394
|
+
}
|
|
6395
|
+
else {
|
|
6396
|
+
this.cameraPoseOverride = null;
|
|
6397
|
+
}
|
|
6398
|
+
this.refreshCameraDrive();
|
|
6399
|
+
if (this.cameraPoseOverride)
|
|
6400
|
+
this.camera.setVmdPose(this.cameraPoseOverride);
|
|
6401
|
+
}
|
|
6402
|
+
/** The pose currently forced from outside, or null when nothing is. */
|
|
6403
|
+
getCameraPoseOverride() {
|
|
6404
|
+
return this.cameraPoseOverride;
|
|
6405
|
+
}
|
|
5982
6406
|
/** Load a camera VMD (dedicated camera file, or any VMD's camera block) and drive the shot
|
|
5983
6407
|
* from it. Default-on once a non-empty track loads; toggle with setCameraVmdEnabled. */
|
|
5984
6408
|
async loadCameraVmd(url) {
|
|
5985
6409
|
const frames = await VMDLoader.loadCamera(url);
|
|
5986
6410
|
this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null;
|
|
5987
|
-
this.
|
|
6411
|
+
this.cameraVmdEnabled = true;
|
|
6412
|
+
this.refreshCameraDrive();
|
|
5988
6413
|
}
|
|
5989
6414
|
/** Load a camera VMD from an already-fetched buffer (e.g. a File the user dropped). */
|
|
5990
6415
|
loadCameraVmdFromBuffer(buffer) {
|
|
5991
6416
|
const frames = VMDLoader.loadCameraFromBuffer(buffer);
|
|
5992
6417
|
this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null;
|
|
5993
|
-
this.
|
|
6418
|
+
this.cameraVmdEnabled = true;
|
|
6419
|
+
this.refreshCameraDrive();
|
|
5994
6420
|
}
|
|
5995
6421
|
/**
|
|
5996
6422
|
* Drive the shot from camera keyframes built in JS — the camera's answer to
|
|
@@ -6007,7 +6433,8 @@ export class Engine {
|
|
|
6007
6433
|
*/
|
|
6008
6434
|
loadCameraClip(frames) {
|
|
6009
6435
|
this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null;
|
|
6010
|
-
this.
|
|
6436
|
+
this.cameraVmdEnabled = true;
|
|
6437
|
+
this.refreshCameraDrive();
|
|
6011
6438
|
}
|
|
6012
6439
|
/** The loaded camera track as editable keyframes, or [] with none loaded.
|
|
6013
6440
|
* Copies — mutating them does not reach the track being sampled. */
|
|
@@ -6025,7 +6452,8 @@ export class Engine {
|
|
|
6025
6452
|
}
|
|
6026
6453
|
/** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
|
|
6027
6454
|
setCameraVmdEnabled(enabled) {
|
|
6028
|
-
this.
|
|
6455
|
+
this.cameraVmdEnabled = enabled;
|
|
6456
|
+
this.refreshCameraDrive();
|
|
6029
6457
|
if (!enabled && this.cameraTargetModel) {
|
|
6030
6458
|
// Follow resumes with a clean snap to bone + configured offset — one
|
|
6031
6459
|
// predictable cut to the scene's framing, no easing from the shot.
|
|
@@ -6283,7 +6711,7 @@ export class Engine {
|
|
|
6283
6711
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
6284
6712
|
clearCameraVmd() {
|
|
6285
6713
|
this.cameraAnimation = null;
|
|
6286
|
-
this.
|
|
6714
|
+
this.refreshCameraDrive();
|
|
6287
6715
|
}
|
|
6288
6716
|
/**
|
|
6289
6717
|
* THE TRANSPORT'S CLOCK — where the scene is in its own playback.
|
|
@@ -6326,6 +6754,28 @@ export class Engine {
|
|
|
6326
6754
|
getCameraPosition() {
|
|
6327
6755
|
return this.camera.getPosition();
|
|
6328
6756
|
}
|
|
6757
|
+
/**
|
|
6758
|
+
* The live orbit, read in ONE call.
|
|
6759
|
+
*
|
|
6760
|
+
* A host that stores the shot has to be able to ask where the camera actually
|
|
6761
|
+
* IS, because a drag on the canvas moves this and nothing else — and a
|
|
6762
|
+
* document that never asks will happily write back the angle it last set,
|
|
6763
|
+
* discarding whatever the person just did with the mouse. Reading the four
|
|
6764
|
+
* separately invites a torn set across a frame boundary; this cannot tear.
|
|
6765
|
+
*
|
|
6766
|
+
* `target` is the orbit's own centre. While the engine is following a bone
|
|
6767
|
+
* that point rides the bone, so a caller storing a FOLLOW offset must keep its
|
|
6768
|
+
* own and take only the angles from here.
|
|
6769
|
+
*/
|
|
6770
|
+
getCameraOrbit() {
|
|
6771
|
+
const c = this.camera;
|
|
6772
|
+
return {
|
|
6773
|
+
alpha: c.alpha,
|
|
6774
|
+
beta: c.beta,
|
|
6775
|
+
distance: c.radius,
|
|
6776
|
+
target: new Vec3(c.target.x, c.target.y, c.target.z),
|
|
6777
|
+
};
|
|
6778
|
+
}
|
|
6329
6779
|
getCameraDistance() {
|
|
6330
6780
|
return this.camera.radius;
|
|
6331
6781
|
}
|
|
@@ -6344,6 +6794,21 @@ export class Engine {
|
|
|
6344
6794
|
setCameraBeta(b) {
|
|
6345
6795
|
this.camera.beta = b;
|
|
6346
6796
|
}
|
|
6797
|
+
/**
|
|
6798
|
+
* Roll the orbiting shot, radians — the lean alpha and beta cannot state.
|
|
6799
|
+
*
|
|
6800
|
+
* Tips the up vector about the eye→target line, so the camera stays exactly
|
|
6801
|
+
* where it was and keeps looking at exactly what it looked at. Everything the
|
|
6802
|
+
* orbit does still works underneath it: following a bone, dragging, zooming.
|
|
6803
|
+
*
|
|
6804
|
+
* A camera VMD carries its own roll and ignores this while it drives.
|
|
6805
|
+
*/
|
|
6806
|
+
setCameraRoll(r) {
|
|
6807
|
+
this.camera.roll = r;
|
|
6808
|
+
}
|
|
6809
|
+
getCameraRoll() {
|
|
6810
|
+
return this.camera.roll;
|
|
6811
|
+
}
|
|
6347
6812
|
/** Vertical field of view in radians (default π/4). While a camera VMD
|
|
6348
6813
|
* drives the view it animates fov itself; the orbit value set here is
|
|
6349
6814
|
* restored when the VMD releases the camera. */
|
|
@@ -6452,6 +6917,14 @@ export class Engine {
|
|
|
6452
6917
|
return this.sun;
|
|
6453
6918
|
}
|
|
6454
6919
|
addGround(options) {
|
|
6920
|
+
// NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
|
|
6921
|
+
// deferred a frame by useSceneSync's own rAF batching, and a hot reload
|
|
6922
|
+
// that swaps in a new (uninitialized) engine between the schedule and the
|
|
6923
|
+
// callback lands this on a `device` that has not been assigned yet. The
|
|
6924
|
+
// effect that scheduled it re-fires once the new engine is ready, so
|
|
6925
|
+
// dropping this one loses nothing.
|
|
6926
|
+
if (!this.device)
|
|
6927
|
+
return;
|
|
6455
6928
|
const opts = {
|
|
6456
6929
|
width: 160,
|
|
6457
6930
|
height: 160,
|
|
@@ -6467,6 +6940,7 @@ export class Engine {
|
|
|
6467
6940
|
opacity: 1.0,
|
|
6468
6941
|
mirror: false,
|
|
6469
6942
|
mirrorBlur: 0,
|
|
6943
|
+
shadowSoftness: 0,
|
|
6470
6944
|
...options,
|
|
6471
6945
|
};
|
|
6472
6946
|
this.createGroundGeometry(opts.width, opts.height);
|
|
@@ -6542,7 +7016,15 @@ export class Engine {
|
|
|
6542
7016
|
getLightCount() {
|
|
6543
7017
|
return this.lightHeader[0];
|
|
6544
7018
|
}
|
|
7019
|
+
/** Guarded, unlike most private writers here, because its callers are not:
|
|
7020
|
+
* setWorld/setSun are public and can be called before init() finishes
|
|
7021
|
+
* assigning `device` — a scene-settings effect firing on mount races the
|
|
7022
|
+
* engine's own async setup. The state write still lands immediately either
|
|
7023
|
+
* way; only the GPU upload defers, and setupLighting's own writeWorld/
|
|
7024
|
+
* writeSun calls during init pick up whatever was already set. */
|
|
6545
7025
|
updateLightBuffer() {
|
|
7026
|
+
if (!this.device || !this.lightUniformBuffer)
|
|
7027
|
+
return;
|
|
6546
7028
|
this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData);
|
|
6547
7029
|
}
|
|
6548
7030
|
getStats() {
|
|
@@ -6586,6 +7068,19 @@ export class Engine {
|
|
|
6586
7068
|
this.canvas.removeEventListener("dblclick", this.handleCanvasDoubleClick);
|
|
6587
7069
|
this.canvas.removeEventListener("touchend", this.handleCanvasTouch);
|
|
6588
7070
|
}
|
|
7071
|
+
this.overlayDepthTexture?.destroy();
|
|
7072
|
+
this.overlayDepthTexture = null;
|
|
7073
|
+
this.overlayMsaaTexture?.destroy();
|
|
7074
|
+
this.overlayMsaaTexture = null;
|
|
7075
|
+
this.overlayResolveTexture?.destroy();
|
|
7076
|
+
this.overlayResolveTexture = null;
|
|
7077
|
+
this.overlayInstanceBuffer?.destroy();
|
|
7078
|
+
this.overlayInstanceBuffer = null;
|
|
7079
|
+
for (const inst of this.modelInstances.values()) {
|
|
7080
|
+
for (const edges of inst.wireEdges.values())
|
|
7081
|
+
edges?.buffer.destroy();
|
|
7082
|
+
inst.wireEdges.clear();
|
|
7083
|
+
}
|
|
6589
7084
|
// Remove gizmo drag listeners
|
|
6590
7085
|
this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true });
|
|
6591
7086
|
window.removeEventListener("mousemove", this.handleGizmoMouseMove);
|
|
@@ -6981,6 +7476,16 @@ export class Engine {
|
|
|
6981
7476
|
setSelectedMaterial(modelName, materialName) {
|
|
6982
7477
|
this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null;
|
|
6983
7478
|
}
|
|
7479
|
+
/** Show the transform gizmo on the selected bone. On by default. */
|
|
7480
|
+
setGizmoEnabled(on) {
|
|
7481
|
+
this.gizmoEnabled = on;
|
|
7482
|
+
}
|
|
7483
|
+
/** A pointer-driven preview of a pick, not a pick itself — see pickMaterial
|
|
7484
|
+
* for the click that actually selects one. Cheap: a field write, nothing
|
|
7485
|
+
* rebuilt, safe to call every frame the pointer is over the canvas. */
|
|
7486
|
+
setHoveredMaterial(modelName, materialName) {
|
|
7487
|
+
this.hoverMaterial = modelName && materialName ? { modelName, materialName } : null;
|
|
7488
|
+
}
|
|
6984
7489
|
setSelectedBone(modelName, boneName) {
|
|
6985
7490
|
if (!modelName || !boneName) {
|
|
6986
7491
|
this.selectedBone = null;
|
|
@@ -6994,6 +7499,235 @@ export class Engine {
|
|
|
6994
7499
|
const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName);
|
|
6995
7500
|
this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null;
|
|
6996
7501
|
}
|
|
7502
|
+
// ─── Editor overlays ───────────────────────────────────────────────
|
|
7503
|
+
//
|
|
7504
|
+
// Two ways in. setOverlay takes a list and draws exactly that list, so a host
|
|
7505
|
+
// can paste one in, hand one to a test, or print one back out. The three live
|
|
7506
|
+
// layers name a model instead and are rebuilt from its pose every frame,
|
|
7507
|
+
// which is the only way a skeleton overlay can be right on an animated model.
|
|
7508
|
+
/**
|
|
7509
|
+
* Replace one named layer of overlay primitives. World space, drawn as given
|
|
7510
|
+
* until it is replaced. An empty list removes the layer.
|
|
7511
|
+
*/
|
|
7512
|
+
setOverlay(layer, primitives) {
|
|
7513
|
+
if (primitives.length === 0)
|
|
7514
|
+
this.overlayLayers.delete(layer);
|
|
7515
|
+
else
|
|
7516
|
+
this.overlayLayers.set(layer, primitives);
|
|
7517
|
+
}
|
|
7518
|
+
/** Drop one named layer, or every one. Live layers keep drawing. */
|
|
7519
|
+
clearOverlay(layer) {
|
|
7520
|
+
if (layer === undefined)
|
|
7521
|
+
this.overlayLayers.clear();
|
|
7522
|
+
else
|
|
7523
|
+
this.overlayLayers.delete(layer);
|
|
7524
|
+
}
|
|
7525
|
+
/**
|
|
7526
|
+
* The bone whose marker is nearest a point on the canvas, or null.
|
|
7527
|
+
*
|
|
7528
|
+
* On the CPU, and exact. A few hundred bones with known world positions is a
|
|
7529
|
+
* loop, not a render pass — and having the answer synchronously is what makes
|
|
7530
|
+
* cycling through overlapping bones possible at all. Only VERTICES justify GPU
|
|
7531
|
+
* picking, at tens of thousands.
|
|
7532
|
+
*
|
|
7533
|
+
* `x`/`y` are CSS pixels relative to the canvas, which is what a MouseEvent
|
|
7534
|
+
* gives once getBoundingClientRect is subtracted.
|
|
7535
|
+
*
|
|
7536
|
+
* It projects boneMarkerPositions, the same points the overlay draws markers
|
|
7537
|
+
* at, so the hit box cannot drift away from the circle you are aiming at.
|
|
7538
|
+
*/
|
|
7539
|
+
pickBone(x, y, options = {}) {
|
|
7540
|
+
if (!this.camera)
|
|
7541
|
+
return null;
|
|
7542
|
+
const width = this.canvas.clientWidth;
|
|
7543
|
+
const height = this.canvas.clientHeight;
|
|
7544
|
+
if (width <= 0 || height <= 0)
|
|
7545
|
+
return null;
|
|
7546
|
+
const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
|
|
7547
|
+
let best = null;
|
|
7548
|
+
let bestDist = options.radiusPx ?? 14;
|
|
7549
|
+
let bestDepth = Infinity;
|
|
7550
|
+
for (const inst of this.modelInstances.values()) {
|
|
7551
|
+
if (options.modelName !== undefined && inst.name !== options.modelName)
|
|
7552
|
+
continue;
|
|
7553
|
+
if (inst.isStage || inst.isPlane)
|
|
7554
|
+
continue;
|
|
7555
|
+
const bones = inst.model.getSkeleton().bones;
|
|
7556
|
+
this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch);
|
|
7557
|
+
const pos = this.bonePickScratch;
|
|
7558
|
+
for (let i = 0; i < bones.length; i++) {
|
|
7559
|
+
const px = pos[i * 3];
|
|
7560
|
+
const py = pos[i * 3 + 1];
|
|
7561
|
+
const pz = pos[i * 3 + 2];
|
|
7562
|
+
const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
|
|
7563
|
+
if (cw <= 1e-6)
|
|
7564
|
+
continue; // behind the camera
|
|
7565
|
+
const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
|
|
7566
|
+
const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
|
|
7567
|
+
const sx = ((cx / cw) * 0.5 + 0.5) * width;
|
|
7568
|
+
const sy = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
|
|
7569
|
+
const d = Math.hypot(sx - x, sy - y);
|
|
7570
|
+
if (d > bestDist)
|
|
7571
|
+
continue;
|
|
7572
|
+
// Within a couple of pixels the two are the same click, and MMD stacks
|
|
7573
|
+
// control bones on one point — so the nearer bone takes it.
|
|
7574
|
+
if (d < bestDist - 2 || cw < bestDepth) {
|
|
7575
|
+
best = { modelName: inst.name, boneName: bones[i].name, boneIndex: i };
|
|
7576
|
+
bestDist = d;
|
|
7577
|
+
bestDepth = cw;
|
|
7578
|
+
}
|
|
7579
|
+
}
|
|
7580
|
+
}
|
|
7581
|
+
return best;
|
|
7582
|
+
}
|
|
7583
|
+
/**
|
|
7584
|
+
* The material under a point on the canvas, or null for a miss.
|
|
7585
|
+
*
|
|
7586
|
+
* On the CPU, like pickBone, and for the same reason: a click (or a hover) is
|
|
7587
|
+
* rare and an answer you have synchronously is worth more than one that
|
|
7588
|
+
* arrives a frame later. Tens of thousands of triangles is a loop that costs
|
|
7589
|
+
* a few milliseconds ONCE, against a GPU id pass that costs an attachment and
|
|
7590
|
+
* a readback every frame whether anyone is pointing at the model or not.
|
|
7591
|
+
*
|
|
7592
|
+
* Skinned on the CPU with getSkinMatrices — the same matrices the vertex
|
|
7593
|
+
* shader uses — so the pick lands on the POSED mesh. Bind-pose geometry would
|
|
7594
|
+
* be right on a T-posed model and wrong on every animated one, which is
|
|
7595
|
+
* exactly when someone is clicking around a costume.
|
|
7596
|
+
*
|
|
7597
|
+
* Morph offsets are NOT applied: they move a face, never move it into another
|
|
7598
|
+
* material, and reading them back per click would cost more than the pick.
|
|
7599
|
+
*
|
|
7600
|
+
* `x`/`y` are CSS pixels relative to the canvas, as pickBone takes them.
|
|
7601
|
+
*/
|
|
7602
|
+
pickMaterial(x, y, options = {}) {
|
|
7603
|
+
if (!this.camera)
|
|
7604
|
+
return null;
|
|
7605
|
+
const width = this.canvas.clientWidth;
|
|
7606
|
+
const height = this.canvas.clientHeight;
|
|
7607
|
+
if (width <= 0 || height <= 0)
|
|
7608
|
+
return null;
|
|
7609
|
+
const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
|
|
7610
|
+
let best = null;
|
|
7611
|
+
let bestDepth = Infinity;
|
|
7612
|
+
for (const inst of this.modelInstances.values()) {
|
|
7613
|
+
if (options.modelName !== undefined && inst.name !== options.modelName)
|
|
7614
|
+
continue;
|
|
7615
|
+
if (inst.isStage || inst.isPlane)
|
|
7616
|
+
continue;
|
|
7617
|
+
const model = inst.model;
|
|
7618
|
+
const { positions } = model.getGeometry();
|
|
7619
|
+
const count = positions.length / 3;
|
|
7620
|
+
const { joints, weights } = model.getSkinning();
|
|
7621
|
+
const skin = model.getSkinMatrices();
|
|
7622
|
+
// Project every vertex ONCE into screen x, y and clip w. The triangle
|
|
7623
|
+
// loop then reads three of these rather than re-skinning shared vertices
|
|
7624
|
+
// — a closed mesh uses each vertex about six times.
|
|
7625
|
+
if (!this.materialPickScratch || this.materialPickScratch.length !== count * 3) {
|
|
7626
|
+
this.materialPickScratch = new Float32Array(count * 3);
|
|
7627
|
+
}
|
|
7628
|
+
const proj = this.materialPickScratch;
|
|
7629
|
+
for (let v = 0; v < count; v++) {
|
|
7630
|
+
const bx = positions[v * 3];
|
|
7631
|
+
const by = positions[v * 3 + 1];
|
|
7632
|
+
const bz = positions[v * 3 + 2];
|
|
7633
|
+
let px = 0;
|
|
7634
|
+
let py = 0;
|
|
7635
|
+
let pz = 0;
|
|
7636
|
+
for (let k = 0; k < 4; k++) {
|
|
7637
|
+
const w = weights[v * 4 + k] / 255;
|
|
7638
|
+
if (w === 0)
|
|
7639
|
+
continue;
|
|
7640
|
+
const m = joints[v * 4 + k] * 16;
|
|
7641
|
+
px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12]);
|
|
7642
|
+
py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13]);
|
|
7643
|
+
pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14]);
|
|
7644
|
+
}
|
|
7645
|
+
const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
|
|
7646
|
+
proj[v * 3 + 2] = cw;
|
|
7647
|
+
if (cw <= 1e-6)
|
|
7648
|
+
continue;
|
|
7649
|
+
const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
|
|
7650
|
+
const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
|
|
7651
|
+
proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width;
|
|
7652
|
+
proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
|
|
7653
|
+
}
|
|
7654
|
+
// Point-in-triangle in SCREEN space, nearest w wins. The same projection
|
|
7655
|
+
// pickBone uses, so the two agree about where things are, and it needs no
|
|
7656
|
+
// inverse view-projection to build a ray from.
|
|
7657
|
+
const indices = model.getIndices();
|
|
7658
|
+
const materials = model.getMaterials();
|
|
7659
|
+
let m = 0;
|
|
7660
|
+
let matEnd = materials.length > 0 ? materials[0].vertexCount : indices.length;
|
|
7661
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
7662
|
+
while (i >= matEnd && m + 1 < materials.length) {
|
|
7663
|
+
m++;
|
|
7664
|
+
matEnd += materials[m].vertexCount;
|
|
7665
|
+
}
|
|
7666
|
+
const a = indices[i] * 3;
|
|
7667
|
+
const b = indices[i + 1] * 3;
|
|
7668
|
+
const c = indices[i + 2] * 3;
|
|
7669
|
+
if (proj[a + 2] <= 1e-6 || proj[b + 2] <= 1e-6 || proj[c + 2] <= 1e-6)
|
|
7670
|
+
continue;
|
|
7671
|
+
const ax = proj[a];
|
|
7672
|
+
const ay = proj[a + 1];
|
|
7673
|
+
const bx = proj[b];
|
|
7674
|
+
const by = proj[b + 1];
|
|
7675
|
+
const cx2 = proj[c];
|
|
7676
|
+
const cy2 = proj[c + 1];
|
|
7677
|
+
// Barycentric sign test, both windings: PMX faces are one winding but a
|
|
7678
|
+
// double-sided material is legitimately seen from behind.
|
|
7679
|
+
const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by);
|
|
7680
|
+
const d2 = (x - cx2) * (by - cy2) - (bx - cx2) * (y - cy2);
|
|
7681
|
+
const d3 = (x - ax) * (cy2 - ay) - (cx2 - ax) * (y - ay);
|
|
7682
|
+
const neg = d1 < 0 || d2 < 0 || d3 < 0;
|
|
7683
|
+
const pos = d1 > 0 || d2 > 0 || d3 > 0;
|
|
7684
|
+
if (neg && pos)
|
|
7685
|
+
continue;
|
|
7686
|
+
const depth = (proj[a + 2] + proj[b + 2] + proj[c + 2]) / 3;
|
|
7687
|
+
if (depth >= bestDepth)
|
|
7688
|
+
continue;
|
|
7689
|
+
bestDepth = depth;
|
|
7690
|
+
best = { modelName: inst.name, materialName: materials[m].name, materialIndex: m };
|
|
7691
|
+
}
|
|
7692
|
+
}
|
|
7693
|
+
return best;
|
|
7694
|
+
}
|
|
7695
|
+
/** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
|
|
7696
|
+
setBoneOverlay(modelName, options = {}) {
|
|
7697
|
+
this.overlayBones = modelName ? { modelName, options } : null;
|
|
7698
|
+
}
|
|
7699
|
+
/** Draw every rigidbody of `modelName` where the simulation has it, rebuilt
|
|
7700
|
+
* each frame. Null off. */
|
|
7701
|
+
setRigidbodyOverlay(modelName, options = {}) {
|
|
7702
|
+
this.overlayBodies = modelName ? { modelName, options } : null;
|
|
7703
|
+
}
|
|
7704
|
+
/** Draw a cross per joint of `modelName` plus dashed lines to the bodies it
|
|
7705
|
+
* holds together, rebuilt each frame. Null off. */
|
|
7706
|
+
setJointOverlay(modelName, options = {}) {
|
|
7707
|
+
this.overlayJoints = modelName ? { modelName, options } : null;
|
|
7708
|
+
}
|
|
7709
|
+
/**
|
|
7710
|
+
* Draw `modelName`'s mesh as a wireframe — its vertices and its topology.
|
|
7711
|
+
*
|
|
7712
|
+
* Skinned on the GPU from the model's own vertex buffer and skin matrices, so
|
|
7713
|
+
* it sits on the POSED mesh. The loader's CPU-side positions are bind pose: a
|
|
7714
|
+
* wireframe built from those looks right on a T-posed model and slides off
|
|
7715
|
+
* every animated one, which is exactly the state a user is in while looking at
|
|
7716
|
+
* weights.
|
|
7717
|
+
*
|
|
7718
|
+
* The edge list is deduplicated and built once, on the first frame this is on.
|
|
7719
|
+
*
|
|
7720
|
+
* `material` narrows the wireframe to one material's faces. The mesh still
|
|
7721
|
+
* writes depth in full, so the material reads as part of the body rather than
|
|
7722
|
+
* as a shell floating in front of it — which is the point of scoping it: you
|
|
7723
|
+
* are asking where this material's faces ARE, and an answer that ignores the
|
|
7724
|
+
* torso in front of them is not one.
|
|
7725
|
+
*/
|
|
7726
|
+
setVertexOverlay(modelName, options = {}) {
|
|
7727
|
+
this.overlayVertices = modelName
|
|
7728
|
+
? { modelName, xray: options.xray ?? false, material: options.material ?? null }
|
|
7729
|
+
: null;
|
|
7730
|
+
}
|
|
6997
7731
|
// Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
|
|
6998
7732
|
// (the group's buffer when grouped, or the shared zero buffer when ungrouped).
|
|
6999
7733
|
/** A group's uniform buffer and its maps have the same lifetime — freeing one
|
|
@@ -7080,8 +7814,26 @@ export class Engine {
|
|
|
7080
7814
|
getIKEnabled() {
|
|
7081
7815
|
return this.ikEnabled;
|
|
7082
7816
|
}
|
|
7817
|
+
/**
|
|
7818
|
+
* Run the solver, or stop it.
|
|
7819
|
+
*
|
|
7820
|
+
* Turning it OFF snaps every body back onto its bone. Merely halting the step
|
|
7821
|
+
* leaves hair and skirts hanging wherever the simulation happened to be — a
|
|
7822
|
+
* pose nothing in the document describes, which is the opposite of what "off"
|
|
7823
|
+
* is asked for: you switch physics off to see what the RIG does, and a frozen
|
|
7824
|
+
* mid-swing is still the solver's answer, just a stale one.
|
|
7825
|
+
*/
|
|
7083
7826
|
setPhysicsEnabled(enabled) {
|
|
7827
|
+
if (this.physicsEnabled === enabled)
|
|
7828
|
+
return;
|
|
7084
7829
|
this.physicsEnabled = enabled;
|
|
7830
|
+
if (enabled)
|
|
7831
|
+
return;
|
|
7832
|
+
for (const inst of this.modelInstances.values()) {
|
|
7833
|
+
if (!inst.physics)
|
|
7834
|
+
continue;
|
|
7835
|
+
inst.physics.reset(inst.model.getWorldMatrices());
|
|
7836
|
+
}
|
|
7085
7837
|
}
|
|
7086
7838
|
getPhysicsEnabled() {
|
|
7087
7839
|
return this.physicsEnabled;
|
|
@@ -8124,13 +8876,15 @@ export class Engine {
|
|
|
8124
8876
|
const jointsBuffer = this.device.createBuffer({
|
|
8125
8877
|
label: `${name}: joints buffer`,
|
|
8126
8878
|
size: skinning.joints.byteLength,
|
|
8127
|
-
|
|
8879
|
+
// STORAGE so the wireframe overlay can skin from it: its quads read two
|
|
8880
|
+
// different model vertices per corner, which no vertex stream can supply.
|
|
8881
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
|
|
8128
8882
|
});
|
|
8129
8883
|
this.device.queue.writeBuffer(jointsBuffer, 0, skinning.joints.buffer, skinning.joints.byteOffset, skinning.joints.byteLength);
|
|
8130
8884
|
const weightsBuffer = this.device.createBuffer({
|
|
8131
8885
|
label: `${name}: weights buffer`,
|
|
8132
8886
|
size: skinning.weights.byteLength,
|
|
8133
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
8887
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
|
|
8134
8888
|
});
|
|
8135
8889
|
this.device.queue.writeBuffer(weightsBuffer, 0, skinning.weights.buffer, skinning.weights.byteOffset, skinning.weights.byteLength);
|
|
8136
8890
|
const skinMatrixBuffer = this.device.createBuffer({
|
|
@@ -8217,6 +8971,7 @@ export class Engine {
|
|
|
8217
8971
|
jointsBuffer,
|
|
8218
8972
|
weightsBuffer,
|
|
8219
8973
|
skinMatrixBuffer,
|
|
8974
|
+
wireEdges: new Map(),
|
|
8220
8975
|
drawCalls: [],
|
|
8221
8976
|
shadowDrawCalls: [],
|
|
8222
8977
|
shadowBindGroups,
|
|
@@ -8379,7 +9134,7 @@ export class Engine {
|
|
|
8379
9134
|
this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
|
|
8380
9135
|
}
|
|
8381
9136
|
createShadowGroundResources(opts) {
|
|
8382
|
-
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, } = opts;
|
|
9137
|
+
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, shadowSoftness, } = opts;
|
|
8383
9138
|
// Shadow map is already created in setupPipelines()
|
|
8384
9139
|
// 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
|
|
8385
9140
|
// keeping the uniform vec4-aligned.
|
|
@@ -8404,6 +9159,9 @@ export class Engine {
|
|
|
8404
9159
|
this.groundMirror = gb[15];
|
|
8405
9160
|
gb[16] = Math.min(Math.max(mirrorBlur, 0), 1);
|
|
8406
9161
|
this.groundMirrorBlur = gb[16];
|
|
9162
|
+
// gb[18] — shadow edge softness. Was padding; the shader reads it as the
|
|
9163
|
+
// Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
|
|
9164
|
+
gb[18] = Math.min(Math.max(shadowSoftness, 0), 1);
|
|
8407
9165
|
// gb[17] — does the FAR cascade hold anything?
|
|
8408
9166
|
//
|
|
8409
9167
|
// It holds something only when a stage is loaded; that is what it exists for
|
|
@@ -9087,6 +9845,407 @@ export class Engine {
|
|
|
9087
9845
|
epass.draw(3);
|
|
9088
9846
|
epass.end();
|
|
9089
9847
|
}
|
|
9848
|
+
// Unique edges of the mesh, as a line-list index buffer. Each interior edge is
|
|
9849
|
+
// shared by two triangles, so deduplicating halves both the buffer and the
|
|
9850
|
+
// draw. Built once per model, on the first frame its wireframe is asked for.
|
|
9851
|
+
/** The index run `material` owns, or the whole list when it is null. Materials
|
|
9852
|
+
* are consecutive runs in declaration order, so the offset is a prefix sum —
|
|
9853
|
+
* the same walk the draw list does. Returns null for a name the model does
|
|
9854
|
+
* not have, which is what a stale selection looks like after a reload. */
|
|
9855
|
+
materialIndexRange(inst, material) {
|
|
9856
|
+
const indices = inst.model.getIndices();
|
|
9857
|
+
if (!material)
|
|
9858
|
+
return [0, indices.length];
|
|
9859
|
+
let offset = 0;
|
|
9860
|
+
for (const m of inst.model.getMaterials()) {
|
|
9861
|
+
if (m.name === material)
|
|
9862
|
+
return [offset, offset + m.vertexCount];
|
|
9863
|
+
offset += m.vertexCount;
|
|
9864
|
+
}
|
|
9865
|
+
return null;
|
|
9866
|
+
}
|
|
9867
|
+
/**
|
|
9868
|
+
* @param material one material's own edges, or null for the whole mesh
|
|
9869
|
+
* @param seams every material's OUTLINE instead — the borders between them
|
|
9870
|
+
*/
|
|
9871
|
+
ensureEdgeBuffer(inst, material, seams = false) {
|
|
9872
|
+
const key = material ?? (seams ? Engine.SEAM_KEY : "");
|
|
9873
|
+
if (inst.wireEdges.has(key))
|
|
9874
|
+
return inst.wireEdges.get(key) !== null;
|
|
9875
|
+
const indices = inst.model.getIndices();
|
|
9876
|
+
const vertexCount = inst.model.getGeometry().positions.length / 3;
|
|
9877
|
+
const edges = [];
|
|
9878
|
+
if (seams && !material) {
|
|
9879
|
+
// Inside one material's run an interior edge belongs to two triangles and
|
|
9880
|
+
// a border edge to one, so counting uses within the run and keeping the
|
|
9881
|
+
// singles gives exactly that material's outline.
|
|
9882
|
+
//
|
|
9883
|
+
// Per run, never over the whole mesh: an edge two materials share is
|
|
9884
|
+
// interior to the model and a border to both, and only the per-run count
|
|
9885
|
+
// can tell those two cases apart.
|
|
9886
|
+
const used = new Map();
|
|
9887
|
+
const seen = new Set();
|
|
9888
|
+
let offset = 0;
|
|
9889
|
+
for (const m of inst.model.getMaterials()) {
|
|
9890
|
+
const end = offset + m.vertexCount;
|
|
9891
|
+
used.clear();
|
|
9892
|
+
const bump = (a, b) => {
|
|
9893
|
+
const k = (a < b ? a : b) * vertexCount + (a < b ? b : a);
|
|
9894
|
+
used.set(k, (used.get(k) ?? 0) + 1);
|
|
9895
|
+
};
|
|
9896
|
+
for (let i = offset; i + 2 < end; i += 3) {
|
|
9897
|
+
bump(indices[i], indices[i + 1]);
|
|
9898
|
+
bump(indices[i + 1], indices[i + 2]);
|
|
9899
|
+
bump(indices[i + 2], indices[i]);
|
|
9900
|
+
}
|
|
9901
|
+
for (const [k, count] of used) {
|
|
9902
|
+
if (count !== 1 || seen.has(k))
|
|
9903
|
+
continue;
|
|
9904
|
+
seen.add(k);
|
|
9905
|
+
edges.push(Math.floor(k / vertexCount), k % vertexCount);
|
|
9906
|
+
}
|
|
9907
|
+
offset = end;
|
|
9908
|
+
}
|
|
9909
|
+
}
|
|
9910
|
+
else {
|
|
9911
|
+
const range = this.materialIndexRange(inst, material);
|
|
9912
|
+
if (!range)
|
|
9913
|
+
return false;
|
|
9914
|
+
const [start, end] = range;
|
|
9915
|
+
const seen = new Set();
|
|
9916
|
+
const add = (a, b) => {
|
|
9917
|
+
const lo = a < b ? a : b;
|
|
9918
|
+
const hi = a < b ? b : a;
|
|
9919
|
+
const k = lo * vertexCount + hi;
|
|
9920
|
+
if (seen.has(k))
|
|
9921
|
+
return;
|
|
9922
|
+
seen.add(k);
|
|
9923
|
+
edges.push(lo, hi);
|
|
9924
|
+
};
|
|
9925
|
+
for (let i = start; i + 2 < end; i += 3) {
|
|
9926
|
+
add(indices[i], indices[i + 1]);
|
|
9927
|
+
add(indices[i + 1], indices[i + 2]);
|
|
9928
|
+
add(indices[i + 2], indices[i]);
|
|
9929
|
+
}
|
|
9930
|
+
}
|
|
9931
|
+
if (edges.length === 0) {
|
|
9932
|
+
inst.wireEdges.set(key, null);
|
|
9933
|
+
return false;
|
|
9934
|
+
}
|
|
9935
|
+
const data = new Uint32Array(edges);
|
|
9936
|
+
const buffer = this.device.createBuffer({
|
|
9937
|
+
label: `wireframe edges ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
|
|
9938
|
+
size: data.byteLength,
|
|
9939
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
9940
|
+
});
|
|
9941
|
+
this.device.queue.writeBuffer(buffer, 0, data);
|
|
9942
|
+
const bindGroup = this.device.createBindGroup({
|
|
9943
|
+
label: `wireframe mesh ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
|
|
9944
|
+
layout: this.wireframeSkinLayout,
|
|
9945
|
+
entries: [
|
|
9946
|
+
{ binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
|
|
9947
|
+
{ binding: 1, resource: { buffer: inst.vertexBuffer } },
|
|
9948
|
+
{ binding: 2, resource: { buffer: inst.jointsBuffer } },
|
|
9949
|
+
{ binding: 3, resource: { buffer: inst.weightsBuffer } },
|
|
9950
|
+
{ binding: 4, resource: { buffer } },
|
|
9951
|
+
],
|
|
9952
|
+
});
|
|
9953
|
+
inst.wireEdges.set(key, { buffer, count: edges.length, bindGroup });
|
|
9954
|
+
return true;
|
|
9955
|
+
}
|
|
9956
|
+
renderWireframe(pass) {
|
|
9957
|
+
if (!this.overlayVertices)
|
|
9958
|
+
return;
|
|
9959
|
+
const inst = this.overlayModel(this.overlayVertices.modelName);
|
|
9960
|
+
const { material, xray } = this.overlayVertices;
|
|
9961
|
+
if (!inst || !this.ensureEdgeBuffer(inst, material))
|
|
9962
|
+
return;
|
|
9963
|
+
const edges = inst.wireEdges.get(material ?? "");
|
|
9964
|
+
if (!edges)
|
|
9965
|
+
return;
|
|
9966
|
+
// The whole mesh gets its material BORDERS drawn over it — that is what
|
|
9967
|
+
// makes the view read as every material at once rather than as one body of
|
|
9968
|
+
// undifferentiated lines. A single material asked for by name is already
|
|
9969
|
+
// one material, so it needs no borders to separate it from anything.
|
|
9970
|
+
const seams = material === null && this.ensureEdgeBuffer(inst, null, true)
|
|
9971
|
+
? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
|
|
9972
|
+
: null;
|
|
9973
|
+
// A pointer over a material previews EXACTLY what clicking it would pick —
|
|
9974
|
+
// the same self-occluding reveal, layered over the section-wide view rather
|
|
9975
|
+
// than replacing it, so the rest of the mesh stays legible while one
|
|
9976
|
+
// material calls attention to itself. Meaningless once something IS
|
|
9977
|
+
// picked, since only the picked material draws at all then.
|
|
9978
|
+
const hm = this.hoverMaterial;
|
|
9979
|
+
const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null;
|
|
9980
|
+
const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null;
|
|
9981
|
+
const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName) : null;
|
|
9982
|
+
this.wireframeColorData.set(DEFAULT_VERTEX_COLOR);
|
|
9983
|
+
this.wireframeColorData[4] = this.canvas.width;
|
|
9984
|
+
this.wireframeColorData[5] = this.canvas.height;
|
|
9985
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.meshStrokePx;
|
|
9986
|
+
// The triangulation steps back only when there is something drawn over it
|
|
9987
|
+
// to step back FROM.
|
|
9988
|
+
if (seams)
|
|
9989
|
+
this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3] * OVERLAY_STYLE.meshAlpha;
|
|
9990
|
+
this.device.queue.writeBuffer(this.wireframeUniformBuffer, 0, this.wireframeColorData);
|
|
9991
|
+
if (seams) {
|
|
9992
|
+
this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3];
|
|
9993
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx;
|
|
9994
|
+
this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData);
|
|
9995
|
+
}
|
|
9996
|
+
if (hover) {
|
|
9997
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx;
|
|
9998
|
+
this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData);
|
|
9999
|
+
}
|
|
10000
|
+
// Both bind groups, before the FIRST draw call below, regardless of which
|
|
10001
|
+
// branch runs first — the depth prepass reads the camera from group 0 and
|
|
10002
|
+
// the skin matrices from group 1 same as the edge pass does, and every
|
|
10003
|
+
// draw in this function needs both set to SOMETHING before it runs. Each
|
|
10004
|
+
// block below is free to swap either one out for its own draws.
|
|
10005
|
+
pass.setBindGroup(0, this.wireframeBindGroup);
|
|
10006
|
+
pass.setBindGroup(1, edges.bindGroup);
|
|
10007
|
+
const bindMesh = () => {
|
|
10008
|
+
pass.setPipeline(this.wireframeDepthPipeline);
|
|
10009
|
+
pass.setVertexBuffer(0, inst.vertexBuffer);
|
|
10010
|
+
pass.setVertexBuffer(1, inst.jointsBuffer);
|
|
10011
|
+
pass.setVertexBuffer(2, inst.weightsBuffer);
|
|
10012
|
+
pass.setIndexBuffer(inst.indexBuffer, "uint32");
|
|
10013
|
+
};
|
|
10014
|
+
// The hover preview writes and draws against its OWN depth first, while the
|
|
10015
|
+
// shared depth buffer is still empty — the same trick a pick uses, run
|
|
10016
|
+
// before the base mesh below gets a chance to occlude it. The base mesh's
|
|
10017
|
+
// depth write further down repeats the SAME geometry for these faces
|
|
10018
|
+
// (identical z), so it neither disturbs this nor needs to skip them.
|
|
10019
|
+
if (hover && hoverRange && !xray) {
|
|
10020
|
+
bindMesh();
|
|
10021
|
+
pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0]);
|
|
10022
|
+
pass.setBindGroup(0, this.wireframeHoverBindGroup);
|
|
10023
|
+
pass.setBindGroup(1, hover.bindGroup);
|
|
10024
|
+
pass.setPipeline(this.wireframePipeline);
|
|
10025
|
+
pass.draw(6, hover.count / 2);
|
|
10026
|
+
pass.setBindGroup(0, this.wireframeBindGroup);
|
|
10027
|
+
pass.setBindGroup(1, edges.bindGroup);
|
|
10028
|
+
}
|
|
10029
|
+
// What writes depth is what is allowed to hide the wireframe, and that
|
|
10030
|
+
// differs between the two views.
|
|
10031
|
+
//
|
|
10032
|
+
// The whole mesh, so the far wall of a 30k-triangle body does not draw on
|
|
10033
|
+
// top of the near one — occluded is the default everywhere, Blender's edit
|
|
10034
|
+
// mode and Maya included, and seeing both walls at once is moire rather than
|
|
10035
|
+
// information.
|
|
10036
|
+
//
|
|
10037
|
+
// A PICKED material writes only its OWN faces. The question a pick asks is
|
|
10038
|
+
// where this material is, and half of it is usually under a coat; letting
|
|
10039
|
+
// the coat hide it does not answer that. Its own depth still goes in, so its
|
|
10040
|
+
// back faces stay hidden and it reads as an object instead of a haze —
|
|
10041
|
+
// which is the difference between this and turning x-ray on.
|
|
10042
|
+
const range = material !== null ? this.materialIndexRange(inst, material) : null;
|
|
10043
|
+
if (!xray) {
|
|
10044
|
+
bindMesh();
|
|
10045
|
+
if (range)
|
|
10046
|
+
pass.drawIndexed(range[1] - range[0], 1, range[0]);
|
|
10047
|
+
else
|
|
10048
|
+
pass.drawIndexed(inst.model.getIndices().length);
|
|
10049
|
+
}
|
|
10050
|
+
// Six vertices an edge, instanced.
|
|
10051
|
+
pass.setPipeline(this.wireframePipeline);
|
|
10052
|
+
pass.draw(6, edges.count / 2);
|
|
10053
|
+
if (seams) {
|
|
10054
|
+
pass.setBindGroup(0, this.wireframeSeamBindGroup);
|
|
10055
|
+
pass.setBindGroup(1, seams.bindGroup);
|
|
10056
|
+
pass.draw(6, seams.count / 2);
|
|
10057
|
+
}
|
|
10058
|
+
}
|
|
10059
|
+
/** The bone overlay's options with `selected` filled in from setSelectedBone,
|
|
10060
|
+
* so clicking a bone highlights it without the host mirroring the state. An
|
|
10061
|
+
* explicit `selected` in the options still wins. */
|
|
10062
|
+
boneOverlayOptions(modelName) {
|
|
10063
|
+
const options = this.overlayBones?.options ?? {};
|
|
10064
|
+
if (options.selected !== undefined)
|
|
10065
|
+
return options;
|
|
10066
|
+
const chosen = this.selectedBone?.modelName === modelName ? this.selectedBone.boneName : null;
|
|
10067
|
+
this.boneOptionsScratch.selected = chosen;
|
|
10068
|
+
this.boneOptionsScratch.include = options.include;
|
|
10069
|
+
return this.boneOptionsScratch;
|
|
10070
|
+
}
|
|
10071
|
+
overlayActive() {
|
|
10072
|
+
return (this.overlayLayers.size > 0 ||
|
|
10073
|
+
this.overlayBones !== null ||
|
|
10074
|
+
this.overlayBodies !== null ||
|
|
10075
|
+
this.overlayJoints !== null ||
|
|
10076
|
+
this.overlayVertices !== null);
|
|
10077
|
+
}
|
|
10078
|
+
overlayModel(name) {
|
|
10079
|
+
return this.modelInstances.get(name) ?? null;
|
|
10080
|
+
}
|
|
10081
|
+
/** The primitives a live layer would draw right now. Same list the pass uses,
|
|
10082
|
+
* so a host can show it as data, diff it, or hit-test it on the CPU. */
|
|
10083
|
+
getOverlayPrimitives(layer) {
|
|
10084
|
+
if (layer === "bones") {
|
|
10085
|
+
const inst = this.overlayBones ? this.overlayModel(this.overlayBones.modelName) : null;
|
|
10086
|
+
return inst ? boneOverlay(inst.model, this.boneOverlayOptions(inst.name)) : [];
|
|
10087
|
+
}
|
|
10088
|
+
if (layer === "rigidbodies") {
|
|
10089
|
+
const inst = this.overlayBodies ? this.overlayModel(this.overlayBodies.modelName) : null;
|
|
10090
|
+
return inst ? rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options) : [];
|
|
10091
|
+
}
|
|
10092
|
+
const inst = this.overlayJoints ? this.overlayModel(this.overlayJoints.modelName) : null;
|
|
10093
|
+
return inst ? jointOverlay(inst.model, inst.physics, this.overlayJoints.options) : [];
|
|
10094
|
+
}
|
|
10095
|
+
// The overlay's own layer: a 4x multisampled colour target, its resolve, and a
|
|
10096
|
+
// matching depth. All three are allocated the first frame an overlay is
|
|
10097
|
+
// actually on, so a scene that never shows one never pays for any of it.
|
|
10098
|
+
ensureOverlayTargets(width, height) {
|
|
10099
|
+
if (this.overlayResolveTexture && this.overlayTargetSize[0] === width && this.overlayTargetSize[1] === height) {
|
|
10100
|
+
return;
|
|
10101
|
+
}
|
|
10102
|
+
const samples = Engine.OVERLAY_SAMPLE_COUNT;
|
|
10103
|
+
this.overlayDepthTexture?.destroy();
|
|
10104
|
+
this.overlayMsaaTexture?.destroy();
|
|
10105
|
+
this.overlayResolveTexture?.destroy();
|
|
10106
|
+
this.overlayMsaaTexture = this.device.createTexture({
|
|
10107
|
+
label: "overlay msaa",
|
|
10108
|
+
size: [width, height],
|
|
10109
|
+
sampleCount: samples,
|
|
10110
|
+
format: this.presentationFormat,
|
|
10111
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
10112
|
+
});
|
|
10113
|
+
this.overlayResolveTexture = this.device.createTexture({
|
|
10114
|
+
label: "overlay resolve",
|
|
10115
|
+
size: [width, height],
|
|
10116
|
+
format: this.presentationFormat,
|
|
10117
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
10118
|
+
});
|
|
10119
|
+
this.overlayDepthTexture = this.device.createTexture({
|
|
10120
|
+
label: "overlay depth",
|
|
10121
|
+
size: [width, height],
|
|
10122
|
+
sampleCount: samples,
|
|
10123
|
+
format: "depth24plus",
|
|
10124
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
10125
|
+
});
|
|
10126
|
+
this.overlayTargetSize = [width, height];
|
|
10127
|
+
const colorAtt = this.overlayPassDescriptor.colorAttachments[0];
|
|
10128
|
+
colorAtt.view = this.overlayMsaaTexture.createView();
|
|
10129
|
+
colorAtt.resolveTarget = this.overlayResolveTexture.createView();
|
|
10130
|
+
const depthAtt = this.overlayPassDescriptor.depthStencilAttachment;
|
|
10131
|
+
depthAtt.view = this.overlayDepthTexture.createView();
|
|
10132
|
+
this.overlayCompositeBindGroup = this.device.createBindGroup({
|
|
10133
|
+
label: "overlay composite bind group",
|
|
10134
|
+
layout: this.overlayCompositeLayout,
|
|
10135
|
+
entries: [{ binding: 0, resource: this.overlayResolveTexture.createView() }],
|
|
10136
|
+
});
|
|
10137
|
+
}
|
|
10138
|
+
ensureOverlayInstanceCapacity(count) {
|
|
10139
|
+
if (this.overlayInstanceBuffer && this.overlayInstanceCapacity >= count)
|
|
10140
|
+
return;
|
|
10141
|
+
// Grow in powers of two so a skirt gaining bodies one at a time does not
|
|
10142
|
+
// reallocate once per body.
|
|
10143
|
+
let capacity = Math.max(64, this.overlayInstanceCapacity || 64);
|
|
10144
|
+
while (capacity < count)
|
|
10145
|
+
capacity *= 2;
|
|
10146
|
+
this.overlayInstanceBuffer?.destroy();
|
|
10147
|
+
this.overlayInstanceBuffer = this.device.createBuffer({
|
|
10148
|
+
label: "overlay instance buffer",
|
|
10149
|
+
size: capacity * OVERLAY_INSTANCE_FLOATS * 4,
|
|
10150
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
10151
|
+
});
|
|
10152
|
+
this.overlayInstanceCapacity = capacity;
|
|
10153
|
+
this.overlayInstanceData = new Float32Array(capacity * OVERLAY_INSTANCE_FLOATS);
|
|
10154
|
+
}
|
|
10155
|
+
// Collects every layer, groups it by shape so each shape is one instanced
|
|
10156
|
+
// draw, and runs them into the swapchain over the finished frame.
|
|
10157
|
+
renderOverlayPass(encoder, swapchainView) {
|
|
10158
|
+
if (!this.camera)
|
|
10159
|
+
return;
|
|
10160
|
+
const byShape = this.overlayByShape;
|
|
10161
|
+
for (const shape of OVERLAY_SHAPES) {
|
|
10162
|
+
const list = byShape.get(shape);
|
|
10163
|
+
if (list)
|
|
10164
|
+
list.length = 0;
|
|
10165
|
+
}
|
|
10166
|
+
let total = 0;
|
|
10167
|
+
const collect = (primitives) => {
|
|
10168
|
+
for (const primitive of primitives) {
|
|
10169
|
+
let list = byShape.get(primitive.shape);
|
|
10170
|
+
if (!list) {
|
|
10171
|
+
list = [];
|
|
10172
|
+
byShape.set(primitive.shape, list);
|
|
10173
|
+
}
|
|
10174
|
+
list.push(primitive);
|
|
10175
|
+
total++;
|
|
10176
|
+
}
|
|
10177
|
+
};
|
|
10178
|
+
for (const layer of this.overlayLayers.values())
|
|
10179
|
+
collect(layer);
|
|
10180
|
+
if (this.overlayBones) {
|
|
10181
|
+
const inst = this.overlayModel(this.overlayBones.modelName);
|
|
10182
|
+
if (inst)
|
|
10183
|
+
collect(boneOverlay(inst.model, this.boneOverlayOptions(inst.name)));
|
|
10184
|
+
}
|
|
10185
|
+
if (this.overlayBodies) {
|
|
10186
|
+
const inst = this.overlayModel(this.overlayBodies.modelName);
|
|
10187
|
+
if (inst)
|
|
10188
|
+
collect(rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options));
|
|
10189
|
+
}
|
|
10190
|
+
if (this.overlayJoints) {
|
|
10191
|
+
const inst = this.overlayModel(this.overlayJoints.modelName);
|
|
10192
|
+
if (inst)
|
|
10193
|
+
collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options));
|
|
10194
|
+
}
|
|
10195
|
+
if (total === 0 && !this.overlayVertices)
|
|
10196
|
+
return;
|
|
10197
|
+
this.ensureOverlayInstanceCapacity(total);
|
|
10198
|
+
const data = this.overlayInstanceData;
|
|
10199
|
+
const draws = [];
|
|
10200
|
+
let written = 0;
|
|
10201
|
+
for (const shape of OVERLAY_SHAPES) {
|
|
10202
|
+
const list = byShape.get(shape);
|
|
10203
|
+
if (!list || list.length === 0)
|
|
10204
|
+
continue;
|
|
10205
|
+
draws.push({ shape, first: written, count: list.length });
|
|
10206
|
+
for (const primitive of list) {
|
|
10207
|
+
writeOverlayInstance(primitive, data, written * OVERLAY_INSTANCE_FLOATS);
|
|
10208
|
+
written++;
|
|
10209
|
+
}
|
|
10210
|
+
}
|
|
10211
|
+
this.device.queue.writeBuffer(this.overlayInstanceBuffer, 0, data.buffer, data.byteOffset, written * OVERLAY_INSTANCE_FLOATS * 4);
|
|
10212
|
+
const width = this.canvas.width;
|
|
10213
|
+
const height = this.canvas.height;
|
|
10214
|
+
this.ensureOverlayTargets(width, height);
|
|
10215
|
+
this.overlayUniformData[0] = width;
|
|
10216
|
+
this.overlayUniformData[1] = height;
|
|
10217
|
+
this.overlayUniformData[2] = Engine.OVERLAY_DASH_PERIOD_PX;
|
|
10218
|
+
this.device.queue.writeBuffer(this.overlayUniformBuffer, 0, this.overlayUniformData);
|
|
10219
|
+
const pass = encoder.beginRenderPass(this.overlayPassDescriptor);
|
|
10220
|
+
// Under everything: the mesh is the haze the rig is read against.
|
|
10221
|
+
this.renderWireframe(pass);
|
|
10222
|
+
pass.setBindGroup(0, this.overlayBindGroup);
|
|
10223
|
+
pass.setVertexBuffer(0, this.overlayVertexBuffer);
|
|
10224
|
+
pass.setVertexBuffer(1, this.overlayInstanceBuffer);
|
|
10225
|
+
// Volumes first and without depth writes, then the line work over them.
|
|
10226
|
+
for (const solid of [true, false]) {
|
|
10227
|
+
let bound = false;
|
|
10228
|
+
for (const draw of draws) {
|
|
10229
|
+
if (OVERLAY_SOLID_SHAPES.has(draw.shape) !== solid)
|
|
10230
|
+
continue;
|
|
10231
|
+
if (!bound) {
|
|
10232
|
+
pass.setPipeline(solid ? this.overlaySolidPipeline : this.overlayPipeline);
|
|
10233
|
+
bound = true;
|
|
10234
|
+
}
|
|
10235
|
+
const range = this.overlayGeometry.ranges[draw.shape];
|
|
10236
|
+
pass.draw(range.count, draw.count, range.first, draw.first);
|
|
10237
|
+
}
|
|
10238
|
+
}
|
|
10239
|
+
pass.end();
|
|
10240
|
+
// The resolved layer over the finished frame, premultiplied.
|
|
10241
|
+
const compositeAtt = this.overlayCompositePassDescriptor.colorAttachments[0];
|
|
10242
|
+
compositeAtt.view = swapchainView;
|
|
10243
|
+
const composite = encoder.beginRenderPass(this.overlayCompositePassDescriptor);
|
|
10244
|
+
composite.setPipeline(this.overlayCompositePipeline);
|
|
10245
|
+
composite.setBindGroup(0, this.overlayCompositeBindGroup);
|
|
10246
|
+
composite.draw(3);
|
|
10247
|
+
composite.end();
|
|
10248
|
+
}
|
|
9090
10249
|
// Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
|
|
9091
10250
|
// then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
|
|
9092
10251
|
// aligns rings with the bone's current world orientation, so clicking a ring
|
|
@@ -9163,15 +10322,65 @@ export class Engine {
|
|
|
9163
10322
|
return new Vec3(x / w, y / w, z / w);
|
|
9164
10323
|
}
|
|
9165
10324
|
// World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
|
|
10325
|
+
/**
|
|
10326
|
+
* Where a point on the canvas lands on a horizontal plane.
|
|
10327
|
+
*
|
|
10328
|
+
* `px,py` are canvas-relative pixels, top-left origin — what a pointer event
|
|
10329
|
+
* gives you after subtracting the element's rect. Returns null when the ray
|
|
10330
|
+
* cannot reach the plane: parallel to it, or pointing the other way, which is
|
|
10331
|
+
* what a click on the sky above the horizon is.
|
|
10332
|
+
*
|
|
10333
|
+
* The one primitive a placement UI needs. Dragging a thing across the floor is
|
|
10334
|
+
* otherwise three sliders in world units, which asks someone to guess numbers
|
|
10335
|
+
* that have no visible relation to the picture they are looking at — and it
|
|
10336
|
+
* throws away the property that makes pointing work at all: under perspective,
|
|
10337
|
+
* moving something further away makes it smaller by exactly the right amount,
|
|
10338
|
+
* so position and size stop being two controls to tune against each other.
|
|
10339
|
+
*/
|
|
10340
|
+
groundPointAt(px, py, planeY = 0) {
|
|
10341
|
+
const ray = this.buildMouseRay(px, py);
|
|
10342
|
+
if (!ray)
|
|
10343
|
+
return null;
|
|
10344
|
+
// Parallel to the plane: no intersection, and a huge one is not an answer.
|
|
10345
|
+
if (Math.abs(ray.dir.y) < 1e-6)
|
|
10346
|
+
return null;
|
|
10347
|
+
const t = (planeY - ray.origin.y) / ray.dir.y;
|
|
10348
|
+
// Behind the camera — the plane is there, but not in this shot.
|
|
10349
|
+
if (!(t > 0) || !isFinite(t))
|
|
10350
|
+
return null;
|
|
10351
|
+
return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t);
|
|
10352
|
+
}
|
|
10353
|
+
/** Hand the pointer to something else — a placement drag, a gizmo, a host's own
|
|
10354
|
+
* overlay — so the orbit does not also act on it. */
|
|
10355
|
+
setCameraInputLocked(locked) {
|
|
10356
|
+
this.camera?.setInputLocked(locked);
|
|
10357
|
+
}
|
|
9166
10358
|
buildMouseRay(px, py) {
|
|
9167
10359
|
if (!this.camera)
|
|
9168
10360
|
return null;
|
|
9169
10361
|
const width = this.canvas.clientWidth;
|
|
9170
10362
|
const height = this.canvas.clientHeight;
|
|
9171
|
-
if (width <= 0 || height <= 0)
|
|
10363
|
+
if (width <= 0 || height <= 0 || this.canvas.width <= 0 || this.canvas.height <= 0)
|
|
9172
10364
|
return null;
|
|
9173
|
-
|
|
9174
|
-
|
|
10365
|
+
// THE PICTURE, NOT THE ELEMENT.
|
|
10366
|
+
//
|
|
10367
|
+
// The projection's aspect comes from the DRAWING BUFFER, while a pointer
|
|
10368
|
+
// arrives in the CSS box — and the two do not have to agree. The canvas is
|
|
10369
|
+
// laid out `object-contain`, so whenever they differ the rendered image sits
|
|
10370
|
+
// letterboxed inside the element with bars either side of it, and dividing
|
|
10371
|
+
// by the element's own size lands the ray somewhere the picture is not.
|
|
10372
|
+
// They disagree on every resize until the observer catches up, and
|
|
10373
|
+
// permanently wherever a host frames the canvas to a shape of its own.
|
|
10374
|
+
//
|
|
10375
|
+
// So: work out where the image actually sits, and take the ray from that.
|
|
10376
|
+
const bufAspect = this.canvas.width / this.canvas.height;
|
|
10377
|
+
const boxAspect = width / height;
|
|
10378
|
+
const imgW = bufAspect > boxAspect ? width : height * bufAspect;
|
|
10379
|
+
const imgH = bufAspect > boxAspect ? width / bufAspect : height;
|
|
10380
|
+
const ox = (width - imgW) / 2;
|
|
10381
|
+
const oy = (height - imgH) / 2;
|
|
10382
|
+
const ndcX = ((px - ox) / imgW) * 2 - 1;
|
|
10383
|
+
const ndcY = -(((py - oy) / imgH) * 2 - 1);
|
|
9175
10384
|
const view = this.camera.getViewMatrix();
|
|
9176
10385
|
const proj = this.camera.getProjectionMatrix();
|
|
9177
10386
|
const invVP = proj.multiply(view).inverse();
|
|
@@ -9454,8 +10663,14 @@ export class Engine {
|
|
|
9454
10663
|
}
|
|
9455
10664
|
}
|
|
9456
10665
|
}
|
|
9457
|
-
//
|
|
9458
|
-
|
|
10666
|
+
// Who holds the shot this frame. An external pose is a statement about
|
|
10667
|
+
// where the camera IS, so it is reapplied rather than sampled — and it
|
|
10668
|
+
// outranks a loaded track, which is scene data.
|
|
10669
|
+
if (this.cameraPoseOverride) {
|
|
10670
|
+
this.camera.setVmdPose(this.cameraPoseOverride);
|
|
10671
|
+
}
|
|
10672
|
+
else if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
10673
|
+
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
9459
10674
|
const pose = this.cameraAnimation.sample(this.transportTime());
|
|
9460
10675
|
if (pose)
|
|
9461
10676
|
this.camera.setVmdPose(pose);
|
|
@@ -9734,7 +10949,11 @@ export class Engine {
|
|
|
9734
10949
|
this.renderIdDebugPass(encoder, swapchainView);
|
|
9735
10950
|
if (this.selectedMaterial && hasModels)
|
|
9736
10951
|
this.renderSelectionPasses(encoder, swapchainView);
|
|
9737
|
-
|
|
10952
|
+
// Under the gizmo: the handles you drag stay on top of the rig you are
|
|
10953
|
+
// reading them against.
|
|
10954
|
+
if (this.overlayActive())
|
|
10955
|
+
this.renderOverlayPass(encoder, swapchainView);
|
|
10956
|
+
if (this.gizmoEnabled && this.selectedBone && hasModels)
|
|
9738
10957
|
this.renderGizmoPass(encoder, swapchainView);
|
|
9739
10958
|
const pick = this.pendingPick;
|
|
9740
10959
|
if (pick && hasModels)
|
|
@@ -10683,6 +11902,12 @@ export class Engine {
|
|
|
10683
11902
|
// clock is already per effect, which is the one that actually breaks
|
|
10684
11903
|
// things (rzGridFrame()==0 is a grid's only chance to seed).
|
|
10685
11904
|
u[24] = this.sceneClock - (this.effects[0]?.epochScene ?? 0);
|
|
11905
|
+
// The grain's seed rides the same per-frame refresh, because it is the
|
|
11906
|
+
// only thing that makes it move — a seed written once by its setter is a
|
|
11907
|
+
// still pattern welded to the picture. On the SCENE clock like everything
|
|
11908
|
+
// else here, so an export reproduces the editor exactly rather than
|
|
11909
|
+
// scattering differently at whatever rate the encoder ran.
|
|
11910
|
+
u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0;
|
|
10686
11911
|
u[26] = this.canvas.width;
|
|
10687
11912
|
u[27] = this.canvas.height;
|
|
10688
11913
|
// Camera world position (viewU[10]) — the other half of bgWorldPos. It
|
|
@@ -11020,6 +12245,13 @@ export class Engine {
|
|
|
11020
12245
|
}
|
|
11021
12246
|
}
|
|
11022
12247
|
Engine.instance = null;
|
|
12248
|
+
/** The overlay renders multisampled into its own layer; the scene's own depth
|
|
12249
|
+
* is discarded before the composite (see the depthRead note in render), so it
|
|
12250
|
+
* could not have shared either that or the single-sample swapchain. */
|
|
12251
|
+
Engine.OVERLAY_SAMPLE_COUNT = 4;
|
|
12252
|
+
/** Dash period in device pixels — dashes are geometry, so this is only the
|
|
12253
|
+
* reference the dashedLine shape is cut against. */
|
|
12254
|
+
Engine.OVERLAY_DASH_PERIOD_PX = 8.0;
|
|
11023
12255
|
Engine.GIZMO_RING_SEGMENTS = 96;
|
|
11024
12256
|
Engine.GIZMO_RING_RADIUS = 0.8;
|
|
11025
12257
|
// Axis visible length (relative to gizmo size). Extends past ring radius so
|
|
@@ -11175,6 +12407,7 @@ Engine.TIMED_PASSES = [
|
|
|
11175
12407
|
"field",
|
|
11176
12408
|
"bloom",
|
|
11177
12409
|
"composite",
|
|
12410
|
+
"overlay",
|
|
11178
12411
|
];
|
|
11179
12412
|
// ── The floor mirror (step 7C) ──
|
|
11180
12413
|
// Half-res scene-contract attachments a mirrored draw renders into, plus the
|
|
@@ -11199,3 +12432,6 @@ Engine.CULL_ARG_WORDS = 5;
|
|
|
11199
12432
|
Engine.CULL_DRAW_CASTS_SHADOW = 1;
|
|
11200
12433
|
Engine.CULL_MODEL_VISIBLE = 1;
|
|
11201
12434
|
Engine.CULL_MODEL_RIGID = 2;
|
|
12435
|
+
/** A cache key no material can collide with — a PMX name is never empty and
|
|
12436
|
+
* never contains a NUL. */
|
|
12437
|
+
Engine.SEAM_KEY = "\u0000seams";
|