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/src/engine.ts CHANGED
@@ -56,6 +56,28 @@ import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/
56
56
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
57
57
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
58
58
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
59
+ import { OVERLAY_SHADER_WGSL, OVERLAY_COMPOSITE_SHADER_WGSL } from "./shaders/passes/overlay"
60
+ import { WIREFRAME_SHADER_WGSL } from "./shaders/passes/wireframe"
61
+ import {
62
+ boneOverlay,
63
+ boneMarkerPositions,
64
+ buildOverlayShapes,
65
+ jointOverlay,
66
+ rigidbodyOverlay,
67
+ writeOverlayInstance,
68
+ OVERLAY_INSTANCE_FLOATS,
69
+ OVERLAY_VERTEX_FLOATS,
70
+ OVERLAY_SHAPES,
71
+ OVERLAY_SOLID_SHAPES,
72
+ DEFAULT_VERTEX_COLOR,
73
+ OVERLAY_STYLE,
74
+ type BoneOverlayOptions,
75
+ type JointOverlayOptions,
76
+ type OverlayGeometry,
77
+ type OverlayPrimitive,
78
+ type OverlayShape,
79
+ type RigidbodyOverlayOptions,
80
+ } from "./overlay"
59
81
  import {
60
82
  BLOOM_BLIT_SHADER_WGSL,
61
83
  BLOOM_DOWNSAMPLE_SHADER_WGSL,
@@ -772,6 +794,15 @@ interface ModelInstance {
772
794
  materialMorphTargets: MaterialMorphTarget[] | null
773
795
  /** The same targets by PMX material index, so a named offset is one lookup. */
774
796
  materialMorphByIndex: Map<number, MaterialMorphTarget> | null
797
+ /** The mesh's unique edges as a line-list index buffer, built on first use.
798
+ * Deduplicated: an interior edge belongs to two triangles, so drawing the
799
+ * triangle list's edges directly would draw most of the mesh twice.
800
+ *
801
+ * Keyed by the material the edges were cut from, "" for the whole mesh. A
802
+ * material is a consecutive index run, so scoping is a slice of the same
803
+ * build — and both stay cached, because narrowing to one material and
804
+ * widening back out is the loop somebody auditing a model is in. */
805
+ wireEdges: Map<string, { buffer: GPUBuffer; count: number; bindGroup: GPUBindGroup } | null>
775
806
  physics: RezePhysics | null
776
807
  vertexBufferNeedsUpdate: boolean
777
808
  gpuMorph: GpuMorph | null
@@ -1493,8 +1524,73 @@ export class Engine {
1493
1524
  private selectionEdgePassDescriptor!: GPURenderPassDescriptor
1494
1525
  private selectionSampler!: GPUSampler
1495
1526
 
1527
+ // ─── Editor overlays (bones, rigidbodies, joints) ──────────────────
1528
+ // One instanced pass over unit wireframes. Static layers are whatever the
1529
+ // host handed setOverlay; the three live ones are rebuilt from the model
1530
+ // every frame, because a posed skeleton has moved by the next one.
1531
+ private overlayVertexBuffer!: GPUBuffer
1532
+ private overlayInstanceBuffer: GPUBuffer | null = null
1533
+ private overlayInstanceCapacity = 0
1534
+ private overlayPipeline!: GPURenderPipeline
1535
+ private overlaySolidPipeline!: GPURenderPipeline
1536
+ private overlayBindGroup!: GPUBindGroup
1537
+ private overlayGeometry!: OverlayGeometry
1538
+ private overlayPassDescriptor!: GPURenderPassDescriptor
1539
+ private overlayDepthTexture: GPUTexture | null = null
1540
+ private overlayMsaaTexture: GPUTexture | null = null
1541
+ private overlayResolveTexture: GPUTexture | null = null
1542
+ private overlayUniformBuffer!: GPUBuffer
1543
+ private overlayUniformData = new Float32Array(4)
1544
+ private overlayCompositePipeline!: GPURenderPipeline
1545
+ private overlayCompositeLayout!: GPUBindGroupLayout
1546
+ private overlayCompositeBindGroup: GPUBindGroup | null = null
1547
+ private overlayCompositePassDescriptor!: GPURenderPassDescriptor
1548
+ private overlayTargetSize: [number, number] = [0, 0]
1549
+ /** The overlay renders multisampled into its own layer; the scene's own depth
1550
+ * is discarded before the composite (see the depthRead note in render), so it
1551
+ * could not have shared either that or the single-sample swapchain. */
1552
+ private static readonly OVERLAY_SAMPLE_COUNT = 4
1553
+ /** Dash period in device pixels — dashes are geometry, so this is only the
1554
+ * reference the dashedLine shape is cut against. */
1555
+ private static readonly OVERLAY_DASH_PERIOD_PX = 8.0
1556
+ private overlayLayers = new Map<string, OverlayPrimitive[]>()
1557
+ private overlayBones: { modelName: string; options: BoneOverlayOptions } | null = null
1558
+ private overlayBodies: { modelName: string; options: RigidbodyOverlayOptions } | null = null
1559
+ private overlayJoints: { modelName: string; options: JointOverlayOptions } | null = null
1560
+ private overlayVertices: { modelName: string; xray: boolean; material: string | null } | null = null
1561
+ private wireframePipeline!: GPURenderPipeline
1562
+ private wireframeDepthPipeline!: GPURenderPipeline
1563
+ private wireframeUniformBuffer!: GPUBuffer
1564
+ private wireframeBindGroup!: GPUBindGroup
1565
+ /** The seam pass draws in the same frame at a different stroke and alpha, and
1566
+ * every queue write lands before the command buffer runs — writing the one
1567
+ * buffer twice would give both draws the second value. */
1568
+ private wireframeSeamUniformBuffer!: GPUBuffer
1569
+ private wireframeSeamBindGroup!: GPUBindGroup
1570
+ /** Same reason: the hovered material draws its own stroke, self-occluding, in
1571
+ * the same frame as the base mesh and the seams. */
1572
+ private wireframeHoverUniformBuffer!: GPUBuffer
1573
+ private wireframeHoverBindGroup!: GPUBindGroup
1574
+ private wireframeSkinLayout!: GPUBindGroupLayout
1575
+ private wireframeColorData = new Float32Array(8)
1576
+ /** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
1577
+ private overlayByShape = new Map<OverlayShape, OverlayPrimitive[]>()
1578
+ private overlayScratch: OverlayPrimitive[] = []
1579
+ private bonePickScratch: Float32Array = new Float32Array(0)
1580
+ private overlayInstanceData = new Float32Array(0)
1581
+
1496
1582
  // ─── Transform gizmo ───────────────────────────────────────────────
1497
1583
  private selectedBone: { modelName: string; boneName: string; boneIndex: number } | null = null
1584
+ /** The material a pointer is currently over, or null. Cheap and separate from
1585
+ * setVertexOverlay on purpose — the same split setSelectedBone takes from
1586
+ * setBoneOverlay — because this is written every frame the pointer moves and
1587
+ * the overlay's own option object is not something to reconstruct that often. */
1588
+ private hoverMaterial: { modelName: string; materialName: string } | null = null
1589
+ /** The transform gizmo follows setSelectedBone, which is also what selects a
1590
+ * bone to INSPECT. A model editor selects bones constantly and poses them
1591
+ * rarely, so the two need separating: off leaves selection working and takes
1592
+ * the handles away. */
1593
+ private gizmoEnabled = true
1498
1594
  private gizmoVertexBuffer!: GPUBuffer
1499
1595
  private gizmoTransformBuffer!: GPUBuffer
1500
1596
  private gizmoPipeline!: GPURenderPipeline
@@ -1872,6 +1968,7 @@ export class Engine {
1872
1968
  "field",
1873
1969
  "bloom",
1874
1970
  "composite",
1971
+ "overlay",
1875
1972
  ] as const
1876
1973
  private timestampQuerySet: GPUQuerySet | null = null
1877
1974
  private timestampResolve: GPUBuffer | null = null
@@ -5062,15 +5159,6 @@ export class Engine {
5062
5159
  this.createPipelines()
5063
5160
  this.setupResize()
5064
5161
  Engine.instance = this
5065
- // One line, at init, naming the three answers that differ between two
5066
- // browsers on the same machine. Not a debug flag and not a readout — it is
5067
- // the identity of the renderer that was actually built, and on a device that
5068
- // cannot be attached to a debugger it is the only way to know which of the
5069
- // three paths is running. Every graphics application prints this.
5070
- const r = this.gpuReport()
5071
- console.info(
5072
- `[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`,
5073
- )
5074
5162
  }
5075
5163
 
5076
5164
  // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
@@ -6122,6 +6210,9 @@ export class Engine {
6122
6210
  // ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
6123
6211
  this.setupGizmo()
6124
6212
 
6213
+ // ─── Editor overlays (instanced wireframe primitives) ────────────
6214
+ this.setupOverlay()
6215
+
6125
6216
  // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
6126
6217
  // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
6127
6218
  // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
@@ -7094,6 +7185,330 @@ export class Engine {
7094
7185
  }
7095
7186
  }
7096
7187
 
7188
+ // Builds the overlay pipeline and the one vertex buffer holding every unit
7189
+ // wireframe. The instance buffer is grown on demand in renderOverlayPass — a
7190
+ // scene with no overlays on never allocates one.
7191
+ private setupOverlay() {
7192
+ this.overlayGeometry = buildOverlayShapes()
7193
+ const verts = this.overlayGeometry.vertices
7194
+ this.overlayVertexBuffer = this.device.createBuffer({
7195
+ label: "overlay vertex buffer",
7196
+ size: verts.byteLength,
7197
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
7198
+ })
7199
+ this.device.queue.writeBuffer(this.overlayVertexBuffer, 0, verts)
7200
+
7201
+ this.overlayUniformBuffer = this.device.createBuffer({
7202
+ label: "overlay uniforms",
7203
+ size: 16, // vec2 viewport + dash period + pad
7204
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7205
+ })
7206
+ const bgLayout = this.device.createBindGroupLayout({
7207
+ label: "overlay group 0 layout (camera + overlay)",
7208
+ entries: [
7209
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
7210
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
7211
+ ],
7212
+ })
7213
+ const shader = this.device.createShaderModule({ label: "overlay shader", code: OVERLAY_SHADER_WGSL })
7214
+ const overlayPipelineDescriptor = {
7215
+ label: "overlay pipeline",
7216
+ layout: this.device.createPipelineLayout({
7217
+ label: "overlay pipeline layout",
7218
+ bindGroupLayouts: [bgLayout],
7219
+ }),
7220
+ vertex: {
7221
+ module: shader,
7222
+ entryPoint: "vs",
7223
+ buffers: [
7224
+ {
7225
+ arrayStride: OVERLAY_VERTEX_FLOATS * 4,
7226
+ attributes: [
7227
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }, // pos
7228
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat }, // dir
7229
+ { shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat }, // caps
7230
+ { shaderLocation: 3, offset: 8 * 4, format: "float32" as GPUVertexFormat }, // side
7231
+ { shaderLocation: 4, offset: 9 * 4, format: "float32" as GPUVertexFormat }, // t
7232
+ { shaderLocation: 5, offset: 10 * 4, format: "float32" as GPUVertexFormat }, // mode
7233
+ ],
7234
+ },
7235
+ {
7236
+ arrayStride: OVERLAY_INSTANCE_FLOATS * 4,
7237
+ stepMode: "instance",
7238
+ attributes: [
7239
+ { shaderLocation: 6, offset: 0, format: "float32x4" as GPUVertexFormat }, // rotation
7240
+ { shaderLocation: 7, offset: 4 * 4, format: "float32x4" as GPUVertexFormat }, // position + extent
7241
+ { shaderLocation: 8, offset: 8 * 4, format: "float32x4" as GPUVertexFormat }, // scale + thickness
7242
+ { shaderLocation: 9, offset: 12 * 4, format: "float32x4" as GPUVertexFormat }, // color
7243
+ ],
7244
+ },
7245
+ ],
7246
+ },
7247
+ fragment: {
7248
+ module: shader,
7249
+ entryPoint: "fs",
7250
+ targets: [
7251
+ {
7252
+ format: this.presentationFormat,
7253
+ // Premultiplied: the FS already scaled rgb by alpha. See the shader.
7254
+ blend: {
7255
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7256
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7257
+ },
7258
+ },
7259
+ ],
7260
+ },
7261
+ primitive: { topology: "triangle-list", cullMode: "none" },
7262
+ // The rig ignores depth entirely. It shares this pass's buffer with the
7263
+ // wireframe's mesh prepass, and that prepass exists to hide the far side
7264
+ // of the BODY — not to hide the skeleton inside it. An editor wants the
7265
+ // rig in front of the mesh, which is what "always" says. The cost is that
7266
+ // the rig no longer sorts against itself; for line work a few pixels wide,
7267
+ // draw order reads the same.
7268
+ depthStencil: {
7269
+ format: "depth24plus",
7270
+ depthWriteEnabled: false,
7271
+ depthCompare: "always",
7272
+ },
7273
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7274
+ } satisfies GPURenderPipelineDescriptor
7275
+ this.overlayPipeline = this.device.createRenderPipeline(overlayPipelineDescriptor)
7276
+
7277
+ // The solid volumes: the same shader and layout, with no depth write and no
7278
+ // culling. A translucent body must not hide the rig behind it, and you have
7279
+ // to see its far wall for it to read as a volume rather than a silhouette.
7280
+ this.overlaySolidPipeline = this.device.createRenderPipeline({
7281
+ ...overlayPipelineDescriptor,
7282
+ label: "overlay solid pipeline",
7283
+ primitive: { topology: "triangle-list", cullMode: "none" },
7284
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
7285
+ })
7286
+
7287
+ const compositeShader = this.device.createShaderModule({
7288
+ label: "overlay composite shader",
7289
+ code: OVERLAY_COMPOSITE_SHADER_WGSL,
7290
+ })
7291
+ this.overlayCompositeLayout = this.device.createBindGroupLayout({
7292
+ label: "overlay composite layout",
7293
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }],
7294
+ })
7295
+ this.overlayCompositePipeline = this.device.createRenderPipeline({
7296
+ label: "overlay composite pipeline",
7297
+ layout: this.device.createPipelineLayout({
7298
+ label: "overlay composite pipeline layout",
7299
+ bindGroupLayouts: [this.overlayCompositeLayout],
7300
+ }),
7301
+ vertex: { module: compositeShader, entryPoint: "vs" },
7302
+ fragment: {
7303
+ module: compositeShader,
7304
+ entryPoint: "fs",
7305
+ targets: [
7306
+ {
7307
+ format: this.presentationFormat,
7308
+ blend: {
7309
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7310
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7311
+ },
7312
+ },
7313
+ ],
7314
+ },
7315
+ primitive: { topology: "triangle-list" },
7316
+ multisample: { count: 1 },
7317
+ })
7318
+ this.overlayCompositePassDescriptor = {
7319
+ label: "overlay composite pass",
7320
+ colorAttachments: [
7321
+ { view: undefined as unknown as GPUTextureView, loadOp: "load", storeOp: "store" },
7322
+ ],
7323
+ }
7324
+
7325
+ this.overlayBindGroup = this.device.createBindGroup({
7326
+ label: "overlay bind group",
7327
+ layout: bgLayout,
7328
+ entries: [
7329
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7330
+ { binding: 1, resource: { buffer: this.overlayUniformBuffer } },
7331
+ ],
7332
+ })
7333
+
7334
+ // The mesh wireframe: the same line-list target, its own pipeline, because it
7335
+ // draws the model's OWN vertex buffer through the model's OWN skin matrices.
7336
+ // That is the whole reason it exists rather than emitting lines from the
7337
+ // loader's positions — those are bind pose, and a wireframe built from them
7338
+ // sits perfectly on a T-posed model and slides off every animated one.
7339
+ this.wireframeUniformBuffer = this.device.createBuffer({
7340
+ label: "wireframe color",
7341
+ size: 32, // vec4 colour + vec2 viewport + thickness + pad
7342
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7343
+ })
7344
+ this.wireframeSeamUniformBuffer = this.device.createBuffer({
7345
+ label: "wireframe color (material borders)",
7346
+ size: 32,
7347
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7348
+ })
7349
+ this.wireframeHoverUniformBuffer = this.device.createBuffer({
7350
+ label: "wireframe color (hovered material)",
7351
+ size: 32,
7352
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7353
+ })
7354
+ const wireBg0 = this.device.createBindGroupLayout({
7355
+ label: "wireframe group 0 layout (camera + wire)",
7356
+ entries: [
7357
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
7358
+ // Both stages: the FS takes the colour, the VS takes the viewport and
7359
+ // the stroke width it extrudes each edge quad to.
7360
+ {
7361
+ binding: 1,
7362
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
7363
+ buffer: { type: "uniform" },
7364
+ },
7365
+ ],
7366
+ })
7367
+ // Spelled out rather than mapped over a range: tests/bindings.test.mjs reads
7368
+ // these statically to check every bind group covers its layout, and a loop
7369
+ // hides the bindings from it.
7370
+ this.wireframeSkinLayout = this.device.createBindGroupLayout({
7371
+ label: "wireframe group 1 layout (mesh + skin)",
7372
+ entries: [
7373
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
7374
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
7375
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
7376
+ { binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
7377
+ { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
7378
+ ],
7379
+ })
7380
+ const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL })
7381
+ this.wireframePipeline = this.device.createRenderPipeline({
7382
+ label: "wireframe pipeline",
7383
+ layout: this.device.createPipelineLayout({
7384
+ label: "wireframe pipeline layout",
7385
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
7386
+ }),
7387
+ // No vertex stream: an edge quad's corners come from two different model
7388
+ // vertices, so the mesh is read through storage instead.
7389
+ vertex: { module: wireShader, entryPoint: "vs" },
7390
+ fragment: {
7391
+ module: wireShader,
7392
+ entryPoint: "fs",
7393
+ targets: [
7394
+ {
7395
+ format: this.presentationFormat,
7396
+ blend: {
7397
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7398
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7399
+ },
7400
+ },
7401
+ ],
7402
+ },
7403
+ primitive: { topology: "triangle-list", cullMode: "none" },
7404
+ // Depth-TESTED but not written: the mesh is a haze the rig reads against,
7405
+ // so a bone behind a triangle must not be punched out by it.
7406
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
7407
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7408
+ })
7409
+ // The mesh's own depth, so the wireframe can be occluded by the body it
7410
+ // belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
7411
+ // three's and Babylon's wireframe materials all depth-test, and X-ray is a
7412
+ // toggle beside them. Seeing both walls of a 30k-triangle body at once is
7413
+ // moire, not information.
7414
+ //
7415
+ // It writes depth and nothing else — but it still DECLARES the colour
7416
+ // target, at writeMask 0. A pipeline's attachment state has to match its
7417
+ // pass's, and a pass with a colour attachment will not take a pipeline that
7418
+ // has none. Same trick the scene's own depth prepass uses.
7419
+ //
7420
+ // Its own pass rather than the scene's, because the scene's depth is
7421
+ // multisampled and discarded before the composite.
7422
+ this.wireframeDepthPipeline = this.device.createRenderPipeline({
7423
+ label: "wireframe depth prepass pipeline",
7424
+ layout: this.device.createPipelineLayout({
7425
+ label: "wireframe depth prepass layout",
7426
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
7427
+ }),
7428
+ vertex: {
7429
+ module: wireShader,
7430
+ entryPoint: "vsDepth",
7431
+ buffers: [
7432
+ { arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }] },
7433
+ { arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" as GPUVertexFormat }] },
7434
+ { arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" as GPUVertexFormat }] },
7435
+ ],
7436
+ },
7437
+ fragment: {
7438
+ module: wireShader,
7439
+ entryPoint: "fs",
7440
+ targets: [{ format: this.presentationFormat, writeMask: 0 }],
7441
+ },
7442
+ primitive: { topology: "triangle-list", cullMode: "none" },
7443
+ depthStencil: {
7444
+ format: "depth24plus",
7445
+ depthWriteEnabled: true,
7446
+ depthCompare: this.depthAhead,
7447
+ // The wireframe lies exactly ON the surface this writes, so every edge
7448
+ // ties with its own triangles and loses wherever rounding goes the wrong
7449
+ // way — lines that break up and shift as the camera turns. Push the
7450
+ // solid mesh back so the edges win their own ties. The slope term is
7451
+ // what handles a surface seen at a grazing angle, where a pixel spans
7452
+ // far more depth than a constant bias can cover.
7453
+ //
7454
+ // SIGNED BY CONVENTION, as the outline hulls are: bias adds to the depth
7455
+ // VALUE, and reversed-Z inverts what a larger value means.
7456
+ depthBias: this.reversedZ ? -64 : 64,
7457
+ depthBiasSlopeScale: this.reversedZ ? -2 : 2,
7458
+ depthBiasClamp: 0,
7459
+ },
7460
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7461
+ })
7462
+
7463
+ this.wireframeBindGroup = this.device.createBindGroup({
7464
+ label: "wireframe bind group",
7465
+ layout: wireBg0,
7466
+ entries: [
7467
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7468
+ { binding: 1, resource: { buffer: this.wireframeUniformBuffer } },
7469
+ ],
7470
+ })
7471
+ this.wireframeSeamBindGroup = this.device.createBindGroup({
7472
+ label: "wireframe bind group (material borders)",
7473
+ layout: wireBg0,
7474
+ entries: [
7475
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7476
+ { binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
7477
+ ],
7478
+ })
7479
+ this.wireframeHoverBindGroup = this.device.createBindGroup({
7480
+ label: "wireframe bind group (hovered material)",
7481
+ layout: wireBg0,
7482
+ entries: [
7483
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7484
+ { binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
7485
+ ],
7486
+ })
7487
+
7488
+ this.overlayPassDescriptor = {
7489
+ label: "overlay pass",
7490
+ timestampWrites: this.stamps("overlay"),
7491
+ colorAttachments: [
7492
+ {
7493
+ view: undefined as unknown as GPUTextureView,
7494
+ resolveTarget: undefined,
7495
+ // Transparent, because this layer is composited over the frame rather
7496
+ // than drawn into it. storeOp discard keeps the 4 samples in tile
7497
+ // memory on a TBDR part — only the resolve reaches RAM.
7498
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
7499
+ loadOp: "clear",
7500
+ storeOp: "discard",
7501
+ },
7502
+ ],
7503
+ depthStencilAttachment: {
7504
+ view: undefined as unknown as GPUTextureView,
7505
+ depthClearValue: this.depthClear,
7506
+ depthLoadOp: "clear",
7507
+ depthStoreOp: "discard",
7508
+ },
7509
+ }
7510
+ }
7511
+
7097
7512
  // Step 4: Create camera and uniform buffer
7098
7513
  private setupCamera() {
7099
7514
  this.cameraUniformBuffer = this.device.createBuffer({
@@ -7667,6 +8082,13 @@ export class Engine {
7667
8082
  * scaled by how far the reflected geometry sits behind the surface. */
7668
8083
  mirrorBlur?: number
7669
8084
  }): void {
8085
+ // NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
8086
+ // deferred a frame by useSceneSync's own rAF batching, and a hot reload
8087
+ // that swaps in a new (uninitialized) engine between the schedule and the
8088
+ // callback lands this on a `device` that has not been assigned yet. The
8089
+ // effect that scheduled it re-fires once the new engine is ready, so
8090
+ // dropping this one loses nothing.
8091
+ if (!this.device) return
7670
8092
  const opts = {
7671
8093
  width: 160,
7672
8094
  height: 160,
@@ -7767,7 +8189,14 @@ export class Engine {
7767
8189
  return this.lightHeader[0]
7768
8190
  }
7769
8191
 
8192
+ /** Guarded, unlike most private writers here, because its callers are not:
8193
+ * setWorld/setSun are public and can be called before init() finishes
8194
+ * assigning `device` — a scene-settings effect firing on mount races the
8195
+ * engine's own async setup. The state write still lands immediately either
8196
+ * way; only the GPU upload defers, and setupLighting's own writeWorld/
8197
+ * writeSun calls during init pick up whatever was already set. */
7770
8198
  private updateLightBuffer() {
8199
+ if (!this.device || !this.lightUniformBuffer) return
7771
8200
  this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
7772
8201
  }
7773
8202
 
@@ -7817,6 +8246,19 @@ export class Engine {
7817
8246
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch)
7818
8247
  }
7819
8248
 
8249
+ this.overlayDepthTexture?.destroy()
8250
+ this.overlayDepthTexture = null
8251
+ this.overlayMsaaTexture?.destroy()
8252
+ this.overlayMsaaTexture = null
8253
+ this.overlayResolveTexture?.destroy()
8254
+ this.overlayResolveTexture = null
8255
+ this.overlayInstanceBuffer?.destroy()
8256
+ this.overlayInstanceBuffer = null
8257
+ for (const inst of this.modelInstances.values()) {
8258
+ for (const edges of inst.wireEdges.values()) edges?.buffer.destroy()
8259
+ inst.wireEdges.clear()
8260
+ }
8261
+
7820
8262
  // Remove gizmo drag listeners
7821
8263
  this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true })
7822
8264
  window.removeEventListener("mousemove", this.handleGizmoMouseMove)
@@ -8278,6 +8720,18 @@ export class Engine {
8278
8720
  this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null
8279
8721
  }
8280
8722
 
8723
+ /** Show the transform gizmo on the selected bone. On by default. */
8724
+ setGizmoEnabled(on: boolean): void {
8725
+ this.gizmoEnabled = on
8726
+ }
8727
+
8728
+ /** A pointer-driven preview of a pick, not a pick itself — see pickMaterial
8729
+ * for the click that actually selects one. Cheap: a field write, nothing
8730
+ * rebuilt, safe to call every frame the pointer is over the canvas. */
8731
+ setHoveredMaterial(modelName: string | null, materialName: string | null): void {
8732
+ this.hoverMaterial = modelName && materialName ? { modelName, materialName } : null
8733
+ }
8734
+
8281
8735
  setSelectedBone(modelName: string | null, boneName: string | null): void {
8282
8736
  if (!modelName || !boneName) {
8283
8737
  this.selectedBone = null
@@ -8292,6 +8746,246 @@ export class Engine {
8292
8746
  this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null
8293
8747
  }
8294
8748
 
8749
+ // ─── Editor overlays ───────────────────────────────────────────────
8750
+ //
8751
+ // Two ways in. setOverlay takes a list and draws exactly that list, so a host
8752
+ // can paste one in, hand one to a test, or print one back out. The three live
8753
+ // layers name a model instead and are rebuilt from its pose every frame,
8754
+ // which is the only way a skeleton overlay can be right on an animated model.
8755
+
8756
+ /**
8757
+ * Replace one named layer of overlay primitives. World space, drawn as given
8758
+ * until it is replaced. An empty list removes the layer.
8759
+ */
8760
+ setOverlay(layer: string, primitives: OverlayPrimitive[]): void {
8761
+ if (primitives.length === 0) this.overlayLayers.delete(layer)
8762
+ else this.overlayLayers.set(layer, primitives)
8763
+ }
8764
+
8765
+ /** Drop one named layer, or every one. Live layers keep drawing. */
8766
+ clearOverlay(layer?: string): void {
8767
+ if (layer === undefined) this.overlayLayers.clear()
8768
+ else this.overlayLayers.delete(layer)
8769
+ }
8770
+
8771
+ /**
8772
+ * The bone whose marker is nearest a point on the canvas, or null.
8773
+ *
8774
+ * On the CPU, and exact. A few hundred bones with known world positions is a
8775
+ * loop, not a render pass — and having the answer synchronously is what makes
8776
+ * cycling through overlapping bones possible at all. Only VERTICES justify GPU
8777
+ * picking, at tens of thousands.
8778
+ *
8779
+ * `x`/`y` are CSS pixels relative to the canvas, which is what a MouseEvent
8780
+ * gives once getBoundingClientRect is subtracted.
8781
+ *
8782
+ * It projects boneMarkerPositions, the same points the overlay draws markers
8783
+ * at, so the hit box cannot drift away from the circle you are aiming at.
8784
+ */
8785
+ pickBone(
8786
+ x: number,
8787
+ y: number,
8788
+ options: { radiusPx?: number; modelName?: string } = {},
8789
+ ): { modelName: string; boneName: string; boneIndex: number } | null {
8790
+ if (!this.camera) return null
8791
+ const width = this.canvas.clientWidth
8792
+ const height = this.canvas.clientHeight
8793
+ if (width <= 0 || height <= 0) return null
8794
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
8795
+
8796
+ let best: { modelName: string; boneName: string; boneIndex: number } | null = null
8797
+ let bestDist = options.radiusPx ?? 14
8798
+ let bestDepth = Infinity
8799
+
8800
+ for (const inst of this.modelInstances.values()) {
8801
+ if (options.modelName !== undefined && inst.name !== options.modelName) continue
8802
+ if (inst.isStage || inst.isPlane) continue
8803
+ const bones = inst.model.getSkeleton().bones
8804
+ this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch)
8805
+ const pos = this.bonePickScratch
8806
+ for (let i = 0; i < bones.length; i++) {
8807
+ const px = pos[i * 3]
8808
+ const py = pos[i * 3 + 1]
8809
+ const pz = pos[i * 3 + 2]
8810
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15]
8811
+ if (cw <= 1e-6) continue // behind the camera
8812
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]
8813
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]
8814
+ const sx = ((cx / cw) * 0.5 + 0.5) * width
8815
+ const sy = (1 - ((cy / cw) * 0.5 + 0.5)) * height
8816
+ const d = Math.hypot(sx - x, sy - y)
8817
+ if (d > bestDist) continue
8818
+ // Within a couple of pixels the two are the same click, and MMD stacks
8819
+ // control bones on one point — so the nearer bone takes it.
8820
+ if (d < bestDist - 2 || cw < bestDepth) {
8821
+ best = { modelName: inst.name, boneName: bones[i].name, boneIndex: i }
8822
+ bestDist = d
8823
+ bestDepth = cw
8824
+ }
8825
+ }
8826
+ }
8827
+ return best
8828
+ }
8829
+
8830
+ /** Skinned positions for picking, grown on demand. One click's worth of work
8831
+ * reused across clicks — a model's vertex count does not change. */
8832
+ private materialPickScratch: Float32Array | null = null
8833
+
8834
+ /**
8835
+ * The material under a point on the canvas, or null for a miss.
8836
+ *
8837
+ * On the CPU, like pickBone, and for the same reason: a click (or a hover) is
8838
+ * rare and an answer you have synchronously is worth more than one that
8839
+ * arrives a frame later. Tens of thousands of triangles is a loop that costs
8840
+ * a few milliseconds ONCE, against a GPU id pass that costs an attachment and
8841
+ * a readback every frame whether anyone is pointing at the model or not.
8842
+ *
8843
+ * Skinned on the CPU with getSkinMatrices — the same matrices the vertex
8844
+ * shader uses — so the pick lands on the POSED mesh. Bind-pose geometry would
8845
+ * be right on a T-posed model and wrong on every animated one, which is
8846
+ * exactly when someone is clicking around a costume.
8847
+ *
8848
+ * Morph offsets are NOT applied: they move a face, never move it into another
8849
+ * material, and reading them back per click would cost more than the pick.
8850
+ *
8851
+ * `x`/`y` are CSS pixels relative to the canvas, as pickBone takes them.
8852
+ */
8853
+ pickMaterial(
8854
+ x: number,
8855
+ y: number,
8856
+ options: { modelName?: string } = {},
8857
+ ): { modelName: string; materialName: string; materialIndex: number } | null {
8858
+ if (!this.camera) return null
8859
+ const width = this.canvas.clientWidth
8860
+ const height = this.canvas.clientHeight
8861
+ if (width <= 0 || height <= 0) return null
8862
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
8863
+
8864
+ let best: { modelName: string; materialName: string; materialIndex: number } | null = null
8865
+ let bestDepth = Infinity
8866
+
8867
+ for (const inst of this.modelInstances.values()) {
8868
+ if (options.modelName !== undefined && inst.name !== options.modelName) continue
8869
+ if (inst.isStage || inst.isPlane) continue
8870
+ const model = inst.model
8871
+ const { positions } = model.getGeometry()
8872
+ const count = positions.length / 3
8873
+ const { joints, weights } = model.getSkinning()
8874
+ const skin = model.getSkinMatrices()
8875
+
8876
+ // Project every vertex ONCE into screen x, y and clip w. The triangle
8877
+ // loop then reads three of these rather than re-skinning shared vertices
8878
+ // — a closed mesh uses each vertex about six times.
8879
+ if (!this.materialPickScratch || this.materialPickScratch.length !== count * 3) {
8880
+ this.materialPickScratch = new Float32Array(count * 3)
8881
+ }
8882
+ const proj = this.materialPickScratch
8883
+ for (let v = 0; v < count; v++) {
8884
+ const bx = positions[v * 3]
8885
+ const by = positions[v * 3 + 1]
8886
+ const bz = positions[v * 3 + 2]
8887
+ let px = 0
8888
+ let py = 0
8889
+ let pz = 0
8890
+ for (let k = 0; k < 4; k++) {
8891
+ const w = weights[v * 4 + k] / 255
8892
+ if (w === 0) continue
8893
+ const m = joints[v * 4 + k] * 16
8894
+ px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12])
8895
+ py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13])
8896
+ pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14])
8897
+ }
8898
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15]
8899
+ proj[v * 3 + 2] = cw
8900
+ if (cw <= 1e-6) continue
8901
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]
8902
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]
8903
+ proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width
8904
+ proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height
8905
+ }
8906
+
8907
+ // Point-in-triangle in SCREEN space, nearest w wins. The same projection
8908
+ // pickBone uses, so the two agree about where things are, and it needs no
8909
+ // inverse view-projection to build a ray from.
8910
+ const indices = model.getIndices()
8911
+ const materials = model.getMaterials()
8912
+ let m = 0
8913
+ let matEnd = materials.length > 0 ? materials[0].vertexCount : indices.length
8914
+ for (let i = 0; i + 2 < indices.length; i += 3) {
8915
+ while (i >= matEnd && m + 1 < materials.length) {
8916
+ m++
8917
+ matEnd += materials[m].vertexCount
8918
+ }
8919
+ const a = indices[i] * 3
8920
+ const b = indices[i + 1] * 3
8921
+ const c = indices[i + 2] * 3
8922
+ if (proj[a + 2] <= 1e-6 || proj[b + 2] <= 1e-6 || proj[c + 2] <= 1e-6) continue
8923
+ const ax = proj[a]
8924
+ const ay = proj[a + 1]
8925
+ const bx = proj[b]
8926
+ const by = proj[b + 1]
8927
+ const cx2 = proj[c]
8928
+ const cy2 = proj[c + 1]
8929
+ // Barycentric sign test, both windings: PMX faces are one winding but a
8930
+ // double-sided material is legitimately seen from behind.
8931
+ const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by)
8932
+ const d2 = (x - cx2) * (by - cy2) - (bx - cx2) * (y - cy2)
8933
+ const d3 = (x - ax) * (cy2 - ay) - (cx2 - ax) * (y - ay)
8934
+ const neg = d1 < 0 || d2 < 0 || d3 < 0
8935
+ const pos = d1 > 0 || d2 > 0 || d3 > 0
8936
+ if (neg && pos) continue
8937
+ const depth = (proj[a + 2] + proj[b + 2] + proj[c + 2]) / 3
8938
+ if (depth >= bestDepth) continue
8939
+ bestDepth = depth
8940
+ best = { modelName: inst.name, materialName: materials[m].name, materialIndex: m }
8941
+ }
8942
+ }
8943
+ return best
8944
+ }
8945
+
8946
+ /** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
8947
+ setBoneOverlay(modelName: string | null, options: BoneOverlayOptions = {}): void {
8948
+ this.overlayBones = modelName ? { modelName, options } : null
8949
+ }
8950
+
8951
+ /** Draw every rigidbody of `modelName` where the simulation has it, rebuilt
8952
+ * each frame. Null off. */
8953
+ setRigidbodyOverlay(modelName: string | null, options: RigidbodyOverlayOptions = {}): void {
8954
+ this.overlayBodies = modelName ? { modelName, options } : null
8955
+ }
8956
+
8957
+ /** Draw a cross per joint of `modelName` plus dashed lines to the bodies it
8958
+ * holds together, rebuilt each frame. Null off. */
8959
+ setJointOverlay(modelName: string | null, options: JointOverlayOptions = {}): void {
8960
+ this.overlayJoints = modelName ? { modelName, options } : null
8961
+ }
8962
+
8963
+ /**
8964
+ * Draw `modelName`'s mesh as a wireframe — its vertices and its topology.
8965
+ *
8966
+ * Skinned on the GPU from the model's own vertex buffer and skin matrices, so
8967
+ * it sits on the POSED mesh. The loader's CPU-side positions are bind pose: a
8968
+ * wireframe built from those looks right on a T-posed model and slides off
8969
+ * every animated one, which is exactly the state a user is in while looking at
8970
+ * weights.
8971
+ *
8972
+ * The edge list is deduplicated and built once, on the first frame this is on.
8973
+ *
8974
+ * `material` narrows the wireframe to one material's faces. The mesh still
8975
+ * writes depth in full, so the material reads as part of the body rather than
8976
+ * as a shell floating in front of it — which is the point of scoping it: you
8977
+ * are asking where this material's faces ARE, and an answer that ignores the
8978
+ * torso in front of them is not one.
8979
+ */
8980
+ setVertexOverlay(
8981
+ modelName: string | null,
8982
+ options: { xray?: boolean; material?: string | null } = {},
8983
+ ): void {
8984
+ this.overlayVertices = modelName
8985
+ ? { modelName, xray: options.xray ?? false, material: options.material ?? null }
8986
+ : null
8987
+ }
8988
+
8295
8989
  // Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
