reze-engine 0.54.16 → 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 (49) hide show
  1. package/dist/engine.d.ts +210 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1063 -11
  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/passes/field-blit.d.ts +26 -0
  25. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  26. package/dist/shaders/passes/field-blit.js +65 -0
  27. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  28. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  29. package/dist/shaders/passes/ground-noise.js +88 -0
  30. package/dist/shaders/passes/overlay.d.ts +3 -0
  31. package/dist/shaders/passes/overlay.d.ts.map +1 -0
  32. package/dist/shaders/passes/overlay.js +164 -0
  33. package/dist/shaders/passes/sim.d.ts +34 -0
  34. package/dist/shaders/passes/sim.d.ts.map +1 -0
  35. package/dist/shaders/passes/sim.js +169 -0
  36. package/dist/shaders/passes/wireframe.d.ts +2 -0
  37. package/dist/shaders/passes/wireframe.d.ts.map +1 -0
  38. package/dist/shaders/passes/wireframe.js +107 -0
  39. package/dist/shaders/score-api.d.ts +10 -0
  40. package/dist/shaders/score-api.d.ts.map +1 -0
  41. package/dist/shaders/score-api.js +114 -0
  42. package/package.json +1 -1
  43. package/src/engine.ts +1147 -13
  44. package/src/index.ts +50 -0
  45. package/src/model.ts +9 -0
  46. package/src/overlay.ts +1000 -0
  47. package/src/pmx-document.ts +882 -0
  48. package/src/shaders/passes/overlay.ts +166 -0
  49. 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
@@ -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)
@@ -4036,13 +4072,6 @@ export class Engine {
4036
4072
  this.createPipelines();
4037
4073
  this.setupResize();
4038
4074
  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
4075
  }
4047
4076
  /**
4048
4077
  * Bake the ground's frost noise once — the same fbm the shader used to run
@@ -5012,6 +5041,8 @@ export class Engine {
5012
5041
  this.device.queue.writeBuffer(this.selectionEdgeUniformBuffer, 0, new Float32Array([5.0, 0, 0, 0]));
5013
5042
  // ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
5014
5043
  this.setupGizmo();
5044
+ // ─── Editor overlays (instanced wireframe primitives) ────────────
5045
+ this.setupOverlay();
5015
5046
  // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
5016
5047
  // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
5017
5048
  // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
@@ -5917,6 +5948,322 @@ export class Engine {
5917
5948
  ],
5918
5949
  };
5919
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
+ }
5920
6267
  // Step 4: Create camera and uniform buffer
5921
6268
  setupCamera() {
5922
6269
  this.cameraUniformBuffer = this.device.createBuffer({
@@ -6452,6 +6799,14 @@ export class Engine {
6452
6799
  return this.sun;
6453
6800
  }
6454
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;
6455
6810
  const opts = {
6456
6811
  width: 160,
6457
6812
  height: 160,
@@ -6542,7 +6897,15 @@ export class Engine {
6542
6897
  getLightCount() {
6543
6898
  return this.lightHeader[0];
6544
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. */
6545
6906
  updateLightBuffer() {
6907
+ if (!this.device || !this.lightUniformBuffer)
6908
+ return;
6546
6909
  this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData);
6547
6910
  }
6548
6911
  getStats() {
@@ -6586,6 +6949,19 @@ export class Engine {
6586
6949
  this.canvas.removeEventListener("dblclick", this.handleCanvasDoubleClick);
6587
6950
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch);
6588
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
+ }
6589
6965
  // Remove gizmo drag listeners
6590
6966
  this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true });
6591
6967
  window.removeEventListener("mousemove", this.handleGizmoMouseMove);
