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.
Files changed (59) hide show
  1. package/dist/camera.d.ts +13 -0
  2. package/dist/camera.d.ts.map +1 -1
  3. package/dist/camera.js +53 -2
  4. package/dist/engine.d.ts +317 -0
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +1263 -27
  7. package/dist/graph/presets/pool_floor.d.ts +3 -0
  8. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  9. package/dist/graph/presets/pool_floor.js +42 -0
  10. package/dist/graph/presets/water.d.ts +3 -0
  11. package/dist/graph/presets/water.d.ts.map +1 -0
  12. package/dist/graph/presets/water.js +31 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/model.d.ts.map +1 -1
  17. package/dist/model.js +10 -0
  18. package/dist/overlay.d.ts +200 -0
  19. package/dist/overlay.d.ts.map +1 -0
  20. package/dist/overlay.js +806 -0
  21. package/dist/physics/autofit.d.ts +147 -0
  22. package/dist/physics/autofit.d.ts.map +1 -0
  23. package/dist/physics/autofit.js +501 -0
  24. package/dist/pmx-document.d.ts +218 -0
  25. package/dist/pmx-document.d.ts.map +1 -0
  26. package/dist/pmx-document.js +685 -0
  27. package/dist/shaders/passes/composite.d.ts.map +1 -1
  28. package/dist/shaders/passes/composite.js +18 -1
  29. package/dist/shaders/passes/field-blit.d.ts +26 -0
  30. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  31. package/dist/shaders/passes/field-blit.js +65 -0
  32. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  33. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  34. package/dist/shaders/passes/ground-noise.js +88 -0
  35. package/dist/shaders/passes/ground.d.ts.map +1 -1
  36. package/dist/shaders/passes/ground.js +63 -17
  37. package/dist/shaders/passes/overlay.d.ts +3 -0
  38. package/dist/shaders/passes/overlay.d.ts.map +1 -0
  39. package/dist/shaders/passes/overlay.js +164 -0
  40. package/dist/shaders/passes/sim.d.ts +34 -0
  41. package/dist/shaders/passes/sim.d.ts.map +1 -0
  42. package/dist/shaders/passes/sim.js +169 -0
  43. package/dist/shaders/passes/wireframe.d.ts +2 -0
  44. package/dist/shaders/passes/wireframe.d.ts.map +1 -0
  45. package/dist/shaders/passes/wireframe.js +107 -0
  46. package/dist/shaders/score-api.d.ts +10 -0
  47. package/dist/shaders/score-api.d.ts.map +1 -0
  48. package/dist/shaders/score-api.js +114 -0
  49. package/package.json +1 -1
  50. package/src/camera.ts +60 -2
  51. package/src/engine.ts +1359 -25
  52. package/src/index.ts +50 -0
  53. package/src/model.ts +9 -0
  54. package/src/overlay.ts +1000 -0
  55. package/src/pmx-document.ts +882 -0
  56. package/src/shaders/passes/composite.ts +18 -1
  57. package/src/shaders/passes/ground.ts +65 -17
  58. package/src/shaders/passes/overlay.ts +166 -0
  59. 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] = 0.0;