8296
8990
  // (the group's buffer when grouped, or the shared zero buffer when ungrouped).
8297
8991
  /** A group's uniform buffer and its maps have the same lifetime — freeing one
@@ -8387,8 +9081,23 @@ export class Engine {
8387
9081
  return this.ikEnabled
8388
9082
  }
8389
9083
 
9084
+ /**
9085
+ * Run the solver, or stop it.
9086
+ *
9087
+ * Turning it OFF snaps every body back onto its bone. Merely halting the step
9088
+ * leaves hair and skirts hanging wherever the simulation happened to be — a
9089
+ * pose nothing in the document describes, which is the opposite of what "off"
9090
+ * is asked for: you switch physics off to see what the RIG does, and a frozen
9091
+ * mid-swing is still the solver's answer, just a stale one.
9092
+ */
8390
9093
  setPhysicsEnabled(enabled: boolean): void {
9094
+ if (this.physicsEnabled === enabled) return
8391
9095
  this.physicsEnabled = enabled
9096
+ if (enabled) return
9097
+ for (const inst of this.modelInstances.values()) {
9098
+ if (!inst.physics) continue
9099
+ inst.physics.reset(inst.model.getWorldMatrices())
9100
+ }
8392
9101
  }
8393
9102
 
8394
9103
  getPhysicsEnabled(): boolean {
@@ -9498,7 +10207,9 @@ export class Engine {
9498
10207
  const jointsBuffer = this.device.createBuffer({
9499
10208
  label: `${name}: joints buffer`,
9500
10209
  size: skinning.joints.byteLength,
9501
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
10210
+ // STORAGE so the wireframe overlay can skin from it: its quads read two
10211
+ // different model vertices per corner, which no vertex stream can supply.
10212
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
9502
10213
  })
9503
10214
  this.device.queue.writeBuffer(
9504
10215
  jointsBuffer,
@@ -9511,7 +10222,7 @@ export class Engine {
9511
10222
  const weightsBuffer = this.device.createBuffer({
9512
10223
  label: `${name}: weights buffer`,
9513
10224
  size: skinning.weights.byteLength,
9514
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
10225
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
9515
10226
  })
9516
10227
  this.device.queue.writeBuffer(
9517
10228
  weightsBuffer,
@@ -9614,6 +10325,7 @@ export class Engine {
9614
10325
  jointsBuffer,
9615
10326
  weightsBuffer,
9616
10327
  skinMatrixBuffer,
10328
+ wireEdges: new Map(),
9617
10329
  drawCalls: [],
9618
10330
  shadowDrawCalls: [],
9619
10331
  shadowBindGroups,
@@ -10630,6 +11342,425 @@ export class Engine {
10630
11342
  epass.end()
10631
11343
  }
10632
11344
 
11345
+ // Unique edges of the mesh, as a line-list index buffer. Each interior edge is
11346
+ // shared by two triangles, so deduplicating halves both the buffer and the
11347
+ // draw. Built once per model, on the first frame its wireframe is asked for.
11348
+ /** The index run `material` owns, or the whole list when it is null. Materials
11349
+ * are consecutive runs in declaration order, so the offset is a prefix sum —
11350
+ * the same walk the draw list does. Returns null for a name the model does
11351
+ * not have, which is what a stale selection looks like after a reload. */
11352
+ private materialIndexRange(inst: ModelInstance, material: string | null): [number, number] | null {
11353
+ const indices = inst.model.getIndices()
11354
+ if (!material) return [0, indices.length]
11355
+ let offset = 0
11356
+ for (const m of inst.model.getMaterials()) {
11357
+ if (m.name === material) return [offset, offset + m.vertexCount]
11358
+ offset += m.vertexCount
11359
+ }
11360
+ return null
11361
+ }
11362
+
11363
+ /** A cache key no material can collide with — a PMX name is never empty and
11364
+ * never contains a NUL. */
11365
+ private static readonly SEAM_KEY = "\u0000seams"
11366
+
11367
+ /**
11368
+ * @param material one material's own edges, or null for the whole mesh
11369
+ * @param seams every material's OUTLINE instead — the borders between them
11370
+ */
11371
+ private ensureEdgeBuffer(inst: ModelInstance, material: string | null, seams = false): boolean {
11372
+ const key = material ?? (seams ? Engine.SEAM_KEY : "")
11373
+ if (inst.wireEdges.has(key)) return inst.wireEdges.get(key) !== null
11374
+ const indices = inst.model.getIndices()
11375
+ const vertexCount = inst.model.getGeometry().positions.length / 3
11376
+ const edges: number[] = []
11377
+
11378
+ if (seams && !material) {
11379
+ // Inside one material's run an interior edge belongs to two triangles and
11380
+ // a border edge to one, so counting uses within the run and keeping the
11381
+ // singles gives exactly that material's outline.
11382
+ //
11383
+ // Per run, never over the whole mesh: an edge two materials share is
11384
+ // interior to the model and a border to both, and only the per-run count
11385
+ // can tell those two cases apart.
11386
+ const used = new Map<number, number>()
11387
+ const seen = new Set<number>()
11388
+ let offset = 0
11389
+ for (const m of inst.model.getMaterials()) {
11390
+ const end = offset + m.vertexCount
11391
+ used.clear()
11392
+ const bump = (a: number, b: number) => {
11393
+ const k = (a < b ? a : b) * vertexCount + (a < b ? b : a)
11394
+ used.set(k, (used.get(k) ?? 0) + 1)
11395
+ }
11396
+ for (let i = offset; i + 2 < end; i += 3) {
11397
+ bump(indices[i], indices[i + 1])
11398
+ bump(indices[i + 1], indices[i + 2])
11399
+ bump(indices[i + 2], indices[i])
11400
+ }
11401
+ for (const [k, count] of used) {
11402
+ if (count !== 1 || seen.has(k)) continue
11403
+ seen.add(k)
11404
+ edges.push(Math.floor(k / vertexCount), k % vertexCount)
11405
+ }
11406
+ offset = end
11407
+ }
11408
+ } else {
11409
+ const range = this.materialIndexRange(inst, material)
11410
+ if (!range) return false
11411
+ const [start, end] = range
11412
+ const seen = new Set<number>()
11413
+ const add = (a: number, b: number) => {
11414
+ const lo = a < b ? a : b
11415
+ const hi = a < b ? b : a
11416
+ const k = lo * vertexCount + hi
11417
+ if (seen.has(k)) return
11418
+ seen.add(k)
11419
+ edges.push(lo, hi)
11420
+ }
11421
+ for (let i = start; i + 2 < end; i += 3) {
11422
+ add(indices[i], indices[i + 1])
11423
+ add(indices[i + 1], indices[i + 2])
11424
+ add(indices[i + 2], indices[i])
11425
+ }
11426
+ }
11427
+ if (edges.length === 0) {
11428
+ inst.wireEdges.set(key, null)
11429
+ return false
11430
+ }
11431
+ const data = new Uint32Array(edges)
11432
+ const buffer = this.device.createBuffer({
11433
+ label: `wireframe edges ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
11434
+ size: data.byteLength,
11435
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
11436
+ })
11437
+ this.device.queue.writeBuffer(buffer, 0, data)
11438
+ const bindGroup = this.device.createBindGroup({
11439
+ label: `wireframe mesh ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
11440
+ layout: this.wireframeSkinLayout,
11441
+ entries: [
11442
+ { binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
11443
+ { binding: 1, resource: { buffer: inst.vertexBuffer } },
11444
+ { binding: 2, resource: { buffer: inst.jointsBuffer } },
11445
+ { binding: 3, resource: { buffer: inst.weightsBuffer } },
11446
+ { binding: 4, resource: { buffer } },
11447
+ ],
11448
+ })
11449
+ inst.wireEdges.set(key, { buffer, count: edges.length, bindGroup })
11450
+ return true
11451
+ }
11452
+
11453
+ private renderWireframe(pass: GPURenderPassEncoder): void {
11454
+ if (!this.overlayVertices) return
11455
+ const inst = this.overlayModel(this.overlayVertices.modelName)
11456
+ const { material, xray } = this.overlayVertices
11457
+ if (!inst || !this.ensureEdgeBuffer(inst, material)) return
11458
+ const edges = inst.wireEdges.get(material ?? "")
11459
+ if (!edges) return
11460
+
11461
+ // The whole mesh gets its material BORDERS drawn over it — that is what
11462
+ // makes the view read as every material at once rather than as one body of
11463
+ // undifferentiated lines. A single material asked for by name is already
11464
+ // one material, so it needs no borders to separate it from anything.
11465
+ const seams =
11466
+ material === null && this.ensureEdgeBuffer(inst, null, true)
11467
+ ? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
11468
+ : null
11469
+
11470
+ // A pointer over a material previews EXACTLY what clicking it would pick —
11471
+ // the same self-occluding reveal, layered over the section-wide view rather
11472
+ // than replacing it, so the rest of the mesh stays legible while one
11473
+ // material calls attention to itself. Meaningless once something IS
11474
+ // picked, since only the picked material draws at all then.
11475
+ const hm = this.hoverMaterial
11476
+ const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null
11477
+ const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null
11478
+ const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName!) : null
11479
+
11480
+ this.wireframeColorData.set(DEFAULT_VERTEX_COLOR)
11481
+ this.wireframeColorData[4] = this.canvas.width
11482
+ this.wireframeColorData[5] = this.canvas.height
11483
+ this.wireframeColorData[6] = OVERLAY_STYLE.meshStrokePx
11484
+ // The triangulation steps back only when there is something drawn over it
11485
+ // to step back FROM.
11486
+ if (seams) this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3] * OVERLAY_STYLE.meshAlpha
11487
+ this.device.queue.writeBuffer(this.wireframeUniformBuffer, 0, this.wireframeColorData)
11488
+ if (seams) {
11489
+ this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3]
11490
+ this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx
11491
+ this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData)
11492
+ }
11493
+ if (hover) {
11494
+ this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx
11495
+ this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData)
11496
+ }
11497
+
11498
+ // Both bind groups, before the FIRST draw call below, regardless of which
11499
+ // branch runs first — the depth prepass reads the camera from group 0 and
11500
+ // the skin matrices from group 1 same as the edge pass does, and every
11501
+ // draw in this function needs both set to SOMETHING before it runs. Each
11502
+ // block below is free to swap either one out for its own draws.
11503
+ pass.setBindGroup(0, this.wireframeBindGroup)
11504
+ pass.setBindGroup(1, edges.bindGroup)
11505
+
11506
+ const bindMesh = () => {
11507
+ pass.setPipeline(this.wireframeDepthPipeline)
11508
+ pass.setVertexBuffer(0, inst.vertexBuffer)
11509
+ pass.setVertexBuffer(1, inst.jointsBuffer)
11510
+ pass.setVertexBuffer(2, inst.weightsBuffer)
11511
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
11512
+ }
11513
+
11514
+ // The hover preview writes and draws against its OWN depth first, while the
11515
+ // shared depth buffer is still empty — the same trick a pick uses, run
11516
+ // before the base mesh below gets a chance to occlude it. The base mesh's
11517
+ // depth write further down repeats the SAME geometry for these faces
11518
+ // (identical z), so it neither disturbs this nor needs to skip them.
11519
+ if (hover && hoverRange && !xray) {
11520
+ bindMesh()
11521
+ pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0])
11522
+ pass.setBindGroup(0, this.wireframeHoverBindGroup)
11523
+ pass.setBindGroup(1, hover.bindGroup)
11524
+ pass.setPipeline(this.wireframePipeline)
11525
+ pass.draw(6, hover.count / 2)
11526
+ pass.setBindGroup(0, this.wireframeBindGroup)
11527
+ pass.setBindGroup(1, edges.bindGroup)
11528
+ }
11529
+
11530
+ // What writes depth is what is allowed to hide the wireframe, and that
11531
+ // differs between the two views.
11532
+ //
11533
+ // The whole mesh, so the far wall of a 30k-triangle body does not draw on
11534
+ // top of the near one — occluded is the default everywhere, Blender's edit
11535
+ // mode and Maya included, and seeing both walls at once is moire rather than
11536
+ // information.
11537
+ //
11538
+ // A PICKED material writes only its OWN faces. The question a pick asks is
11539
+ // where this material is, and half of it is usually under a coat; letting
11540
+ // the coat hide it does not answer that. Its own depth still goes in, so its
11541
+ // back faces stay hidden and it reads as an object instead of a haze —
11542
+ // which is the difference between this and turning x-ray on.
11543
+ const range = material !== null ? this.materialIndexRange(inst, material) : null
11544
+ if (!xray) {
11545
+ bindMesh()
11546
+ if (range) pass.drawIndexed(range[1] - range[0], 1, range[0])
11547
+ else pass.drawIndexed(inst.model.getIndices().length)
11548
+ }
11549
+
11550
+ // Six vertices an edge, instanced.
11551
+ pass.setPipeline(this.wireframePipeline)
11552
+ pass.draw(6, edges.count / 2)
11553
+
11554
+ if (seams) {
11555
+ pass.setBindGroup(0, this.wireframeSeamBindGroup)
11556
+ pass.setBindGroup(1, seams.bindGroup)
11557
+ pass.draw(6, seams.count / 2)
11558
+ }
11559
+ }
11560
+
11561
+ /** The bone overlay's options with `selected` filled in from setSelectedBone,
11562
+ * so clicking a bone highlights it without the host mirroring the state. An
11563
+ * explicit `selected` in the options still wins. */
11564
+ private boneOverlayOptions(modelName: string): BoneOverlayOptions {
11565
+ const options = this.overlayBones?.options ?? {}
11566
+ if (options.selected !== undefined) return options
11567
+ const chosen = this.selectedBone?.modelName === modelName ? this.selectedBone.boneName : null
11568
+ this.boneOptionsScratch.selected = chosen
11569
+ this.boneOptionsScratch.include = options.include
11570
+ return this.boneOptionsScratch
11571
+ }
11572
+ private boneOptionsScratch: BoneOverlayOptions = {}
11573
+
11574
+ private overlayActive(): boolean {
11575
+ return (
11576
+ this.overlayLayers.size > 0 ||
11577
+ this.overlayBones !== null ||
11578
+ this.overlayBodies !== null ||
11579
+ this.overlayJoints !== null ||
11580
+ this.overlayVertices !== null
11581
+ )
11582
+ }
11583
+
11584
+ private overlayModel(name: string): ModelInstance | null {
11585
+ return this.modelInstances.get(name) ?? null
11586
+ }
11587
+
11588
+ /** The primitives a live layer would draw right now. Same list the pass uses,
11589
+ * so a host can show it as data, diff it, or hit-test it on the CPU. */
11590
+ getOverlayPrimitives(layer: "bones" | "rigidbodies" | "joints"): OverlayPrimitive[] {
11591
+ if (layer === "bones") {
11592
+ const inst = this.overlayBones ? this.overlayModel(this.overlayBones.modelName) : null
11593
+ return inst ? boneOverlay(inst.model, this.boneOverlayOptions(inst.name)) : []
11594
+ }
11595
+ if (layer === "rigidbodies") {
11596
+ const inst = this.overlayBodies ? this.overlayModel(this.overlayBodies.modelName) : null
11597
+ return inst ? rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies!.options) : []
11598
+ }
11599
+ const inst = this.overlayJoints ? this.overlayModel(this.overlayJoints.modelName) : null
11600
+ return inst ? jointOverlay(inst.model, inst.physics, this.overlayJoints!.options) : []
11601
+ }
11602
+
11603
+ // The overlay's own layer: a 4x multisampled colour target, its resolve, and a
11604
+ // matching depth. All three are allocated the first frame an overlay is
11605
+ // actually on, so a scene that never shows one never pays for any of it.
11606
+ private ensureOverlayTargets(width: number, height: number): void {
11607
+ if (this.overlayResolveTexture && this.overlayTargetSize[0] === width && this.overlayTargetSize[1] === height) {
11608
+ return
11609
+ }
11610
+ const samples = Engine.OVERLAY_SAMPLE_COUNT
11611
+ this.overlayDepthTexture?.destroy()
11612
+ this.overlayMsaaTexture?.destroy()
11613
+ this.overlayResolveTexture?.destroy()
11614
+
11615
+ this.overlayMsaaTexture = this.device.createTexture({
11616
+ label: "overlay msaa",
11617
+ size: [width, height],
11618
+ sampleCount: samples,
11619
+ format: this.presentationFormat,
11620
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
11621
+ })
11622
+ this.overlayResolveTexture = this.device.createTexture({
11623
+ label: "overlay resolve",
11624
+ size: [width, height],
11625
+ format: this.presentationFormat,
11626
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
11627
+ })
11628
+ this.overlayDepthTexture = this.device.createTexture({
11629
+ label: "overlay depth",
11630
+ size: [width, height],
11631
+ sampleCount: samples,
11632
+ format: "depth24plus",
11633
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
11634
+ })
11635
+ this.overlayTargetSize = [width, height]
11636
+
11637
+ const colorAtt = (this.overlayPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
11638
+ colorAtt.view = this.overlayMsaaTexture.createView()
11639
+ colorAtt.resolveTarget = this.overlayResolveTexture.createView()
11640
+ const depthAtt = this.overlayPassDescriptor.depthStencilAttachment as GPURenderPassDepthStencilAttachment
11641
+ depthAtt.view = this.overlayDepthTexture.createView()
11642
+
11643
+ this.overlayCompositeBindGroup = this.device.createBindGroup({
11644
+ label: "overlay composite bind group",
11645
+ layout: this.overlayCompositeLayout,
11646
+ entries: [{ binding: 0, resource: this.overlayResolveTexture.createView() }],
11647
+ })
11648
+ }
11649
+
11650
+ private ensureOverlayInstanceCapacity(count: number): void {
11651
+ if (this.overlayInstanceBuffer && this.overlayInstanceCapacity >= count) return
11652
+ // Grow in powers of two so a skirt gaining bodies one at a time does not
11653
+ // reallocate once per body.
11654
+ let capacity = Math.max(64, this.overlayInstanceCapacity || 64)
11655
+ while (capacity < count) capacity *= 2
11656
+ this.overlayInstanceBuffer?.destroy()
11657
+ this.overlayInstanceBuffer = this.device.createBuffer({
11658
+ label: "overlay instance buffer",
11659
+ size: capacity * OVERLAY_INSTANCE_FLOATS * 4,
11660
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
11661
+ })
11662
+ this.overlayInstanceCapacity = capacity
11663
+ this.overlayInstanceData = new Float32Array(capacity * OVERLAY_INSTANCE_FLOATS)
11664
+ }
11665
+
11666
+ // Collects every layer, groups it by shape so each shape is one instanced
11667
+ // draw, and runs them into the swapchain over the finished frame.
11668
+ private renderOverlayPass(encoder: GPUCommandEncoder, swapchainView: GPUTextureView): void {
11669
+ if (!this.camera) return
11670
+
11671
+ const byShape = this.overlayByShape
11672
+ for (const shape of OVERLAY_SHAPES) {
11673
+ const list = byShape.get(shape)
11674
+ if (list) list.length = 0
11675
+ }
11676
+ let total = 0
11677
+ const collect = (primitives: readonly OverlayPrimitive[]) => {
11678
+ for (const primitive of primitives) {
11679
+ let list = byShape.get(primitive.shape)
11680
+ if (!list) {
11681
+ list = []
11682
+ byShape.set(primitive.shape, list)
11683
+ }
11684
+ list.push(primitive)
11685
+ total++
11686
+ }
11687
+ }
11688
+
11689
+ for (const layer of this.overlayLayers.values()) collect(layer)
11690
+ if (this.overlayBones) {
11691
+ const inst = this.overlayModel(this.overlayBones.modelName)
11692
+ if (inst) collect(boneOverlay(inst.model, this.boneOverlayOptions(inst.name)))
11693
+ }
11694
+ if (this.overlayBodies) {
11695
+ const inst = this.overlayModel(this.overlayBodies.modelName)
11696
+ if (inst) collect(rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options))
11697
+ }
11698
+ if (this.overlayJoints) {
11699
+ const inst = this.overlayModel(this.overlayJoints.modelName)
11700
+ if (inst) collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options))
11701
+ }
11702
+ if (total === 0 && !this.overlayVertices) return
11703
+
11704
+ this.ensureOverlayInstanceCapacity(total)
11705
+ const data = this.overlayInstanceData
11706
+ const draws: { shape: OverlayShape; first: number; count: number }[] = []
11707
+ let written = 0
11708
+ for (const shape of OVERLAY_SHAPES) {
11709
+ const list = byShape.get(shape)
11710
+ if (!list || list.length === 0) continue
11711
+ draws.push({ shape, first: written, count: list.length })
11712
+ for (const primitive of list) {
11713
+ writeOverlayInstance(primitive, data, written * OVERLAY_INSTANCE_FLOATS)
11714
+ written++
11715
+ }
11716
+ }
11717
+ this.device.queue.writeBuffer(
11718
+ this.overlayInstanceBuffer!,
11719
+ 0,
11720
+ data.buffer,
11721
+ data.byteOffset,
11722
+ written * OVERLAY_INSTANCE_FLOATS * 4,
11723
+ )
11724
+
11725
+ const width = this.canvas.width
11726
+ const height = this.canvas.height
11727
+ this.ensureOverlayTargets(width, height)
11728
+ this.overlayUniformData[0] = width
11729
+ this.overlayUniformData[1] = height
11730
+ this.overlayUniformData[2] = Engine.OVERLAY_DASH_PERIOD_PX
11731
+ this.device.queue.writeBuffer(this.overlayUniformBuffer, 0, this.overlayUniformData)
11732
+
11733
+ const pass = encoder.beginRenderPass(this.overlayPassDescriptor)
11734
+ // Under everything: the mesh is the haze the rig is read against.
11735
+ this.renderWireframe(pass)
11736
+ pass.setBindGroup(0, this.overlayBindGroup)
11737
+ pass.setVertexBuffer(0, this.overlayVertexBuffer)
11738
+ pass.setVertexBuffer(1, this.overlayInstanceBuffer!)
11739
+ // Volumes first and without depth writes, then the line work over them.
11740
+ for (const solid of [true, false]) {
11741
+ let bound = false
11742
+ for (const draw of draws) {
11743
+ if (OVERLAY_SOLID_SHAPES.has(draw.shape) !== solid) continue
11744
+ if (!bound) {
11745
+ pass.setPipeline(solid ? this.overlaySolidPipeline : this.overlayPipeline)
11746
+ bound = true
11747
+ }
11748
+ const range = this.overlayGeometry.ranges[draw.shape]
11749
+ pass.draw(range.count, draw.count, range.first, draw.first)
11750
+ }
11751
+ }
11752
+ pass.end()
11753
+
11754
+ // The resolved layer over the finished frame, premultiplied.
11755
+ const compositeAtt = (this.overlayCompositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
11756
+ compositeAtt.view = swapchainView
11757
+ const composite = encoder.beginRenderPass(this.overlayCompositePassDescriptor)
11758
+ composite.setPipeline(this.overlayCompositePipeline)
11759
+ composite.setBindGroup(0, this.overlayCompositeBindGroup!)
11760
+ composite.draw(3)
11761
+ composite.end()
11762
+ }
11763
+
10633
11764
  // Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
