reze-engine 0.54.15 → 0.55.0

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 (62) hide show
  1. package/dist/engine.d.ts +210 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1140 -13
  4. package/dist/graph/presets/pool_floor.d.ts +3 -0
  5. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  6. package/dist/graph/presets/pool_floor.js +42 -0
  7. package/dist/graph/presets/water.d.ts +3 -0
  8. package/dist/graph/presets/water.d.ts.map +1 -0
  9. package/dist/graph/presets/water.js +31 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -0
  13. package/dist/model.d.ts.map +1 -1
  14. package/dist/model.js +10 -0
  15. package/dist/overlay.d.ts +200 -0
  16. package/dist/overlay.d.ts.map +1 -0
  17. package/dist/overlay.js +806 -0
  18. package/dist/physics/autofit.d.ts +147 -0
  19. package/dist/physics/autofit.d.ts.map +1 -0
  20. package/dist/physics/autofit.js +501 -0
  21. package/dist/pmx-document.d.ts +218 -0
  22. package/dist/pmx-document.d.ts.map +1 -0
  23. package/dist/pmx-document.js +685 -0
  24. package/dist/shaders/lights.d.ts.map +1 -1
  25. package/dist/shaders/lights.js +2 -0
  26. package/dist/shaders/passes/composite.d.ts.map +1 -1
  27. package/dist/shaders/passes/composite.js +2 -0
  28. package/dist/shaders/passes/field-blit.d.ts +26 -0
  29. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  30. package/dist/shaders/passes/field-blit.js +65 -0
  31. package/dist/shaders/passes/grid.d.ts.map +1 -1
  32. package/dist/shaders/passes/grid.js +2 -0
  33. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  34. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  35. package/dist/shaders/passes/ground-noise.js +88 -0
  36. package/dist/shaders/passes/overlay.d.ts +3 -0
  37. package/dist/shaders/passes/overlay.d.ts.map +1 -0
  38. package/dist/shaders/passes/overlay.js +164 -0
  39. package/dist/shaders/passes/sim.d.ts +34 -0
  40. package/dist/shaders/passes/sim.d.ts.map +1 -0
  41. package/dist/shaders/passes/sim.js +169 -0
  42. package/dist/shaders/passes/wireframe.d.ts +2 -0
  43. package/dist/shaders/passes/wireframe.d.ts.map +1 -0
  44. package/dist/shaders/passes/wireframe.js +107 -0
  45. package/dist/shaders/scene-tap.d.ts +60 -0
  46. package/dist/shaders/scene-tap.d.ts.map +1 -0
  47. package/dist/shaders/scene-tap.js +234 -0
  48. package/dist/shaders/score-api.d.ts +10 -0
  49. package/dist/shaders/score-api.d.ts.map +1 -0
  50. package/dist/shaders/score-api.js +114 -0
  51. package/package.json +1 -1
  52. package/src/engine.ts +1221 -15
  53. package/src/index.ts +50 -0
  54. package/src/model.ts +9 -0
  55. package/src/overlay.ts +1000 -0
  56. package/src/pmx-document.ts +882 -0
  57. package/src/shaders/lights.ts +2 -0
  58. package/src/shaders/passes/composite.ts +2 -0
  59. package/src/shaders/passes/grid.ts +2 -0
  60. package/src/shaders/passes/overlay.ts +166 -0
  61. package/src/shaders/passes/wireframe.ts +108 -0
  62. package/src/shaders/scene-tap.ts +236 -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
@@ -1037,6 +1069,9 @@ export class Engine {
1037
1069
  * Set by the cascade loop, which skips a cascade that is unwanted and already
1038
1070
  * cleared rather than re-clearing it every frame. */
1039
1071
  this.shadowCascadeCleared = [];
1072
+ /** Skinned positions for picking, grown on demand. One click's worth of work
1073
+ * reused across clicks — a model's vertex count does not change. */
1074
+ this.materialPickScratch = null;
1040
1075
  // CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
1041
1076
  // milliseconds actually go — animation/IK/blending vs physics vs everything
1042
1077
  // else on the render thread. The first question of any perf report.
@@ -1098,8 +1133,9 @@ export class Engine {
1098
1133
  this.lastTouchTime = currentTime;
1099
1134
  }
1100
1135
  };