1410
- u[3] = 0.0;
1482
+ u[2] = this.grain.amount;
1483
+ // The seed. Zero means STILL: a plate that is one photograph has grain that
1484
+ // does not move, and CG noise crawling over a frozen picture makes the CG
1485
+ // look more alive than the footage — the opposite of the point.
1486
+ u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0;
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
- // ── VMD camera track ──
5980
- // A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
5981
- // via model.loadVmd never touch the camera — the camera shot is opt-in through here.
6366
+ /** The one place that decides who is holding the camera. An external pose
6367
+ * wins; a track drives when it is loaded and enabled; otherwise orbit. */
6368
+ refreshCameraDrive() {
6369
+ this.camera.setVmdDriven(this.cameraPoseOverride !== null || (this.cameraVmdEnabled && this.cameraAnimation !== null));
6370
+ }
6371
+ /**
6372
+ * Aim the camera from outside — a solved match-move, a saved shot, a rig
6373
+ * driving the view from the host's own clock.
6374
+ *
6375
+ * The exact partner of `getCameraPose`, and the same five channels: the shot
6376
+ * as MMD states it, roll included. Orbit cannot express roll, so this is the
6377
+ * only way a tilted camera reaches the engine.
6378
+ *
6379
+ * Reapplied every frame while set, which makes it authoritative rather than
6380
+ * advisory — nothing the transport or a loaded track does moves it. Pass null
6381
+ * to release, and whatever was driving before takes the camera back.
6382
+ */
6383
+ setCameraPose(pose) {
6384
+ if (pose) {
6385
+ // Copied, not held: a host reusing one object per frame is the normal
6386
+ // shape of a track, and storing the reference would make the value we
6387
+ // reapply depend on when the caller next touched theirs.
6388
+ this.cameraPoseOverride = {
6389
+ target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
6390
+ rotation: new Vec3(pose.rotation.x, pose.rotation.y, pose.rotation.z),
6391
+ distance: pose.distance,
6392
+ fov: pose.fov,
6393
+ };
6394
+ }
6395
+ else {
6396
+ this.cameraPoseOverride = null;
6397
+ }
6398
+ this.refreshCameraDrive();
6399
+ if (this.cameraPoseOverride)
6400
+ this.camera.setVmdPose(this.cameraPoseOverride);
6401
+ }
6402
+ /** The pose currently forced from outside, or null when nothing is. */
6403
+ getCameraPoseOverride() {
6404
+ return this.cameraPoseOverride;
6405
+ }
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.camera.setVmdDriven(this.cameraAnimation !== null);
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.camera.setVmdDriven(this.cameraAnimation !== null);
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.camera.setVmdDriven(this.cameraAnimation !== null);
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.camera.setVmdDriven(enabled && this.cameraAnimation !== null);
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.camera.setVmdDriven(false);
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
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
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
- const ndcX = (px / width) * 2 - 1;
9174
- const ndcY = -((py / height) * 2 - 1);
10365
+ // THE PICTURE, NOT THE ELEMENT.
10366
+ //
10367
+ // The projection's aspect comes from the DRAWING BUFFER, while a pointer
10368
+ // arrives in the CSS box — and the two do not have to agree. The canvas is
10369
+ // laid out `object-contain`, so whenever they differ the rendered image sits
10370
+ // letterboxed inside the element with bars either side of it, and dividing
10371
+ // by the element's own size lands the ray somewhere the picture is not.
10372
+ // They disagree on every resize until the observer catches up, and
10373
+ // permanently wherever a host frames the canvas to a shape of its own.
10374
+ //
10375
+ // So: work out where the image actually sits, and take the ray from that.
10376
+ const bufAspect = this.canvas.width / this.canvas.height;
10377
+ const boxAspect = width / height;
10378
+ const imgW = bufAspect > boxAspect ? width : height * bufAspect;
10379
+ const imgH = bufAspect > boxAspect ? width / bufAspect : height;
10380
+ const ox = (width - imgW) / 2;
10381
+ const oy = (height - imgH) / 2;
10382
+ const ndcX = ((px - ox) / imgW) * 2 - 1;
10383
+ const ndcY = -(((py - oy) / imgH) * 2 - 1);
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
- // Drive the shot from the camera VMD (synced to the animated model's clock).
9458
- if (this.camera.vmdDriven && this.cameraAnimation) {
10666
+ // Who holds the shot this frame. An external pose is a statement about
10667
+ // where the camera IS, so it is reapplied rather than sampled — and it
10668
+ // outranks a loaded track, which is scene data.
10669
+ if (this.cameraPoseOverride) {
10670
+ this.camera.setVmdPose(this.cameraPoseOverride);
10671
+ }
10672
+ else if (this.camera.vmdDriven && this.cameraAnimation) {
10673
+ // Drive the shot from the camera VMD (synced to the animated model's clock).
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
- if (this.selectedBone && hasModels)
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";