@@ -6981,6 +7357,16 @@ export class Engine {
6981
7357
  setSelectedMaterial(modelName, materialName) {
6982
7358
  this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null;
6983
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
+ }
6984
7370
  setSelectedBone(modelName, boneName) {
6985
7371
  if (!modelName || !boneName) {
6986
7372
  this.selectedBone = null;
@@ -6994,6 +7380,235 @@ export class Engine {
6994
7380
  const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName);
6995
7381
  this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null;
6996
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
+ }
6997
7612
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
6998
7613
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
6999
7614
  /** A group's uniform buffer and its maps have the same lifetime — freeing one
@@ -7080,8 +7695,26 @@ export class Engine {
7080
7695
  getIKEnabled() {
7081
7696
  return this.ikEnabled;
7082
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
+ */
7083
7707
  setPhysicsEnabled(enabled) {
7708
+ if (this.physicsEnabled === enabled)
7709
+ return;
7084
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
+ }
7085
7718
  }
7086
7719
  getPhysicsEnabled() {
7087
7720
  return this.physicsEnabled;
@@ -8124,13 +8757,15 @@ export class Engine {
8124
8757
  const jointsBuffer = this.device.createBuffer({
8125
8758
  label: `${name}: joints buffer`,
8126
8759
  size: skinning.joints.byteLength,
8127
- 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,
8128
8763
  });
8129
8764
  this.device.queue.writeBuffer(jointsBuffer, 0, skinning.joints.buffer, skinning.joints.byteOffset, skinning.joints.byteLength);
8130
8765
  const weightsBuffer = this.device.createBuffer({
8131
8766
  label: `${name}: weights buffer`,
8132
8767
  size: skinning.weights.byteLength,
8133
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
8768
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
8134
8769
  });
8135
8770
  this.device.queue.writeBuffer(weightsBuffer, 0, skinning.weights.buffer, skinning.weights.byteOffset, skinning.weights.byteLength);
8136
8771
  const skinMatrixBuffer = this.device.createBuffer({
@@ -8217,6 +8852,7 @@ export class Engine {
8217
8852
  jointsBuffer,
8218
8853
  weightsBuffer,
8219
8854
  skinMatrixBuffer,
8855
+ wireEdges: new Map(),
8220
8856
  drawCalls: [],
8221
8857
  shadowDrawCalls: [],
8222
8858
  shadowBindGroups,
@@ -9087,6 +9723,407 @@ export class Engine {
9087
9723
  epass.draw(3);
9088
9724
  epass.end();
9089
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
+ }
9090
10127
  // Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
9091
10128
  // then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
9092
10129
  // aligns rings with the bone's current world orientation, so clicking a ring
@@ -9734,7 +10771,11 @@ export class Engine {
9734
10771
  this.renderIdDebugPass(encoder, swapchainView);
9735
10772
  if (this.selectedMaterial && hasModels)
9736
10773
  this.renderSelectionPasses(encoder, swapchainView);
9737
- 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)
9738
10779
  this.renderGizmoPass(encoder, swapchainView);
9739
10780
  const pick = this.pendingPick;
9740
10781
  if (pick && hasModels)
@@ -11020,6 +12061,13 @@ export class Engine {
11020
12061
  }
11021
12062
  }
11022
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;
11023
12071
  Engine.GIZMO_RING_SEGMENTS = 96;
11024
12072
  Engine.GIZMO_RING_RADIUS = 0.8;
11025
12073
  // Axis visible length (relative to gizmo size). Extends past ring radius so
@@ -11175,6 +12223,7 @@ Engine.TIMED_PASSES = [
11175
12223
  "field",
11176
12224
  "bloom",
11177
12225
  "composite",
12226
+ "overlay",
11178
12227
  ];
11179
12228
  // ── The floor mirror (step 7C) ──
11180
12229
  // Half-res scene-contract attachments a mirrored draw renders into, plus the
@@ -11199,3 +12248,6 @@ Engine.CULL_ARG_WORDS = 5;
11199
12248
  Engine.CULL_DRAW_CASTS_SHADOW = 1;
11200
12249
  Engine.CULL_MODEL_VISIBLE = 1;
11201
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";