1136
+ this.boneOptionsScratch = {};
1101
1137
  this.handleGizmoMouseDown = (e) => {
1102
- if (!this.selectedBone || !this.camera || !this.device || e.button !== 0)
1138
+ if (!this.gizmoEnabled || !this.selectedBone || !this.camera || !this.device || e.button !== 0)
1103
1139
  return;
1104
1140
  const inst = this.modelInstances.get(this.selectedBone.modelName);
1105
1141
  if (!inst)
@@ -2060,10 +2096,11 @@ export class Engine {
2060
2096
  * change that only ever toggles between two known states.
2061
2097
  */
2062
2098
  rebuildFieldBindGroup() {
2063
- if (!this.device || !this.depthReadView || this.fieldUniformBuffers.length === 0)
2099
+ if (!this.device || !this.depthReadView || !this.compositeBloomView || this.fieldUniformBuffers.length === 0)
2064
2100
  return;
2065
- // Captured, so the null guard above survives into the closure.
2101
+ // Captured, so the null guards above survive into the closure.
2066
2102
  const depth = this.depthReadView;
2103
+ const bloom = this.compositeBloomView;
2067
2104
  const build = (owner, grid) => this.device.createBindGroup({
2068
2105
  label: "field layer bind group",
2069
2106
  layout: this.fieldBindGroupLayout,
@@ -2084,6 +2121,13 @@ export class Engine {
2084
2121
  { binding: 17, resource: grid },
2085
2122
  { binding: 18, resource: this.simSampler },
2086
2123
  { binding: 26, resource: this.castDistView ?? this.castDistFallbackView },
2124
+ { binding: 27, resource: this.hdrResolveTexture.createView() },
2125
+ { binding: 28, resource: this.simSampler },
2126
+ { binding: 2, resource: this.bloomSampler },
2127
+ { binding: 5, resource: this.filmicLutView },
2128
+ { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
2129
+ { binding: 1, resource: bloom },
2130
+ { binding: 4, resource: this.maskResolveView },
2087
2131
  ],
2088
2132
  });
2089
2133
  // Per effect: the params buffer and the grid are both its own, so two
@@ -3859,6 +3903,14 @@ export class Engine {
3859
3903
  const proj = this.camera.getProjectionMatrix().values;
3860
3904
  u[8] = proj[10];
3861
3905
  u[9] = proj[14];
3906
+ // THE FAR PLANE ITSELF, written rather than left to be re-derived. An effect
3907
+ // that resamples has to know whether the scene was drawn at a given pixel,
3908
+ // and "nothing was drawn" reads back as exactly this distance. Recovering it
3909
+ // from the pair above means inverting a cleared depth, which is a sign trap
3910
+ // that differs by projection convention and fails silently — as an effect
3911
+ // that draws nothing at all, or one that paints the sky. The camera knows
3912
+ // the number; this hands it over.
3913
+ u[10] = this.camera.far;
3862
3914
  this.device.queue.writeBuffer(this.dofUniformBuffer, 0, u);
3863
3915
  }
3864
3916
  setBloomOptions(patch) {
@@ -4020,13 +4072,6 @@ export class Engine {
4020
4072
  this.createPipelines();
4021
4073
  this.setupResize();
4022
4074
  Engine.instance = this;
4023
- // One line, at init, naming the three answers that differ between two
4024
- // browsers on the same machine. Not a debug flag and not a readout — it is
4025
- // the identity of the renderer that was actually built, and on a device that
4026
- // cannot be attached to a debugger it is the only way to know which of the
4027
- // three paths is running. Every graphics application prints this.
4028
- const r = this.gpuReport();
4029
- console.info(`[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`);
4030
4075
  }
4031
4076
  /**
4032
4077
  * Bake the ground's frost noise once — the same fbm the shader used to run
@@ -4362,6 +4407,23 @@ export class Engine {
4362
4407
  // compiled, and a 1x1 stands in when the flood is not running, so an
4363
4408
  // author never has to guard the name.
4364
4409
  { binding: 26, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d" } },
4410
+ // The finished scene, and a sampler of its own — see scene-tap.ts for
4411
+ // why it may not borrow the one at 18.
4412
+ { binding: 27, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4413
+ { binding: 28, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
4414
+ // THE VIEW TRANSFORM'S OWN RESOURCES. viewTransform() lives in the
4415
+ // header both this module and the composite share, but its lookups did
4416
+ // not: an effect calling it compiled and then failed at pipeline
4417
+ // creation with "binding doesn't exist", naming a binding no effect
4418
+ // author ever wrote. Sharing the code meant sharing these.
4419
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
4420
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4421
+ { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
4422
+ // And the scene's COVERAGE and bloom, without which the tap cannot
4423
+ // reconstruct a pixel: the HDR target is premultiplied, so colour alone
4424
+ // reads a half-transparent ground as a dark opaque one.
4425
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4426
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4365
4427
  ],
4366
4428
  });
4367
4429
  this.fieldPipelineLayout = this.device.createPipelineLayout({
@@ -4979,6 +5041,8 @@ export class Engine {
4979
5041
  this.device.queue.writeBuffer(this.selectionEdgeUniformBuffer, 0, new Float32Array([5.0, 0, 0, 0]));
4980
5042
  // ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
4981
5043
  this.setupGizmo();
5044
+ // ─── Editor overlays (instanced wireframe primitives) ────────────
5045
+ this.setupOverlay();
4982
5046
  // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
4983
5047
  // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
4984
5048
  // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
@@ -5884,6 +5948,322 @@ export class Engine {
5884
5948
  ],
5885
5949
  };
5886
5950
  }
5951
+ // Builds the overlay pipeline and the one vertex buffer holding every unit
5952
+ // wireframe. The instance buffer is grown on demand in renderOverlayPass — a
5953
+ // scene with no overlays on never allocates one.
5954
+ setupOverlay() {
5955
+ this.overlayGeometry = buildOverlayShapes();
5956
+ const verts = this.overlayGeometry.vertices;
5957
+ this.overlayVertexBuffer = this.device.createBuffer({
5958
+ label: "overlay vertex buffer",
5959
+ size: verts.byteLength,
5960
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
5961
+ });
5962
+ this.device.queue.writeBuffer(this.overlayVertexBuffer, 0, verts);
5963
+ this.overlayUniformBuffer = this.device.createBuffer({
5964
+ label: "overlay uniforms",
5965
+ size: 16, // vec2 viewport + dash period + pad
5966
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5967
+ });
5968
+ const bgLayout = this.device.createBindGroupLayout({
5969
+ label: "overlay group 0 layout (camera + overlay)",
5970
+ entries: [
5971
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
5972
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
5973
+ ],
5974
+ });
5975
+ const shader = this.device.createShaderModule({ label: "overlay shader", code: OVERLAY_SHADER_WGSL });
5976
+ const overlayPipelineDescriptor = {
5977
+ label: "overlay pipeline",
5978
+ layout: this.device.createPipelineLayout({
5979
+ label: "overlay pipeline layout",
5980
+ bindGroupLayouts: [bgLayout],
5981
+ }),
5982
+ vertex: {
5983
+ module: shader,
5984
+ entryPoint: "vs",
5985
+ buffers: [
5986
+ {
5987
+ arrayStride: OVERLAY_VERTEX_FLOATS * 4,
5988
+ attributes: [
5989
+ { shaderLocation: 0, offset: 0, format: "float32x3" }, // pos
5990
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" }, // dir
5991
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" }, // caps
5992
+ { shaderLocation: 3, offset: 8 * 4, format: "float32" }, // side
5993
+ { shaderLocation: 4, offset: 9 * 4, format: "float32" }, // t
5994
+ { shaderLocation: 5, offset: 10 * 4, format: "float32" }, // mode
5995
+ ],
5996
+ },
5997
+ {
5998
+ arrayStride: OVERLAY_INSTANCE_FLOATS * 4,
5999
+ stepMode: "instance",
6000
+ attributes: [
6001
+ { shaderLocation: 6, offset: 0, format: "float32x4" }, // rotation
6002
+ { shaderLocation: 7, offset: 4 * 4, format: "float32x4" }, // position + extent
6003
+ { shaderLocation: 8, offset: 8 * 4, format: "float32x4" }, // scale + thickness
6004
+ { shaderLocation: 9, offset: 12 * 4, format: "float32x4" }, // color
6005
+ ],
6006
+ },
6007
+ ],
6008
+ },
6009
+ fragment: {
6010
+ module: shader,
6011
+ entryPoint: "fs",
6012
+ targets: [
6013
+ {
6014
+ format: this.presentationFormat,
6015
+ // Premultiplied: the FS already scaled rgb by alpha. See the shader.
6016
+ blend: {
6017
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6018
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6019
+ },
6020
+ },
6021
+ ],
6022
+ },
6023
+ primitive: { topology: "triangle-list", cullMode: "none" },
6024
+ // The rig ignores depth entirely. It shares this pass's buffer with the
6025
+ // wireframe's mesh prepass, and that prepass exists to hide the far side
6026
+ // of the BODY — not to hide the skeleton inside it. An editor wants the
6027
+ // rig in front of the mesh, which is what "always" says. The cost is that
6028
+ // the rig no longer sorts against itself; for line work a few pixels wide,
6029
+ // draw order reads the same.
6030
+ depthStencil: {
6031
+ format: "depth24plus",
6032
+ depthWriteEnabled: false,
6033
+ depthCompare: "always",
6034
+ },
6035
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
6036
+ };
6037
+ this.overlayPipeline = this.device.createRenderPipeline(overlayPipelineDescriptor);
6038
+ // The solid volumes: the same shader and layout, with no depth write and no
6039
+ // culling. A translucent body must not hide the rig behind it, and you have
6040
+ // to see its far wall for it to read as a volume rather than a silhouette.
6041
+ this.overlaySolidPipeline = this.device.createRenderPipeline({
6042
+ ...overlayPipelineDescriptor,
6043
+ label: "overlay solid pipeline",
6044
+ primitive: { topology: "triangle-list", cullMode: "none" },
6045
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
6046
+ });
6047
+ const compositeShader = this.device.createShaderModule({
6048
+ label: "overlay composite shader",
6049
+ code: OVERLAY_COMPOSITE_SHADER_WGSL,
6050
+ });
6051
+ this.overlayCompositeLayout = this.device.createBindGroupLayout({
6052
+ label: "overlay composite layout",
6053
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }],
6054
+ });
6055
+ this.overlayCompositePipeline = this.device.createRenderPipeline({
6056
+ label: "overlay composite pipeline",
6057
+ layout: this.device.createPipelineLayout({
6058
+ label: "overlay composite pipeline layout",
6059
+ bindGroupLayouts: [this.overlayCompositeLayout],
6060
+ }),
6061
+ vertex: { module: compositeShader, entryPoint: "vs" },
6062
+ fragment: {
6063
+ module: compositeShader,
6064
+ entryPoint: "fs",
6065
+ targets: [
6066
+ {
6067
+ format: this.presentationFormat,
6068
+ blend: {
6069
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6070
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6071
+ },
6072
+ },
6073
+ ],
6074
+ },
6075
+ primitive: { topology: "triangle-list" },
6076
+ multisample: { count: 1 },
6077
+ });
6078
+ this.overlayCompositePassDescriptor = {
6079
+ label: "overlay composite pass",
6080
+ colorAttachments: [
6081
+ { view: undefined, loadOp: "load", storeOp: "store" },
6082
+ ],
6083
+ };
6084
+ this.overlayBindGroup = this.device.createBindGroup({
6085
+ label: "overlay bind group",
6086
+ layout: bgLayout,
6087
+ entries: [
6088
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
6089
+ { binding: 1, resource: { buffer: this.overlayUniformBuffer } },
6090
+ ],
6091
+ });
6092
+ // The mesh wireframe: the same line-list target, its own pipeline, because it
6093
+ // draws the model's OWN vertex buffer through the model's OWN skin matrices.
6094
+ // That is the whole reason it exists rather than emitting lines from the
6095
+ // loader's positions — those are bind pose, and a wireframe built from them
6096
+ // sits perfectly on a T-posed model and slides off every animated one.
6097
+ this.wireframeUniformBuffer = this.device.createBuffer({
6098
+ label: "wireframe color",
6099
+ size: 32, // vec4 colour + vec2 viewport + thickness + pad
6100
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6101
+ });
6102
+ this.wireframeSeamUniformBuffer = this.device.createBuffer({
6103
+ label: "wireframe color (material borders)",
6104
+ size: 32,
6105
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6106
+ });
6107
+ this.wireframeHoverUniformBuffer = this.device.createBuffer({
6108
+ label: "wireframe color (hovered material)",
6109
+ size: 32,
6110
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6111
+ });
6112
+ const wireBg0 = this.device.createBindGroupLayout({
6113
+ label: "wireframe group 0 layout (camera + wire)",
6114
+ entries: [
6115
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
6116
+ // Both stages: the FS takes the colour, the VS takes the viewport and
6117
+ // the stroke width it extrudes each edge quad to.
6118
+ {
6119
+ binding: 1,
6120
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
6121
+ buffer: { type: "uniform" },
6122
+ },
6123
+ ],
6124
+ });
6125
+ // Spelled out rather than mapped over a range: tests/bindings.test.mjs reads
6126
+ // these statically to check every bind group covers its layout, and a loop
6127
+ // hides the bindings from it.
6128
+ this.wireframeSkinLayout = this.device.createBindGroupLayout({
6129
+ label: "wireframe group 1 layout (mesh + skin)",
6130
+ entries: [
6131
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
6132
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
6133
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
6134
+ { binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
6135
+ { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
6136
+ ],
6137
+ });
6138
+ const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL });
6139
+ this.wireframePipeline = this.device.createRenderPipeline({
6140
+ label: "wireframe pipeline",
6141
+ layout: this.device.createPipelineLayout({
6142
+ label: "wireframe pipeline layout",
6143
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
6144
+ }),
6145
+ // No vertex stream: an edge quad's corners come from two different model
6146
+ // vertices, so the mesh is read through storage instead.
6147
+ vertex: { module: wireShader, entryPoint: "vs" },
6148
+ fragment: {
6149
+ module: wireShader,
6150
+ entryPoint: "fs",
6151
+ targets: [
6152
+ {
6153
+ format: this.presentationFormat,
6154
+ blend: {
6155
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6156
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6157
+ },
6158
+ },
6159
+ ],
6160
+ },
6161
+ primitive: { topology: "triangle-list", cullMode: "none" },
6162
+ // Depth-TESTED but not written: the mesh is a haze the rig reads against,
6163
+ // so a bone behind a triangle must not be punched out by it.
6164
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
6165
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
6166
+ });
6167
+ // The mesh's own depth, so the wireframe can be occluded by the body it
6168
+ // belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
6169
+ // three's and Babylon's wireframe materials all depth-test, and X-ray is a
6170
+ // toggle beside them. Seeing both walls of a 30k-triangle body at once is
6171
+ // moire, not information.
6172
+ //
6173
+ // It writes depth and nothing else — but it still DECLARES the colour
6174
+ // target, at writeMask 0. A pipeline's attachment state has to match its
6175
+ // pass's, and a pass with a colour attachment will not take a pipeline that
6176
+ // has none. Same trick the scene's own depth prepass uses.
6177
+ //
6178
+ // Its own pass rather than the scene's, because the scene's depth is
6179
+ // multisampled and discarded before the composite.
6180
+ this.wireframeDepthPipeline = this.device.createRenderPipeline({
6181
+ label: "wireframe depth prepass pipeline",
6182
+ layout: this.device.createPipelineLayout({
6183
+ label: "wireframe depth prepass layout",
6184
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
6185
+ }),
6186
+ vertex: {
6187
+ module: wireShader,
6188
+ entryPoint: "vsDepth",
6189
+ buffers: [
6190
+ { arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }] },
6191
+ { arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" }] },
6192
+ { arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" }] },
6193
+ ],
6194
+ },
6195
+ fragment: {
6196
+ module: wireShader,
6197
+ entryPoint: "fs",
6198
+ targets: [{ format: this.presentationFormat, writeMask: 0 }],
6199
+ },
6200
+ primitive: { topology: "triangle-list", cullMode: "none" },
6201
+ depthStencil: {
6202
+ format: "depth24plus",
6203
+ depthWriteEnabled: true,
6204
+ depthCompare: this.depthAhead,
6205
+ // The wireframe lies exactly ON the surface this writes, so every edge
6206
+ // ties with its own triangles and loses wherever rounding goes the wrong
6207
+ // way — lines that break up and shift as the camera turns. Push the
6208
+ // solid mesh back so the edges win their own ties. The slope term is
6209
+ // what handles a surface seen at a grazing angle, where a pixel spans
6210
+ // far more depth than a constant bias can cover.
6211
+ //
6212
+ // SIGNED BY CONVENTION, as the outline hulls are: bias adds to the depth
6213
+ // VALUE, and reversed-Z inverts what a larger value means.
6214
+ depthBias: this.reversedZ ? -64 : 64,
6215
+ depthBiasSlopeScale: this.reversedZ ? -2 : 2,
6216
+ depthBiasClamp: 0,
6217
+ },
6218
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
6219
+ });
6220
+ this.wireframeBindGroup = this.device.createBindGroup({
6221
+ label: "wireframe bind group",
6222
+ layout: wireBg0,
6223
+ entries: [
6224
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
6225
+ { binding: 1, resource: { buffer: this.wireframeUniformBuffer } },
6226
+ ],
6227
+ });
6228
+ this.wireframeSeamBindGroup = this.device.createBindGroup({
6229
+ label: "wireframe bind group (material borders)",
6230
+ layout: wireBg0,
6231
+ entries: [
6232
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
6233
+ { binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
6234
+ ],
6235
+ });
6236
+ this.wireframeHoverBindGroup = this.device.createBindGroup({
6237
+ label: "wireframe bind group (hovered material)",
6238
+ layout: wireBg0,
6239
+ entries: [
6240
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
6241
+ { binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
6242
+ ],
6243
+ });
6244
+ this.overlayPassDescriptor = {
6245
+ label: "overlay pass",
6246
+ timestampWrites: this.stamps("overlay"),
6247
+ colorAttachments: [
6248
+ {
6249
+ view: undefined,
6250
+ resolveTarget: undefined,
6251
+ // Transparent, because this layer is composited over the frame rather
6252
+ // than drawn into it. storeOp discard keeps the 4 samples in tile
6253
+ // memory on a TBDR part — only the resolve reaches RAM.
6254
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
6255
+ loadOp: "clear",
6256
+ storeOp: "discard",
6257
+ },
6258
+ ],
6259
+ depthStencilAttachment: {
6260
+ view: undefined,
6261
+ depthClearValue: this.depthClear,
6262
+ depthLoadOp: "clear",
6263
+ depthStoreOp: "discard",
6264
+ },
6265
+ };
6266
+ }
5887
6267
  // Step 4: Create camera and uniform buffer
5888
6268
  setupCamera() {
5889
6269
  this.cameraUniformBuffer = this.device.createBuffer({
@@ -6028,6 +6408,20 @@ export class Engine {
6028
6408
  * analysis would render silence into the exported video.
6029
6409
  */
6030
6410
  setAudioData(data, bandsPerFrame, secondsPerFrame) {
6411
+ // NOT YET, OR NEVER AGAIN. `device` is definite-assignment: it is undefined
6412
+ // until init() resolves and after dispose(), and TypeScript cannot see
6413
+ // either state. This setter is reached from an ASYNC callback — the audio
6414
+ // analysis, the score fetch, the lyric rasteriser all land whenever they
6415
+ // land — so on a hot reload the in-flight promise of the outgoing engine
6416
+ // resolves against the incoming one, which is holding a ref but has not
6417
+ // finished init. It threw "Cannot read properties of undefined (reading
6418
+ // 'createBuffer')" from a line whose only crime was being fast.
6419
+ //
6420
+ // Dropped rather than queued: every caller here re-pushes on the effect
6421
+ // that owns the asset, and that effect re-runs on the very reload that
6422
+ // caused this.
6423
+ if (!this.device)
6424
+ return;
6031
6425
  if (this.audioBuffer !== this.audioFallbackBuffer)
6032
6426
  this.audioBuffer.destroy();
6033
6427
  if (!data || data.length === 0) {
@@ -6074,6 +6468,20 @@ export class Engine {
6074
6468
  * atlas are both fixed-size, so nothing re-binds whenever lyrics arrive.
6075
6469
  */
6076
6470
  setLyrics(lines, atlas) {
6471
+ // NOT YET, OR NEVER AGAIN. `device` is definite-assignment: it is undefined
6472
+ // until init() resolves and after dispose(), and TypeScript cannot see
6473
+ // either state. This setter is reached from an ASYNC callback — the audio
6474
+ // analysis, the score fetch, the lyric rasteriser all land whenever they
6475
+ // land — so on a hot reload the in-flight promise of the outgoing engine
6476
+ // resolves against the incoming one, which is holding a ref but has not
6477
+ // finished init. It threw "Cannot read properties of undefined (reading
6478
+ // 'createBuffer')" from a line whose only crime was being fast.
6479
+ //
6480
+ // Dropped rather than queued: every caller here re-pushes on the effect
6481
+ // that owns the asset, and that effect re-runs on the very reload that
6482
+ // caused this.
6483
+ if (!this.device)
6484
+ return;
6077
6485
  this.device.queue.writeBuffer(this.lyricsBuffer, 0, packLyrics(lines ?? [], atlas?.rects));
6078
6486
  if (!atlas)
6079
6487
  return;
@@ -6101,6 +6509,20 @@ export class Engine {
6101
6509
  this.device.queue.copyExternalImageToTexture({ source: atlas.source }, { texture: this.lyricsTexture, premultipliedAlpha: true }, [w, h]);
6102
6510
  }
6103
6511
  setMidiNotes(notes, release = 0.35) {
6512
+ // NOT YET, OR NEVER AGAIN. `device` is definite-assignment: it is undefined
6513
+ // until init() resolves and after dispose(), and TypeScript cannot see
6514
+ // either state. This setter is reached from an ASYNC callback — the audio
6515
+ // analysis, the score fetch, the lyric rasteriser all land whenever they
6516
+ // land — so on a hot reload the in-flight promise of the outgoing engine
6517
+ // resolves against the incoming one, which is holding a ref but has not
6518
+ // finished init. It threw "Cannot read properties of undefined (reading
6519
+ // 'createBuffer')" from a line whose only crime was being fast.
6520
+ //
6521
+ // Dropped rather than queued: every caller here re-pushes on the effect
6522
+ // that owns the asset, and that effect re-runs on the very reload that
6523
+ // caused this.
6524
+ if (!this.device)
6525
+ return;
6104
6526
  if (this.midiBuffer !== this.midiFallbackBuffer)
6105
6527
  this.midiBuffer.destroy();
6106
6528
  this.midiRelease = Math.max(0, release);
@@ -6377,6 +6799,14 @@ export class Engine {
6377
6799
  return this.sun;
6378
6800
  }
6379
6801
  addGround(options) {
6802
+ // NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
6803
+ // deferred a frame by useSceneSync's own rAF batching, and a hot reload
6804
+ // that swaps in a new (uninitialized) engine between the schedule and the
6805
+ // callback lands this on a `device` that has not been assigned yet. The
6806
+ // effect that scheduled it re-fires once the new engine is ready, so
6807
+ // dropping this one loses nothing.
6808
+ if (!this.device)
6809
+ return;
6380
6810
  const opts = {
6381
6811
  width: 160,
6382
6812
  height: 160,
@@ -6467,7 +6897,15 @@ export class Engine {
6467
6897
  getLightCount() {
6468
6898
  return this.lightHeader[0];
6469
6899
  }
6900
+ /** Guarded, unlike most private writers here, because its callers are not:
6901
+ * setWorld/setSun are public and can be called before init() finishes
6902
+ * assigning `device` — a scene-settings effect firing on mount races the
6903
+ * engine's own async setup. The state write still lands immediately either
6904
+ * way; only the GPU upload defers, and setupLighting's own writeWorld/
6905
+ * writeSun calls during init pick up whatever was already set. */
6470
6906
  updateLightBuffer() {
6907
+ if (!this.device || !this.lightUniformBuffer)
6908
+ return;
6471
6909
  this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData);
6472
6910
  }
6473
6911
  getStats() {
@@ -6511,6 +6949,19 @@ export class Engine {
6511
6949
  this.canvas.removeEventListener("dblclick", this.handleCanvasDoubleClick);
6512
6950
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch);
6513
6951
  }
6952
+ this.overlayDepthTexture?.destroy();
6953
+ this.overlayDepthTexture = null;
6954
+ this.overlayMsaaTexture?.destroy();
6955
+ this.overlayMsaaTexture = null;
6956
+ this.overlayResolveTexture?.destroy();
6957
+ this.overlayResolveTexture = null;
6958
+ this.overlayInstanceBuffer?.destroy();
6959
+ this.overlayInstanceBuffer = null;
6960
+ for (const inst of this.modelInstances.values()) {
6961
+ for (const edges of inst.wireEdges.values())
6962
+ edges?.buffer.destroy();
6963
+ inst.wireEdges.clear();
6964
+ }
6514
6965
  // Remove gizmo drag listeners
6515
6966
  this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true });
6516
6967
  window.removeEventListener("mousemove", this.handleGizmoMouseMove);
@@ -6906,6 +7357,16 @@ export class Engine {
6906
7357
  setSelectedMaterial(modelName, materialName) {
6907
7358
  this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null;
6908
7359
  }
7360
+ /** Show the transform gizmo on the selected bone. On by default. */
7361
+ setGizmoEnabled(on) {
7362
+ this.gizmoEnabled = on;
7363
+ }
7364
+ /** A pointer-driven preview of a pick, not a pick itself — see pickMaterial
7365
+ * for the click that actually selects one. Cheap: a field write, nothing
7366
+ * rebuilt, safe to call every frame the pointer is over the canvas. */
7367
+ setHoveredMaterial(modelName, materialName) {
7368
+ this.hoverMaterial = modelName && materialName ? { modelName, materialName } : null;
7369
+ }
6909
7370
  setSelectedBone(modelName, boneName) {
6910
7371
  if (!modelName || !boneName) {
6911
7372
  this.selectedBone = null;
@@ -6919,6 +7380,235 @@ export class Engine {
6919
7380
  const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName);
6920
7381
  this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null;
6921
7382
  }
7383
+ // ─── Editor overlays ───────────────────────────────────────────────
7384
+ //
7385
+ // Two ways in. setOverlay takes a list and draws exactly that list, so a host
7386
+ // can paste one in, hand one to a test, or print one back out. The three live
7387
+ // layers name a model instead and are rebuilt from its pose every frame,
7388
+ // which is the only way a skeleton overlay can be right on an animated model.
7389
+ /**
7390
+ * Replace one named layer of overlay primitives. World space, drawn as given
7391
+ * until it is replaced. An empty list removes the layer.
7392
+ */
7393
+ setOverlay(layer, primitives) {
7394
+ if (primitives.length === 0)
7395
+ this.overlayLayers.delete(layer);
7396
+ else
7397
+ this.overlayLayers.set(layer, primitives);
7398
+ }
7399
+ /** Drop one named layer, or every one. Live layers keep drawing. */
7400
+ clearOverlay(layer) {
7401
+ if (layer === undefined)
7402
+ this.overlayLayers.clear();
7403
+ else
7404
+ this.overlayLayers.delete(layer);
7405
+ }
7406
+ /**
7407
+ * The bone whose marker is nearest a point on the canvas, or null.
7408
+ *
7409
+ * On the CPU, and exact. A few hundred bones with known world positions is a
7410
+ * loop, not a render pass — and having the answer synchronously is what makes
7411
+ * cycling through overlapping bones possible at all. Only VERTICES justify GPU
7412
+ * picking, at tens of thousands.
7413
+ *
7414
+ * `x`/`y` are CSS pixels relative to the canvas, which is what a MouseEvent
7415
+ * gives once getBoundingClientRect is subtracted.
7416
+ *
7417
+ * It projects boneMarkerPositions, the same points the overlay draws markers
7418
+ * at, so the hit box cannot drift away from the circle you are aiming at.
7419
+ */
7420
+ pickBone(x, y, options = {}) {
7421
+ if (!this.camera)
7422
+ return null;
7423
+ const width = this.canvas.clientWidth;
7424
+ const height = this.canvas.clientHeight;
7425
+ if (width <= 0 || height <= 0)
7426
+ return null;
7427
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
7428
+ let best = null;
7429
+ let bestDist = options.radiusPx ?? 14;
7430
+ let bestDepth = Infinity;
7431
+ for (const inst of this.modelInstances.values()) {
7432
+ if (options.modelName !== undefined && inst.name !== options.modelName)
7433
+ continue;
7434
+ if (inst.isStage || inst.isPlane)
7435
+ continue;
7436
+ const bones = inst.model.getSkeleton().bones;
7437
+ this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch);
7438
+ const pos = this.bonePickScratch;
7439
+ for (let i = 0; i < bones.length; i++) {
7440
+ const px = pos[i * 3];
7441
+ const py = pos[i * 3 + 1];
7442
+ const pz = pos[i * 3 + 2];
7443
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
7444
+ if (cw <= 1e-6)
7445
+ continue; // behind the camera
7446
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
7447
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
7448
+ const sx = ((cx / cw) * 0.5 + 0.5) * width;
7449
+ const sy = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
7450
+ const d = Math.hypot(sx - x, sy - y);
7451
+ if (d > bestDist)
7452
+ continue;
7453
+ // Within a couple of pixels the two are the same click, and MMD stacks
7454
+ // control bones on one point — so the nearer bone takes it.
7455
+ if (d < bestDist - 2 || cw < bestDepth) {
7456
+ best = { modelName: inst.name, boneName: bones[i].name, boneIndex: i };
7457
+ bestDist = d;
7458
+ bestDepth = cw;
7459
+ }
7460
+ }
7461
+ }
7462
+ return best;
7463
+ }
7464
+ /**
7465
+ * The material under a point on the canvas, or null for a miss.
7466
+ *
7467
+ * On the CPU, like pickBone, and for the same reason: a click (or a hover) is
7468
+ * rare and an answer you have synchronously is worth more than one that
7469
+ * arrives a frame later. Tens of thousands of triangles is a loop that costs
7470
+ * a few milliseconds ONCE, against a GPU id pass that costs an attachment and
7471
+ * a readback every frame whether anyone is pointing at the model or not.
7472
+ *
7473
+ * Skinned on the CPU with getSkinMatrices — the same matrices the vertex
7474
+ * shader uses — so the pick lands on the POSED mesh. Bind-pose geometry would
7475
+ * be right on a T-posed model and wrong on every animated one, which is
7476
+ * exactly when someone is clicking around a costume.
7477
+ *
7478
+ * Morph offsets are NOT applied: they move a face, never move it into another
7479
+ * material, and reading them back per click would cost more than the pick.
7480
+ *
7481
+ * `x`/`y` are CSS pixels relative to the canvas, as pickBone takes them.
7482
+ */
7483
+ pickMaterial(x, y, options = {}) {
7484
+ if (!this.camera)
7485
+ return null;
7486
+ const width = this.canvas.clientWidth;
7487
+ const height = this.canvas.clientHeight;
7488
+ if (width <= 0 || height <= 0)
7489
+ return null;
7490
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
7491
+ let best = null;
7492
+ let bestDepth = Infinity;
7493
+ for (const inst of this.modelInstances.values()) {
7494
+ if (options.modelName !== undefined && inst.name !== options.modelName)
7495
+ continue;
7496
+ if (inst.isStage || inst.isPlane)
7497
+ continue;
7498
+ const model = inst.model;
7499
+ const { positions } = model.getGeometry();
7500
+ const count = positions.length / 3;
7501
+ const { joints, weights } = model.getSkinning();
7502
+ const skin = model.getSkinMatrices();
7503
+ // Project every vertex ONCE into screen x, y and clip w. The triangle
7504
+ // loop then reads three of these rather than re-skinning shared vertices
7505
+ // — a closed mesh uses each vertex about six times.
7506
+ if (!this.materialPickScratch || this.materialPickScratch.length !== count * 3) {
7507
+ this.materialPickScratch = new Float32Array(count * 3);
7508
+ }
7509
+ const proj = this.materialPickScratch;
7510
+ for (let v = 0; v < count; v++) {
7511
+ const bx = positions[v * 3];
7512
+ const by = positions[v * 3 + 1];
7513
+ const bz = positions[v * 3 + 2];
7514
+ let px = 0;
7515
+ let py = 0;
7516
+ let pz = 0;
7517
+ for (let k = 0; k < 4; k++) {
7518
+ const w = weights[v * 4 + k] / 255;
7519
+ if (w === 0)
7520
+ continue;
7521
+ const m = joints[v * 4 + k] * 16;
7522
+ px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12]);
7523
+ py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13]);
7524
+ pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14]);
7525
+ }
7526
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
7527
+ proj[v * 3 + 2] = cw;
7528
+ if (cw <= 1e-6)
7529
+ continue;
7530
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
7531
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
7532
+ proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width;
7533
+ proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
7534
+ }
7535
+ // Point-in-triangle in SCREEN space, nearest w wins. The same projection
7536
+ // pickBone uses, so the two agree about where things are, and it needs no
7537
+ // inverse view-projection to build a ray from.
7538
+ const indices = model.getIndices();
7539
+ const materials = model.getMaterials();
7540
+ let m = 0;
7541
+ let matEnd = materials.length > 0 ? materials[0].vertexCount : indices.length;
7542
+ for (let i = 0; i + 2 < indices.length; i += 3) {
7543
+ while (i >= matEnd && m + 1 < materials.length) {
7544
+ m++;
7545
+ matEnd += materials[m].vertexCount;
7546
+ }
7547
+ const a = indices[i] * 3;
7548
+ const b = indices[i + 1] * 3;
7549
+ const c = indices[i + 2] * 3;
7550
+ if (proj[a + 2] <= 1e-6 || proj[b + 2] <= 1e-6 || proj[c + 2] <= 1e-6)
7551
+ continue;
7552
+ const ax = proj[a];
7553
+ const ay = proj[a + 1];
7554
+ const bx = proj[b];
7555
+ const by = proj[b + 1];
7556
+ const cx2 = proj[c];
7557
+ const cy2 = proj[c + 1];
7558
+ // Barycentric sign test, both windings: PMX faces are one winding but a
7559
+ // double-sided material is legitimately seen from behind.
7560
+ const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by);
7561
+ const d2 = (x - cx2) * (by - cy2) - (bx - cx2) * (y - cy2);
7562
+ const d3 = (x - ax) * (cy2 - ay) - (cx2 - ax) * (y - ay);
7563
+ const neg = d1 < 0 || d2 < 0 || d3 < 0;
7564
+ const pos = d1 > 0 || d2 > 0 || d3 > 0;
7565
+ if (neg && pos)
7566
+ continue;
7567
+ const depth = (proj[a + 2] + proj[b + 2] + proj[c + 2]) / 3;
7568
+ if (depth >= bestDepth)
7569
+ continue;
7570
+ bestDepth = depth;
7571
+ best = { modelName: inst.name, materialName: materials[m].name, materialIndex: m };
7572
+ }
7573
+ }
7574
+ return best;
7575
+ }
7576
+ /** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
7577
+ setBoneOverlay(modelName, options = {}) {
7578
+ this.overlayBones = modelName ? { modelName, options } : null;
7579
+ }
7580
+ /** Draw every rigidbody of `modelName` where the simulation has it, rebuilt
7581
+ * each frame. Null off. */
7582
+ setRigidbodyOverlay(modelName, options = {}) {
7583
+ this.overlayBodies = modelName ? { modelName, options } : null;
7584
+ }
7585
+ /** Draw a cross per joint of `modelName` plus dashed lines to the bodies it
7586
+ * holds together, rebuilt each frame. Null off. */
7587
+ setJointOverlay(modelName, options = {}) {
7588
+ this.overlayJoints = modelName ? { modelName, options } : null;
7589
+ }
7590
+ /**
7591
+ * Draw `modelName`'s mesh as a wireframe — its vertices and its topology.
7592
+ *
7593
+ * Skinned on the GPU from the model's own vertex buffer and skin matrices, so
7594
+ * it sits on the POSED mesh. The loader's CPU-side positions are bind pose: a
7595
+ * wireframe built from those looks right on a T-posed model and slides off
7596
+ * every animated one, which is exactly the state a user is in while looking at
7597
+ * weights.
7598
+ *
7599
+ * The edge list is deduplicated and built once, on the first frame this is on.
7600
+ *
7601
+ * `material` narrows the wireframe to one material's faces. The mesh still
7602
+ * writes depth in full, so the material reads as part of the body rather than
7603
+ * as a shell floating in front of it — which is the point of scoping it: you
7604
+ * are asking where this material's faces ARE, and an answer that ignores the
7605
+ * torso in front of them is not one.
7606
+ */
7607
+ setVertexOverlay(modelName, options = {}) {
7608
+ this.overlayVertices = modelName
7609
+ ? { modelName, xray: options.xray ?? false, material: options.material ?? null }
7610
+ : null;
7611
+ }
6922
7612
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
6923
7613
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
6924
7614
  /** A group's uniform buffer and its maps have the same lifetime — freeing one
@@ -7005,8 +7695,26 @@ export class Engine {
7005
7695
  getIKEnabled() {
7006
7696
  return this.ikEnabled;
7007
7697
  }
7698
+ /**
7699
+ * Run the solver, or stop it.
7700
+ *
7701
+ * Turning it OFF snaps every body back onto its bone. Merely halting the step
7702
+ * leaves hair and skirts hanging wherever the simulation happened to be — a
7703
+ * pose nothing in the document describes, which is the opposite of what "off"
7704
+ * is asked for: you switch physics off to see what the RIG does, and a frozen
7705
+ * mid-swing is still the solver's answer, just a stale one.
7706
+ */
7008
7707
  setPhysicsEnabled(enabled) {
7708
+ if (this.physicsEnabled === enabled)
7709
+ return;
7009
7710
  this.physicsEnabled = enabled;
7711
+ if (enabled)
7712
+ return;
7713
+ for (const inst of this.modelInstances.values()) {
7714
+ if (!inst.physics)
7715
+ continue;
7716
+ inst.physics.reset(inst.model.getWorldMatrices());
7717
+ }
7010
7718
  }
7011
7719
  getPhysicsEnabled() {
7012
7720
  return this.physicsEnabled;
@@ -8049,13 +8757,15 @@ export class Engine {
8049
8757
  const jointsBuffer = this.device.createBuffer({
8050
8758
  label: `${name}: joints buffer`,
8051
8759
  size: skinning.joints.byteLength,
8052
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
8760
+ // STORAGE so the wireframe overlay can skin from it: its quads read two
8761
+ // different model vertices per corner, which no vertex stream can supply.
8762
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
8053
8763
  });
8054
8764
  this.device.queue.writeBuffer(jointsBuffer, 0, skinning.joints.buffer, skinning.joints.byteOffset, skinning.joints.byteLength);
8055
8765
  const weightsBuffer = this.device.createBuffer({
8056
8766
  label: `${name}: weights buffer`,
8057
8767
  size: skinning.weights.byteLength,
8058
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
8768
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
8059
8769
  });
8060
8770
  this.device.queue.writeBuffer(weightsBuffer, 0, skinning.weights.buffer, skinning.weights.byteOffset, skinning.weights.byteLength);
8061
8771
  const skinMatrixBuffer = this.device.createBuffer({
@@ -8142,6 +8852,7 @@ export class Engine {
8142
8852
  jointsBuffer,
8143
8853
  weightsBuffer,
8144
8854
  skinMatrixBuffer,
8855
+ wireEdges: new Map(),
8145
8856
  drawCalls: [],
8146
8857
  shadowDrawCalls: [],
8147
8858
  shadowBindGroups,
@@ -9012,6 +9723,407 @@ export class Engine {
9012
9723
  epass.draw(3);
9013
9724
  epass.end();
9014
9725
  }
9726
+ // Unique edges of the mesh, as a line-list index buffer. Each interior edge is
9727
+ // shared by two triangles, so deduplicating halves both the buffer and the
9728
+ // draw. Built once per model, on the first frame its wireframe is asked for.
9729
+ /** The index run `material` owns, or the whole list when it is null. Materials
9730
+ * are consecutive runs in declaration order, so the offset is a prefix sum —
9731
+ * the same walk the draw list does. Returns null for a name the model does
9732
+ * not have, which is what a stale selection looks like after a reload. */
9733
+ materialIndexRange(inst, material) {
9734
+ const indices = inst.model.getIndices();
9735
+ if (!material)
9736
+ return [0, indices.length];
9737
+ let offset = 0;
9738
+ for (const m of inst.model.getMaterials()) {
9739
+ if (m.name === material)
9740
+ return [offset, offset + m.vertexCount];
9741
+ offset += m.vertexCount;
9742
+ }
9743
+ return null;
9744
+ }
9745
+ /**
9746
+ * @param material one material's own edges, or null for the whole mesh
9747
+ * @param seams every material's OUTLINE instead — the borders between them
9748
+ */
9749
+ ensureEdgeBuffer(inst, material, seams = false) {
9750
+ const key = material ?? (seams ? Engine.SEAM_KEY : "");
9751
+ if (inst.wireEdges.has(key))
9752
+ return inst.wireEdges.get(key) !== null;
9753
+ const indices = inst.model.getIndices();
9754
+ const vertexCount = inst.model.getGeometry().positions.length / 3;
9755
+ const edges = [];
9756
+ if (seams && !material) {
9757
+ // Inside one material's run an interior edge belongs to two triangles and
9758
+ // a border edge to one, so counting uses within the run and keeping the
9759
+ // singles gives exactly that material's outline.
9760
+ //
9761
+ // Per run, never over the whole mesh: an edge two materials share is
9762
+ // interior to the model and a border to both, and only the per-run count
9763
+ // can tell those two cases apart.
9764
+ const used = new Map();
9765
+ const seen = new Set();
9766
+ let offset = 0;
9767
+ for (const m of inst.model.getMaterials()) {
9768
+ const end = offset + m.vertexCount;
9769
+ used.clear();
9770
+ const bump = (a, b) => {
9771
+ const k = (a < b ? a : b) * vertexCount + (a < b ? b : a);
9772
+ used.set(k, (used.get(k) ?? 0) + 1);
9773
+ };
9774
+ for (let i = offset; i + 2 < end; i += 3) {
9775
+ bump(indices[i], indices[i + 1]);
9776
+ bump(indices[i + 1], indices[i + 2]);
9777
+ bump(indices[i + 2], indices[i]);
9778
+ }
9779
+ for (const [k, count] of used) {
9780
+ if (count !== 1 || seen.has(k))
9781
+ continue;
9782
+ seen.add(k);
9783
+ edges.push(Math.floor(k / vertexCount), k % vertexCount);
9784
+ }
9785
+ offset = end;
9786
+ }
9787
+ }
9788
+ else {
9789
+ const range = this.materialIndexRange(inst, material);
9790
+ if (!range)
9791
+ return false;
9792
+ const [start, end] = range;
9793
+ const seen = new Set();
9794
+ const add = (a, b) => {
9795
+ const lo = a < b ? a : b;
9796
+ const hi = a < b ? b : a;
9797
+ const k = lo * vertexCount + hi;
9798
+ if (seen.has(k))
9799
+ return;
9800
+ seen.add(k);
9801
+ edges.push(lo, hi);
9802
+ };
9803
+ for (let i = start; i + 2 < end; i += 3) {
9804
+ add(indices[i], indices[i + 1]);
9805
+ add(indices[i + 1], indices[i + 2]);
9806
+ add(indices[i + 2], indices[i]);
9807
+ }
9808
+ }
9809
+ if (edges.length === 0) {
9810
+ inst.wireEdges.set(key, null);
9811
+ return false;
9812
+ }
9813
+ const data = new Uint32Array(edges);
9814
+ const buffer = this.device.createBuffer({
9815
+ label: `wireframe edges ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
9816
+ size: data.byteLength,
9817
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
9818
+ });
9819
+ this.device.queue.writeBuffer(buffer, 0, data);
9820
+ const bindGroup = this.device.createBindGroup({
9821
+ label: `wireframe mesh ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
9822
+ layout: this.wireframeSkinLayout,
9823
+ entries: [
9824
+ { binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
9825
+ { binding: 1, resource: { buffer: inst.vertexBuffer } },
9826
+ { binding: 2, resource: { buffer: inst.jointsBuffer } },
9827
+ { binding: 3, resource: { buffer: inst.weightsBuffer } },
9828
+ { binding: 4, resource: { buffer } },
9829
+ ],
9830
+ });
9831
+ inst.wireEdges.set(key, { buffer, count: edges.length, bindGroup });
9832
+ return true;
9833
+ }
9834
+ renderWireframe(pass) {
9835
+ if (!this.overlayVertices)
9836
+ return;
9837
+ const inst = this.overlayModel(this.overlayVertices.modelName);
9838
+ const { material, xray } = this.overlayVertices;
9839
+ if (!inst || !this.ensureEdgeBuffer(inst, material))
9840
+ return;
9841
+ const edges = inst.wireEdges.get(material ?? "");
9842
+ if (!edges)
9843
+ return;
9844
+ // The whole mesh gets its material BORDERS drawn over it — that is what
9845
+ // makes the view read as every material at once rather than as one body of
9846
+ // undifferentiated lines. A single material asked for by name is already
9847
+ // one material, so it needs no borders to separate it from anything.
9848
+ const seams = material === null && this.ensureEdgeBuffer(inst, null, true)
9849
+ ? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
9850
+ : null;
9851
+ // A pointer over a material previews EXACTLY what clicking it would pick —
9852
+ // the same self-occluding reveal, layered over the section-wide view rather
9853
+ // than replacing it, so the rest of the mesh stays legible while one
9854
+ // material calls attention to itself. Meaningless once something IS
9855
+ // picked, since only the picked material draws at all then.
9856
+ const hm = this.hoverMaterial;
9857
+ const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null;
9858
+ const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null;
9859
+ const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName) : null;
9860
+ this.wireframeColorData.set(DEFAULT_VERTEX_COLOR);
9861
+ this.wireframeColorData[4] = this.canvas.width;
9862
+ this.wireframeColorData[5] = this.canvas.height;
9863
+ this.wireframeColorData[6] = OVERLAY_STYLE.meshStrokePx;
9864
+ // The triangulation steps back only when there is something drawn over it
9865
+ // to step back FROM.
9866
+ if (seams)
9867
+ this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3] * OVERLAY_STYLE.meshAlpha;
9868
+ this.device.queue.writeBuffer(this.wireframeUniformBuffer, 0, this.wireframeColorData);
9869
+ if (seams) {
9870
+ this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3];
9871
+ this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx;
9872
+ this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData);
9873
+ }
9874
+ if (hover) {
9875
+ this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx;
9876
+ this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData);
9877
+ }
9878
+ // Both bind groups, before the FIRST draw call below, regardless of which
9879
+ // branch runs first — the depth prepass reads the camera from group 0 and
9880
+ // the skin matrices from group 1 same as the edge pass does, and every
9881
+ // draw in this function needs both set to SOMETHING before it runs. Each
9882
+ // block below is free to swap either one out for its own draws.
9883
+ pass.setBindGroup(0, this.wireframeBindGroup);
9884
+ pass.setBindGroup(1, edges.bindGroup);
9885
+ const bindMesh = () => {
9886
+ pass.setPipeline(this.wireframeDepthPipeline);
9887
+ pass.setVertexBuffer(0, inst.vertexBuffer);
9888
+ pass.setVertexBuffer(1, inst.jointsBuffer);
9889
+ pass.setVertexBuffer(2, inst.weightsBuffer);
9890
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
9891
+ };
9892
+ // The hover preview writes and draws against its OWN depth first, while the
9893
+ // shared depth buffer is still empty — the same trick a pick uses, run
9894
+ // before the base mesh below gets a chance to occlude it. The base mesh's
9895
+ // depth write further down repeats the SAME geometry for these faces
9896
+ // (identical z), so it neither disturbs this nor needs to skip them.
9897
+ if (hover && hoverRange && !xray) {
9898
+ bindMesh();
9899
+ pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0]);
9900
+ pass.setBindGroup(0, this.wireframeHoverBindGroup);
9901
+ pass.setBindGroup(1, hover.bindGroup);
9902
+ pass.setPipeline(this.wireframePipeline);
9903
+ pass.draw(6, hover.count / 2);
9904
+ pass.setBindGroup(0, this.wireframeBindGroup);
9905
+ pass.setBindGroup(1, edges.bindGroup);
9906
+ }
9907
+ // What writes depth is what is allowed to hide the wireframe, and that
9908
+ // differs between the two views.
9909
+ //
9910
+ // The whole mesh, so the far wall of a 30k-triangle body does not draw on
9911
+ // top of the near one — occluded is the default everywhere, Blender's edit
9912
+ // mode and Maya included, and seeing both walls at once is moire rather than
9913
+ // information.
9914
+ //
9915
+ // A PICKED material writes only its OWN faces. The question a pick asks is
9916
+ // where this material is, and half of it is usually under a coat; letting
9917
+ // the coat hide it does not answer that. Its own depth still goes in, so its
9918
+ // back faces stay hidden and it reads as an object instead of a haze —
9919
+ // which is the difference between this and turning x-ray on.
9920
+ const range = material !== null ? this.materialIndexRange(inst, material) : null;
9921
+ if (!xray) {
9922
+ bindMesh();
9923
+ if (range)
9924
+ pass.drawIndexed(range[1] - range[0], 1, range[0]);
9925
+ else
9926
+ pass.drawIndexed(inst.model.getIndices().length);
9927
+ }
9928
+ // Six vertices an edge, instanced.
9929
+ pass.setPipeline(this.wireframePipeline);
9930
+ pass.draw(6, edges.count / 2);
9931
+ if (seams) {
9932
+ pass.setBindGroup(0, this.wireframeSeamBindGroup);
9933
+ pass.setBindGroup(1, seams.bindGroup);
9934
+ pass.draw(6, seams.count / 2);
9935
+ }
9936
+ }
9937
+ /** The bone overlay's options with `selected` filled in from setSelectedBone,
9938
+ * so clicking a bone highlights it without the host mirroring the state. An
9939
+ * explicit `selected` in the options still wins. */
9940
+ boneOverlayOptions(modelName) {
9941
+ const options = this.overlayBones?.options ?? {};
9942
+ if (options.selected !== undefined)
9943
+ return options;
9944
+ const chosen = this.selectedBone?.modelName === modelName ? this.selectedBone.boneName : null;
9945
+ this.boneOptionsScratch.selected = chosen;
9946
+ this.boneOptionsScratch.include = options.include;
9947
+ return this.boneOptionsScratch;
9948
+ }
9949
+ overlayActive() {
9950
+ return (this.overlayLayers.size > 0 ||
9951
+ this.overlayBones !== null ||
9952
+ this.overlayBodies !== null ||
9953
+ this.overlayJoints !== null ||
9954
+ this.overlayVertices !== null);
9955
+ }
9956
+ overlayModel(name) {
9957
+ return this.modelInstances.get(name) ?? null;
9958
+ }
9959
+ /** The primitives a live layer would draw right now. Same list the pass uses,
9960
+ * so a host can show it as data, diff it, or hit-test it on the CPU. */
9961
+ getOverlayPrimitives(layer) {
9962
+ if (layer === "bones") {
9963
+ const inst = this.overlayBones ? this.overlayModel(this.overlayBones.modelName) : null;
9964
+ return inst ? boneOverlay(inst.model, this.boneOverlayOptions(inst.name)) : [];
9965
+ }
9966
+ if (layer === "rigidbodies") {
9967
+ const inst = this.overlayBodies ? this.overlayModel(this.overlayBodies.modelName) : null;
9968
+ return inst ? rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options) : [];
9969
+ }
9970
+ const inst = this.overlayJoints ? this.overlayModel(this.overlayJoints.modelName) : null;
9971
+ return inst ? jointOverlay(inst.model, inst.physics, this.overlayJoints.options) : [];
9972
+ }
9973
+ // The overlay's own layer: a 4x multisampled colour target, its resolve, and a
9974
+ // matching depth. All three are allocated the first frame an overlay is
9975
+ // actually on, so a scene that never shows one never pays for any of it.
9976
+ ensureOverlayTargets(width, height) {
9977
+ if (this.overlayResolveTexture && this.overlayTargetSize[0] === width && this.overlayTargetSize[1] === height) {
9978
+ return;
9979
+ }
9980
+ const samples = Engine.OVERLAY_SAMPLE_COUNT;
9981
+ this.overlayDepthTexture?.destroy();
9982
+ this.overlayMsaaTexture?.destroy();
9983
+ this.overlayResolveTexture?.destroy();
9984
+ this.overlayMsaaTexture = this.device.createTexture({
9985
+ label: "overlay msaa",
9986
+ size: [width, height],
9987
+ sampleCount: samples,
9988
+ format: this.presentationFormat,
9989
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
9990
+ });
9991
+ this.overlayResolveTexture = this.device.createTexture({
9992
+ label: "overlay resolve",
9993
+ size: [width, height],
9994
+ format: this.presentationFormat,
9995
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
9996
+ });
9997
+ this.overlayDepthTexture = this.device.createTexture({
9998
+ label: "overlay depth",
9999
+ size: [width, height],
10000
+ sampleCount: samples,
10001
+ format: "depth24plus",
10002
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
10003
+ });
10004
+ this.overlayTargetSize = [width, height];
10005
+ const colorAtt = this.overlayPassDescriptor.colorAttachments[0];
10006
+ colorAtt.view = this.overlayMsaaTexture.createView();
10007
+ colorAtt.resolveTarget = this.overlayResolveTexture.createView();
10008
+ const depthAtt = this.overlayPassDescriptor.depthStencilAttachment;
10009
+ depthAtt.view = this.overlayDepthTexture.createView();
10010
+ this.overlayCompositeBindGroup = this.device.createBindGroup({
10011
+ label: "overlay composite bind group",
10012
+ layout: this.overlayCompositeLayout,
10013
+ entries: [{ binding: 0, resource: this.overlayResolveTexture.createView() }],
10014
+ });
10015
+ }
10016
+ ensureOverlayInstanceCapacity(count) {
10017
+ if (this.overlayInstanceBuffer && this.overlayInstanceCapacity >= count)
10018
+ return;
10019
+ // Grow in powers of two so a skirt gaining bodies one at a time does not
10020
+ // reallocate once per body.
10021
+ let capacity = Math.max(64, this.overlayInstanceCapacity || 64);
10022
+ while (capacity < count)
10023
+ capacity *= 2;
10024
+ this.overlayInstanceBuffer?.destroy();
10025
+ this.overlayInstanceBuffer = this.device.createBuffer({
10026
+ label: "overlay instance buffer",
10027
+ size: capacity * OVERLAY_INSTANCE_FLOATS * 4,
10028
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
10029
+ });
10030
+ this.overlayInstanceCapacity = capacity;
10031
+ this.overlayInstanceData = new Float32Array(capacity * OVERLAY_INSTANCE_FLOATS);
10032
+ }
10033
+ // Collects every layer, groups it by shape so each shape is one instanced
10034
+ // draw, and runs them into the swapchain over the finished frame.
10035
+ renderOverlayPass(encoder, swapchainView) {
10036
+ if (!this.camera)
10037
+ return;
10038
+ const byShape = this.overlayByShape;
10039
+ for (const shape of OVERLAY_SHAPES) {
10040
+ const list = byShape.get(shape);
10041
+ if (list)
10042
+ list.length = 0;
10043
+ }
10044
+ let total = 0;
10045
+ const collect = (primitives) => {
10046
+ for (const primitive of primitives) {
10047
+ let list = byShape.get(primitive.shape);
10048
+ if (!list) {
10049
+ list = [];
10050
+ byShape.set(primitive.shape, list);
10051
+ }
10052
+ list.push(primitive);
10053
+ total++;
10054
+ }
10055
+ };
10056
+ for (const layer of this.overlayLayers.values())
10057
+ collect(layer);
10058
+ if (this.overlayBones) {
10059
+ const inst = this.overlayModel(this.overlayBones.modelName);
10060
+ if (inst)
10061
+ collect(boneOverlay(inst.model, this.boneOverlayOptions(inst.name)));
10062
+ }
10063
+ if (this.overlayBodies) {
10064
+ const inst = this.overlayModel(this.overlayBodies.modelName);
10065
+ if (inst)
10066
+ collect(rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options));
10067
+ }
10068
+ if (this.overlayJoints) {
10069
+ const inst = this.overlayModel(this.overlayJoints.modelName);
10070
+ if (inst)
10071
+ collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options));
10072
+ }
10073
+ if (total === 0 && !this.overlayVertices)
10074
+ return;
10075
+ this.ensureOverlayInstanceCapacity(total);
10076
+ const data = this.overlayInstanceData;
10077
+ const draws = [];
10078
+ let written = 0;
10079
+ for (const shape of OVERLAY_SHAPES) {
10080
+ const list = byShape.get(shape);
10081
+ if (!list || list.length === 0)
10082
+ continue;
10083
+ draws.push({ shape, first: written, count: list.length });
10084
+ for (const primitive of list) {
10085
+ writeOverlayInstance(primitive, data, written * OVERLAY_INSTANCE_FLOATS);
10086
+ written++;
10087
+ }
10088
+ }
10089
+ this.device.queue.writeBuffer(this.overlayInstanceBuffer, 0, data.buffer, data.byteOffset, written * OVERLAY_INSTANCE_FLOATS * 4);
10090
+ const width = this.canvas.width;
10091
+ const height = this.canvas.height;
10092
+ this.ensureOverlayTargets(width, height);
10093
+ this.overlayUniformData[0] = width;
10094
+ this.overlayUniformData[1] = height;
10095
+ this.overlayUniformData[2] = Engine.OVERLAY_DASH_PERIOD_PX;
10096
+ this.device.queue.writeBuffer(this.overlayUniformBuffer, 0, this.overlayUniformData);
10097
+ const pass = encoder.beginRenderPass(this.overlayPassDescriptor);
10098
+ // Under everything: the mesh is the haze the rig is read against.
10099
+ this.renderWireframe(pass);
10100
+ pass.setBindGroup(0, this.overlayBindGroup);
10101
+ pass.setVertexBuffer(0, this.overlayVertexBuffer);
10102
+ pass.setVertexBuffer(1, this.overlayInstanceBuffer);
10103
+ // Volumes first and without depth writes, then the line work over them.
10104
+ for (const solid of [true, false]) {
10105
+ let bound = false;
10106
+ for (const draw of draws) {
10107
+ if (OVERLAY_SOLID_SHAPES.has(draw.shape) !== solid)
10108
+ continue;
10109
+ if (!bound) {
10110
+ pass.setPipeline(solid ? this.overlaySolidPipeline : this.overlayPipeline);
10111
+ bound = true;
10112
+ }
10113
+ const range = this.overlayGeometry.ranges[draw.shape];
10114
+ pass.draw(range.count, draw.count, range.first, draw.first);
10115
+ }
10116
+ }
10117
+ pass.end();
10118
+ // The resolved layer over the finished frame, premultiplied.
10119
+ const compositeAtt = this.overlayCompositePassDescriptor.colorAttachments[0];
10120
+ compositeAtt.view = swapchainView;
10121
+ const composite = encoder.beginRenderPass(this.overlayCompositePassDescriptor);
10122
+ composite.setPipeline(this.overlayCompositePipeline);
10123
+ composite.setBindGroup(0, this.overlayCompositeBindGroup);
10124
+ composite.draw(3);
10125
+ composite.end();
10126
+ }
9015
10127
  // Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