10634
11765
  // then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
10635
11766
  // aligns rings with the bone's current world orientation, so clicking a ring
@@ -10844,7 +11975,7 @@ export class Engine {
10844
11975
  }
10845
11976
 
10846
11977
  private handleGizmoMouseDown = (e: MouseEvent) => {
10847
- if (!this.selectedBone || !this.camera || !this.device || e.button !== 0) return
11978
+ if (!this.gizmoEnabled || !this.selectedBone || !this.camera || !this.device || e.button !== 0) return
10848
11979
  const inst = this.modelInstances.get(this.selectedBone.modelName)
10849
11980
  if (!inst) return
10850
11981
  const worldMats = inst.model.getWorldMatrices()
@@ -11489,7 +12620,10 @@ export class Engine {
11489
12620
  this.renderIdDebugPass(encoder, swapchainView)
11490
12621
 
11491
12622
  if (this.selectedMaterial && hasModels) this.renderSelectionPasses(encoder, swapchainView)
11492
- if (this.selectedBone && hasModels) this.renderGizmoPass(encoder, swapchainView)
12623
+ // Under the gizmo: the handles you drag stay on top of the rig you are
12624
+ // reading them against.
12625
+ if (this.overlayActive()) this.renderOverlayPass(encoder, swapchainView)
12626
+ if (this.gizmoEnabled && this.selectedBone && hasModels) this.renderGizmoPass(encoder, swapchainView)
11493
12627
 
11494
12628
  const pick = this.pendingPick
11495
12629
  if (pick && hasModels) this.renderPickPass(encoder)