9016
10128
  // then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
9017
10129
  // aligns rings with the bone's current world orientation, so clicking a ring
@@ -9659,7 +10771,11 @@ export class Engine {
9659
10771
  this.renderIdDebugPass(encoder, swapchainView);
9660
10772
  if (this.selectedMaterial && hasModels)
9661
10773
  this.renderSelectionPasses(encoder, swapchainView);
9662
- if (this.selectedBone && hasModels)
10774
+ // Under the gizmo: the handles you drag stay on top of the rig you are
10775
+ // reading them against.
10776
+ if (this.overlayActive())
10777
+ this.renderOverlayPass(encoder, swapchainView);
10778
+ if (this.gizmoEnabled && this.selectedBone && hasModels)
9663
10779
  this.renderGizmoPass(encoder, swapchainView);
9664
10780
  const pick = this.pendingPick;
9665
10781
  if (pick && hasModels)
@@ -10945,6 +12061,13 @@ export class Engine {
10945
12061
  }
10946
12062
  }
10947
12063
  Engine.instance = null;
12064
+ /** The overlay renders multisampled into its own layer; the scene's own depth
12065
+ * is discarded before the composite (see the depthRead note in render), so it
12066
+ * could not have shared either that or the single-sample swapchain. */
12067
+ Engine.OVERLAY_SAMPLE_COUNT = 4;
12068
+ /** Dash period in device pixels — dashes are geometry, so this is only the
12069
+ * reference the dashedLine shape is cut against. */
12070
+ Engine.OVERLAY_DASH_PERIOD_PX = 8.0;
10948
12071
  Engine.GIZMO_RING_SEGMENTS = 96;
10949
12072
  Engine.GIZMO_RING_RADIUS = 0.8;
10950
12073
  // Axis visible length (relative to gizmo size). Extends past ring radius so
@@ -11100,6 +12223,7 @@ Engine.TIMED_PASSES = [
11100
12223
  "field",
11101
12224
  "bloom",
11102
12225
  "composite",
12226
+ "overlay",
11103
12227
  ];
11104
12228
  // ── The floor mirror (step 7C) ──
11105
12229
  // Half-res scene-contract attachments a mirrored draw renders into, plus the
@@ -11124,3 +12248,6 @@ Engine.CULL_ARG_WORDS = 5;
11124
12248
  Engine.CULL_DRAW_CASTS_SHADOW = 1;
11125
12249
  Engine.CULL_MODEL_VISIBLE = 1;
11126
12250
  Engine.CULL_MODEL_RIGID = 2;
12251
+ /** A cache key no material can collide with — a PMX name is never empty and
12252
+ * never contains a NUL. */
12253
+ Engine.SEAM_KEY = "\u0000seams";