reze-engine 0.13.4 → 0.14.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 (83) hide show
  1. package/README.md +104 -13
  2. package/dist/camera.d.ts +2 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +17 -0
  5. package/dist/engine.d.ts +71 -2
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +729 -8
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/model.d.ts +6 -0
  11. package/dist/model.d.ts.map +1 -1
  12. package/dist/model.js +36 -1
  13. package/dist/shaders/body.d.ts +1 -1
  14. package/dist/shaders/body.d.ts.map +1 -1
  15. package/dist/shaders/body.js +7 -28
  16. package/dist/shaders/cloth_rough.d.ts +1 -1
  17. package/dist/shaders/cloth_rough.d.ts.map +1 -1
  18. package/dist/shaders/cloth_rough.js +4 -16
  19. package/dist/shaders/cloth_smooth.d.ts +1 -1
  20. package/dist/shaders/cloth_smooth.d.ts.map +1 -1
  21. package/dist/shaders/cloth_smooth.js +5 -17
  22. package/dist/shaders/default.d.ts +1 -1
  23. package/dist/shaders/default.d.ts.map +1 -1
  24. package/dist/shaders/eye.d.ts +1 -1
  25. package/dist/shaders/eye.d.ts.map +1 -1
  26. package/dist/shaders/face.d.ts +1 -1
  27. package/dist/shaders/face.d.ts.map +1 -1
  28. package/dist/shaders/face.js +21 -57
  29. package/dist/shaders/hair.d.ts +1 -1
  30. package/dist/shaders/hair.d.ts.map +1 -1
  31. package/dist/shaders/hair.js +7 -27
  32. package/dist/shaders/metal.d.ts +1 -1
  33. package/dist/shaders/metal.d.ts.map +1 -1
  34. package/dist/shaders/metal.js +4 -17
  35. package/dist/shaders/nodes.d.ts +1 -1
  36. package/dist/shaders/nodes.d.ts.map +1 -1
  37. package/dist/shaders/nodes.js +0 -9
  38. package/dist/shaders/passes/gizmo.d.ts +2 -0
  39. package/dist/shaders/passes/gizmo.d.ts.map +1 -0
  40. package/dist/shaders/passes/gizmo.js +75 -0
  41. package/dist/shaders/passes/pick.d.ts +1 -1
  42. package/dist/shaders/passes/pick.d.ts.map +1 -1
  43. package/dist/shaders/passes/pick.js +29 -5
  44. package/dist/shaders/passes/selection.d.ts +3 -0
  45. package/dist/shaders/passes/selection.d.ts.map +1 -0
  46. package/dist/shaders/passes/selection.js +65 -0
  47. package/dist/shaders/stockings.d.ts +1 -1
  48. package/dist/shaders/stockings.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/camera.ts +14 -0
  51. package/src/engine.ts +830 -9
  52. package/src/index.ts +3 -0
  53. package/src/model.ts +38 -1
  54. package/src/shaders/passes/gizmo.ts +76 -0
  55. package/src/shaders/passes/pick.ts +29 -5
  56. package/src/shaders/passes/selection.ts +67 -0
  57. package/dist/bezier-interpolate.d.ts +0 -15
  58. package/dist/bezier-interpolate.d.ts.map +0 -1
  59. package/dist/bezier-interpolate.js +0 -40
  60. package/dist/engine_ts.d.ts +0 -143
  61. package/dist/engine_ts.d.ts.map +0 -1
  62. package/dist/engine_ts.js +0 -1575
  63. package/dist/ik.d.ts +0 -32
  64. package/dist/ik.d.ts.map +0 -1
  65. package/dist/ik.js +0 -337
  66. package/dist/player.d.ts +0 -64
  67. package/dist/player.d.ts.map +0 -1
  68. package/dist/player.js +0 -220
  69. package/dist/pool-scene.d.ts +0 -52
  70. package/dist/pool-scene.d.ts.map +0 -1
  71. package/dist/pool-scene.js +0 -1122
  72. package/dist/pool.d.ts +0 -38
  73. package/dist/pool.d.ts.map +0 -1
  74. package/dist/pool.js +0 -422
  75. package/dist/rzm-converter.d.ts +0 -12
  76. package/dist/rzm-converter.d.ts.map +0 -1
  77. package/dist/rzm-converter.js +0 -40
  78. package/dist/rzm-loader.d.ts +0 -24
  79. package/dist/rzm-loader.d.ts.map +0 -1
  80. package/dist/rzm-loader.js +0 -488
  81. package/dist/rzm-writer.d.ts +0 -27
  82. package/dist/rzm-writer.d.ts.map +0 -1
  83. package/dist/rzm-writer.js +0 -701
package/src/engine.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Camera } from "./camera"
2
- import { Mat4, Vec3 } from "./math"
2
+ import { Mat4, Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
4
  import { PmxLoader } from "./pmx-loader"
5
5
  import { Physics, type PhysicsOptions } from "./physics"
@@ -27,6 +27,8 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
27
27
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
28
28
  import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground"
29
29
  import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline"
30
+ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
31
+ import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
30
32
  import {
31
33
  BLOOM_BLIT_SHADER_WGSL,
32
34
  BLOOM_DOWNSAMPLE_SHADER_WGSL,
@@ -59,7 +61,13 @@ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined)
59
61
  return "default"
60
62
  }
61
63
 
62
- export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
64
+ export type RaycastCallback = (
65
+ modelName: string,
66
+ material: string | null,
67
+ bone: string | null,
68
+ screenX: number,
69
+ screenY: number,
70
+ ) => void
63
71
 
64
72
  /** Select a folder (webkitdirectory) and pass FileList or File[]; pmxFile picks which .pmx when several exist. */
65
73
  export type LoadModelFromFilesOptions = {
@@ -132,6 +140,33 @@ export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
132
140
  look: "medium_high_contrast",
133
141
  }
134
142
 
143
+ export type GizmoDragKind = "rotate" | "translate"
144
+
145
+ export interface GizmoDragEvent {
146
+ modelName: string
147
+ boneName: string
148
+ boneIndex: number
149
+ kind: GizmoDragKind
150
+ /** Computed target local rotation (for "rotate") / target local translation (for "translate"). */
151
+ localRotation: Quat
152
+ localTranslation: Vec3
153
+ /** Drag start (mousedown) or end (mouseup). Undefined during drag moves. */
154
+ phase?: "start" | "end"
155
+ }
156
+
157
+ /**
158
+ * Gizmo drag callback. The engine does NOT write to the skeleton on its own —
159
+ * it only computes the target local rotation / translation for the dragged bone
160
+ * and fires this callback. The host decides how to apply it (e.g. call
161
+ * `model.setBoneLocalRotation(boneIndex, localRotation)` for a runtime-only
162
+ * edit, call `rotateBones({ [boneName]: localRotation }, 0)` for a tweened
163
+ * write, or mutate an animation clip keyframe and re-seek).
164
+ *
165
+ * Fires once with phase="start" on mousedown, on every mousemove (no phase),
166
+ * and once with phase="end" on mouseup.
167
+ */
168
+ export type GizmoDragCallback = (event: GizmoDragEvent) => void
169
+
135
170
  export type EngineOptions = {
136
171
  world?: WorldOptions
137
172
  sun?: SunOptions
@@ -141,6 +176,8 @@ export type EngineOptions = {
141
176
  /** View transform (exposure/gamma) applied in composite before/after Filmic. */
142
177
  view?: Partial<ViewTransformOptions>
143
178
  onRaycast?: RaycastCallback
179
+ /** See {@link GizmoDragCallback}. */
180
+ onGizmoDrag?: GizmoDragCallback
144
181
  physicsOptions?: PhysicsOptions
145
182
  }
146
183
 
@@ -237,6 +274,59 @@ export class Engine {
237
274
  private groundShadowPipeline!: GPURenderPipeline
238
275
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
239
276
  private outlinePipeline!: GPURenderPipeline
277
+ private selectedMaterial: { modelName: string; materialName: string } | null = null
278
+ private selectionMaskTexture?: GPUTexture
279
+ private selectionMaskView?: GPUTextureView
280
+ private selectionMaskPipeline!: GPURenderPipeline
281
+ private selectionMaskPassDescriptor!: GPURenderPassDescriptor
282
+ private selectionEdgePipeline!: GPURenderPipeline
283
+ private selectionEdgeBindGroupLayout!: GPUBindGroupLayout
284
+ private selectionEdgeBindGroup?: GPUBindGroup
285
+ private selectionEdgeUniformBuffer!: GPUBuffer
286
+ private selectionEdgePassDescriptor!: GPURenderPassDescriptor
287
+ private selectionSampler!: GPUSampler
288
+
289
+ // ─── Transform gizmo ───────────────────────────────────────────────
290
+ private selectedBone: { modelName: string; boneName: string; boneIndex: number } | null = null
291
+ private gizmoVertexBuffer!: GPUBuffer
292
+ private gizmoTransformBuffer!: GPUBuffer
293
+ private gizmoPipeline!: GPURenderPipeline
294
+ private gizmoBindGroup0!: GPUBindGroup
295
+ private gizmoColorBindGroups: GPUBindGroup[] = []
296
+ private gizmoPassDescriptor!: GPURenderPassDescriptor
297
+ private static readonly GIZMO_RING_SEGMENTS = 96
298
+ private static readonly GIZMO_RING_RADIUS = 0.8
299
+ // Axis visible length (relative to gizmo size). Extends past ring radius so
300
+ // the "arrow stub" sticking out of the ring is a comfortable click target.
301
+ private static readonly GIZMO_AXIS_LENGTH = 1.25
302
+ // Draw ranges derived from GIZMO_RING_SEGMENTS at init (setupGizmo) so the
303
+ // segment-count constant is the single source of truth. Axes: 3 × 6 = 18
304
+ // verts; each ring: SEG × 6 verts.
305
+ private gizmoDraws!: { first: number; count: number; color: number }[]
306
+ private static readonly GIZMO_WORLD_SIZE = 1.5
307
+ private static readonly GIZMO_THICKNESS_PX = 15.0
308
+ private static readonly GIZMO_PICK_THRESHOLD_PX = 17.0
309
+
310
+ // Drag state — set on mousedown if the pointer is over a gizmo handle; cleared
311
+ // on mouseup. While non-null, the camera is locked and mousemove/up are routed
312
+ // to the drag handler. All vectors/quats stored are in world / local frames as
313
+ // indicated; we snapshot "initial" values on drag start so the drag is driven
314
+ // by mouse-delta relative to the click point (not cumulative frame-to-frame).
315
+ private gizmoDrag: {
316
+ kind: "axis" | "ring"
317
+ axis: 0 | 1 | 2 // local-axis index: 0 = X, 1 = Y, 2 = Z (bone-local)
318
+ bonePos: Vec3 // gizmo world origin at drag start
319
+ worldAxis: Vec3 // snapshot of the local axis rotated into world at drag start
320
+ // Ring drag: in-plane basis vectors (world) perpendicular to worldAxis.
321
+ basisU: Vec3
322
+ basisV: Vec3
323
+ initialLocalRot: Quat
324
+ initialLocalTrans: Vec3
325
+ parentWorldRot: Quat // parent bone's world rotation (identity if no parent)
326
+ parentWorldRotInv: Quat
327
+ initialAngle: number
328
+ initialAxisParam: number
329
+ } | null = null
240
330
  private mainPerFrameBindGroupLayout!: GPUBindGroupLayout
241
331
  private mainPerInstanceBindGroupLayout!: GPUBindGroupLayout
242
332
  private mainPerMaterialBindGroupLayout!: GPUBindGroupLayout
@@ -319,7 +409,7 @@ export class Engine {
319
409
  private bloomUpsampleBindGroups: GPUBindGroup[] = []
320
410
  /** Single-attachment pass; colorAttachments[0].view set per bloom step. */
321
411
  private bloomPassDescriptor!: GPURenderPassDescriptor
322
- private static readonly BLOOM_MAX_LEVELS = 7
412
+ private static readonly BLOOM_MAX_LEVELS = 5
323
413
 
324
414
  // Ground properties (shadow only)
325
415
  private groundVertexBuffer?: GPUBuffer
@@ -339,6 +429,7 @@ export class Engine {
339
429
  private groundDrawCall: DrawCall | null = null
340
430
 
341
431
  private onRaycast?: RaycastCallback
432
+ private onGizmoDrag?: GizmoDragCallback
342
433
  private physicsOptions: PhysicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions
343
434
  private lastTouchTime = 0
344
435
  private readonly DOUBLE_TAP_DELAY = 300
@@ -402,6 +493,7 @@ export class Engine {
402
493
  fov: options?.camera?.fov ?? d.camera.fov,
403
494
  }
404
495
  this.onRaycast = options?.onRaycast
496
+ this.onGizmoDrag = options?.onGizmoDrag
405
497
  this.physicsOptions = options?.physicsOptions ?? d.physicsOptions
406
498
  this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom)
407
499
  this.viewTransform = Engine.mergeViewTransformDefaults(options?.view)
@@ -1164,6 +1256,82 @@ export class Engine {
1164
1256
  },
1165
1257
  })
1166
1258
 
1259
+ // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
1260
+ // Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
1261
+ const selectionMaskPipelineLayout = this.device.createPipelineLayout({
1262
+ label: "selection mask pipeline layout",
1263
+ bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout],
1264
+ })
1265
+ const selectionMaskShaderModule = this.device.createShaderModule({
1266
+ label: "selection mask shader",
1267
+ code: SELECTION_MASK_SHADER_WGSL,
1268
+ })
1269
+ this.selectionMaskPipeline = this.device.createRenderPipeline({
1270
+ label: "selection mask pipeline",
1271
+ layout: selectionMaskPipelineLayout,
1272
+ vertex: { module: selectionMaskShaderModule, entryPoint: "vs", buffers: outlineVertexBuffers },
1273
+ fragment: {
1274
+ module: selectionMaskShaderModule,
1275
+ entryPoint: "fs",
1276
+ targets: [{ format: "r8unorm" }],
1277
+ },
1278
+ primitive: { cullMode: "none" },
1279
+ // Single-sample, no depth (depth-always via not attaching a depth buffer at all).
1280
+ multisample: { count: 1 },
1281
+ })
1282
+
1283
+ this.selectionEdgeBindGroupLayout = this.device.createBindGroupLayout({
1284
+ label: "selection edge bind group layout",
1285
+ entries: [
1286
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1287
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1288
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1289
+ ],
1290
+ })
1291
+ const selectionEdgePipelineLayout = this.device.createPipelineLayout({
1292
+ label: "selection edge pipeline layout",
1293
+ bindGroupLayouts: [this.selectionEdgeBindGroupLayout],
1294
+ })
1295
+ const selectionEdgeShaderModule = this.device.createShaderModule({
1296
+ label: "selection edge shader",
1297
+ code: SELECTION_EDGE_SHADER_WGSL,
1298
+ })
1299
+ this.selectionEdgePipeline = this.device.createRenderPipeline({
1300
+ label: "selection edge pipeline",
1301
+ layout: selectionEdgePipelineLayout,
1302
+ vertex: { module: selectionEdgeShaderModule, entryPoint: "vs" },
1303
+ fragment: {
1304
+ module: selectionEdgeShaderModule,
1305
+ entryPoint: "fs",
1306
+ targets: [
1307
+ {
1308
+ format: this.presentationFormat,
1309
+ blend: {
1310
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
1311
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
1312
+ },
1313
+ },
1314
+ ],
1315
+ },
1316
+ primitive: { topology: "triangle-list" },
1317
+ multisample: { count: 1 },
1318
+ })
1319
+ this.selectionSampler = this.device.createSampler({
1320
+ label: "selection sampler",
1321
+ magFilter: "linear",
1322
+ minFilter: "linear",
1323
+ })
1324
+ this.selectionEdgeUniformBuffer = this.device.createBuffer({
1325
+ label: "selection edge uniforms",
1326
+ size: 16,
1327
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1328
+ })
1329
+ // thickness (pixels), + 3 floats padding
1330
+ this.device.queue.writeBuffer(this.selectionEdgeUniformBuffer, 0, new Float32Array([5.0, 0, 0, 0]))
1331
+
1332
+ // ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
1333
+ this.setupGizmo()
1334
+
1167
1335
  // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
1168
1336
  // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
1169
1337
  // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
@@ -1378,6 +1546,14 @@ export class Engine {
1378
1546
  this.canvas.addEventListener("dblclick", this.handleCanvasDoubleClick)
1379
1547
  this.canvas.addEventListener("touchend", this.handleCanvasTouch)
1380
1548
  }
1549
+
1550
+ // Gizmo drag. mousedown registered in capture phase so we can consume the
1551
+ // event via stopImmediatePropagation before the camera's mousedown handler
1552
+ // runs (both listen on the canvas). move/up on window so drag tracks even
1553
+ // if the cursor leaves the canvas.
1554
+ this.canvas.addEventListener("mousedown", this.handleGizmoMouseDown, { capture: true })
1555
+ window.addEventListener("mousemove", this.handleGizmoMouseMove)
1556
+ window.addEventListener("mouseup", this.handleGizmoMouseUp)
1381
1557
  }
1382
1558
 
1383
1559
  private handleResize() {
@@ -1512,6 +1688,46 @@ export class Engine {
1512
1688
  ],
1513
1689
  }
1514
1690
 
1691
+ // Selection mask: single-channel canvas-res texture. Depth-always (no depth attachment).
1692
+ this.selectionMaskTexture = this.device.createTexture({
1693
+ label: "selection mask",
1694
+ size: [width, height],
1695
+ format: "r8unorm",
1696
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1697
+ })
1698
+ this.selectionMaskView = this.selectionMaskTexture.createView()
1699
+ this.selectionMaskPassDescriptor = {
1700
+ label: "selection mask pass",
1701
+ colorAttachments: [
1702
+ {
1703
+ view: this.selectionMaskView,
1704
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1705
+ loadOp: "clear",
1706
+ storeOp: "store",
1707
+ },
1708
+ ],
1709
+ }
1710
+ this.selectionEdgeBindGroup = this.device.createBindGroup({
1711
+ label: "selection edge bind group",
1712
+ layout: this.selectionEdgeBindGroupLayout,
1713
+ entries: [
1714
+ { binding: 0, resource: this.selectionMaskView },
1715
+ { binding: 1, resource: this.selectionSampler },
1716
+ { binding: 2, resource: { buffer: this.selectionEdgeUniformBuffer } },
1717
+ ],
1718
+ })
1719
+ // Edge pass draws on top of the composite output — load-store on swapchain.
1720
+ this.selectionEdgePassDescriptor = {
1721
+ label: "selection edge pass",
1722
+ colorAttachments: [
1723
+ {
1724
+ view: undefined as unknown as GPUTextureView,
1725
+ loadOp: "load",
1726
+ storeOp: "store",
1727
+ },
1728
+ ],
1729
+ }
1730
+
1515
1731
  this.writeBloomUniforms()
1516
1732
 
1517
1733
  if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
@@ -1594,6 +1810,172 @@ export class Engine {
1594
1810
  }
1595
1811
  }
1596
1812
 
1813
+ // Builds the gizmo pipeline, its shared transform bind group, 3 per-color bind
1814
+ // groups (R/G/B), and the packed triangle-list vertex buffer. Each original
1815
+ // line segment is expanded to 6 verts (2 triangles) carrying (pos, dir, side)
1816
+ // so the VS can extrude to a uniform pixel-width ribbon.
1817
+ private setupGizmo() {
1818
+ const SEG = Engine.GIZMO_RING_SEGMENTS
1819
+ const R = Engine.GIZMO_RING_RADIUS
1820
+ const ringVerts = SEG * 6
1821
+ this.gizmoDraws = [
1822
+ { first: 0, count: 6, color: 0 }, // X axis
1823
+ { first: 6, count: 6, color: 1 }, // Y axis
1824
+ { first: 12, count: 6, color: 2 }, // Z axis
1825
+ { first: 18, count: ringVerts, color: 0 }, // X ring (YZ plane)
1826
+ { first: 18 + ringVerts, count: ringVerts, color: 1 }, // Y ring (XZ plane)
1827
+ { first: 18 + 2 * ringVerts, count: ringVerts, color: 2 }, // Z ring (XY plane)
1828
+ ]
1829
+ const verts: number[] = []
1830
+ // Per-vertex layout: pos(3), segDir(3), side(1), axisT(1) = 8 floats.
1831
+ // axisT encodes "parameter along the axis" for axis verts (0 at center, 1
1832
+ // at tip). Ring verts use -1 as a "not an axis" flag the FS uses to skip
1833
+ // the dash + fade treatment.
1834
+ const pushSeg = (
1835
+ p0: [number, number, number],
1836
+ p1: [number, number, number],
1837
+ t0: number,
1838
+ t1: number,
1839
+ ) => {
1840
+ const d = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]]
1841
+ const dn = [-d[0], -d[1], -d[2]]
1842
+ verts.push(p0[0], p0[1], p0[2], d[0], d[1], d[2], -1, t0)
1843
+ verts.push(p0[0], p0[1], p0[2], d[0], d[1], d[2], 1, t0)
1844
+ verts.push(p1[0], p1[1], p1[2], dn[0], dn[1], dn[2], -1, t1)
1845
+ verts.push(p0[0], p0[1], p0[2], d[0], d[1], d[2], 1, t0)
1846
+ verts.push(p1[0], p1[1], p1[2], dn[0], dn[1], dn[2], 1, t1)
1847
+ verts.push(p1[0], p1[1], p1[2], dn[0], dn[1], dn[2], -1, t1)
1848
+ }
1849
+ // Axes (open). t = 0 at center → 1 at tip. FS dashes + dims the inside-ring part.
1850
+ const L = Engine.GIZMO_AXIS_LENGTH
1851
+ pushSeg([0, 0, 0], [L, 0, 0], 0, 1)
1852
+ pushSeg([0, 0, 0], [0, L, 0], 0, 1)
1853
+ pushSeg([0, 0, 0], [0, 0, L], 0, 1)
1854
+ // Rings (closed). t = -1 signals "not an axis".
1855
+ for (let plane = 0; plane < 3; plane++) {
1856
+ for (let i = 0; i < SEG; i++) {
1857
+ const t0 = (i / SEG) * Math.PI * 2
1858
+ const t1 = ((i + 1) / SEG) * Math.PI * 2
1859
+ const c0 = Math.cos(t0) * R, s0 = Math.sin(t0) * R
1860
+ const c1 = Math.cos(t1) * R, s1 = Math.sin(t1) * R
1861
+ if (plane === 0) pushSeg([0, c0, s0], [0, c1, s1], -1, -1)
1862
+ else if (plane === 1) pushSeg([s0, 0, c0], [s1, 0, c1], -1, -1)
1863
+ else pushSeg([c0, s0, 0], [c1, s1, 0], -1, -1)
1864
+ }
1865
+ }
1866
+ const geom = new Float32Array(verts)
1867
+ this.gizmoVertexBuffer = this.device.createBuffer({
1868
+ label: "gizmo vertex buffer",
1869
+ size: geom.byteLength,
1870
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1871
+ })
1872
+ this.device.queue.writeBuffer(this.gizmoVertexBuffer, 0, geom)
1873
+
1874
+ // Shared transform+viewport+thickness uniform. Rewritten per frame.
1875
+ this.gizmoTransformBuffer = this.device.createBuffer({
1876
+ label: "gizmo transform",
1877
+ size: 80, // mat4 (64) + vec2 viewport (8) + thickness f32 (4) + pad (4)
1878
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1879
+ })
1880
+
1881
+ const bg0Layout = this.device.createBindGroupLayout({
1882
+ label: "gizmo group 0 layout (camera + transform)",
1883
+ entries: [
1884
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
1885
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
1886
+ ],
1887
+ })
1888
+ const bg1Layout = this.device.createBindGroupLayout({
1889
+ label: "gizmo group 1 layout (color)",
1890
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
1891
+ })
1892
+ const pipelineLayout = this.device.createPipelineLayout({
1893
+ label: "gizmo pipeline layout",
1894
+ bindGroupLayouts: [bg0Layout, bg1Layout],
1895
+ })
1896
+ const shader = this.device.createShaderModule({ label: "gizmo shader", code: GIZMO_SHADER_WGSL })
1897
+ this.gizmoPipeline = this.device.createRenderPipeline({
1898
+ label: "gizmo pipeline",
1899
+ layout: pipelineLayout,
1900
+ vertex: {
1901
+ module: shader,
1902
+ entryPoint: "vs",
1903
+ buffers: [
1904
+ {
1905
+ arrayStride: 8 * 4, // pos(3) + segDir(3) + side(1) + axisT(1) = 8 floats
1906
+ attributes: [
1907
+ { shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }, // position
1908
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat }, // segDir
1909
+ { shaderLocation: 2, offset: 6 * 4, format: "float32" as GPUVertexFormat }, // side
1910
+ { shaderLocation: 3, offset: 7 * 4, format: "float32" as GPUVertexFormat }, // axisT
1911
+ ],
1912
+ },
1913
+ ],
1914
+ },
1915
+ fragment: {
1916
+ module: shader,
1917
+ entryPoint: "fs",
1918
+ targets: [
1919
+ {
1920
+ format: this.presentationFormat,
1921
+ blend: {
1922
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
1923
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
1924
+ },
1925
+ },
1926
+ ],
1927
+ },
1928
+ primitive: { topology: "triangle-list", cullMode: "none" },
1929
+ multisample: { count: 1 },
1930
+ })
1931
+
1932
+ this.gizmoBindGroup0 = this.device.createBindGroup({
1933
+ label: "gizmo bind group 0",
1934
+ layout: bg0Layout,
1935
+ entries: [
1936
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1937
+ { binding: 1, resource: { buffer: this.gizmoTransformBuffer } },
1938
+ ],
1939
+ })
1940
+
1941
+ // Vivid game-UI palette. FS applies an edge-to-center alpha falloff so these
1942
+ // full-saturation colors stay readable without feeling flat. Pipeline writes
1943
+ // straight to the LDR swapchain (no tonemap), so values > 1 clamp.
1944
+ const colors = [
1945
+ new Float32Array([1.0, 0.24, 0.38, 1.0]), // X: warm red, slight pink
1946
+ new Float32Array([0.35, 0.95, 0.52, 1.0]), // Y: emerald
1947
+ new Float32Array([0.33, 0.62, 1.0, 1.0]), // Z: azure
1948
+ ]
1949
+ this.gizmoColorBindGroups = []
1950
+ for (let i = 0; i < 3; i++) {
1951
+ const buf = this.device.createBuffer({
1952
+ label: `gizmo color ${i}`,
1953
+ size: 16,
1954
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1955
+ })
1956
+ this.device.queue.writeBuffer(buf, 0, colors[i])
1957
+ this.gizmoColorBindGroups.push(
1958
+ this.device.createBindGroup({
1959
+ label: `gizmo color bg ${i}`,
1960
+ layout: bg1Layout,
1961
+ entries: [{ binding: 0, resource: { buffer: buf } }],
1962
+ }),
1963
+ )
1964
+ }
1965
+
1966
+ // Gizmo pass — depth-less, loads the swapchain so it composites on top.
1967
+ this.gizmoPassDescriptor = {
1968
+ label: "gizmo pass",
1969
+ colorAttachments: [
1970
+ {
1971
+ view: undefined as unknown as GPUTextureView,
1972
+ loadOp: "load",
1973
+ storeOp: "store",
1974
+ },
1975
+ ],
1976
+ }
1977
+ }
1978
+
1597
1979
  // Step 4: Create camera and uniform buffer
1598
1980
  private setupCamera() {
1599
1981
  this.cameraUniformBuffer = this.device.createBuffer({
@@ -1822,6 +2204,11 @@ export class Engine {
1822
2204
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch)
1823
2205
  }
1824
2206
 
2207
+ // Remove gizmo drag listeners
2208
+ this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true })
2209
+ window.removeEventListener("mousemove", this.handleGizmoMouseMove)
2210
+ window.removeEventListener("mouseup", this.handleGizmoMouseUp)
2211
+
1825
2212
  if (this.resizeObserver) {
1826
2213
  this.resizeObserver.disconnect()
1827
2214
  this.resizeObserver = null
@@ -1909,6 +2296,24 @@ export class Engine {
1909
2296
  }
1910
2297
  }
1911
2298
 
2299
+ setSelectedMaterial(modelName: string | null, materialName: string | null): void {
2300
+ this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null
2301
+ }
2302
+
2303
+ setSelectedBone(modelName: string | null, boneName: string | null): void {
2304
+ if (!modelName || !boneName) {
2305
+ this.selectedBone = null
2306
+ return
2307
+ }
2308
+ const inst = this.modelInstances.get(modelName)
2309
+ if (!inst) {
2310
+ this.selectedBone = null
2311
+ return
2312
+ }
2313
+ const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName)
2314
+ this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null
2315
+ }
2316
+
1912
2317
  setMaterialPresets(modelName: string, presets: MaterialPresetMap): void {
1913
2318
  const inst = this.modelInstances.get(modelName)
1914
2319
  if (!inst) return
@@ -2241,7 +2646,7 @@ export class Engine {
2241
2646
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
2242
2647
  const up = Math.abs(dir.y) > 0.99 ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0)
2243
2648
  const view = Mat4.lookAt(eye, target, up)
2244
- const proj = Mat4.orthographicLh(-72, 72, -72, 72, 1, 140)
2649
+ const proj = Mat4.orthographicLh(-32, 32, -32, 32, 1, 140)
2245
2650
  const vp = proj.multiply(view)
2246
2651
  this.shadowLightVPMatrix.set(vp.values)
2247
2652
  this.device.queue.writeBuffer(this.shadowLightVPBuffer, 0, this.shadowLightVPMatrix)
@@ -2528,13 +2933,420 @@ export class Engine {
2528
2933
 
2529
2934
  private performRaycast(screenX: number, screenY: number) {
2530
2935
  if (!this.onRaycast || this.modelInstances.size === 0) {
2531
- this.onRaycast?.("", null, screenX, screenY)
2936
+ this.onRaycast?.("", null, null, screenX, screenY)
2532
2937
  return
2533
2938
  }
2534
2939
  const dpr = window.devicePixelRatio || 1
2535
2940
  this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) }
2536
2941
  }
2537
2942
 
2943
+ private renderSelectionPasses(encoder: GPUCommandEncoder, swapchainView: GPUTextureView): void {
2944
+ if (!this.selectedMaterial || !this.selectionEdgeBindGroup) return
2945
+ const inst = this.modelInstances.get(this.selectedMaterial.modelName)
2946
+ if (!inst) return
2947
+ const target = this.selectedMaterial.materialName
2948
+ const draw = inst.drawCalls.find(
2949
+ (d) => (d.type === "opaque" || d.type === "transparent") && d.materialName === target,
2950
+ )
2951
+ if (!draw || !this.shouldRenderDrawCall(inst, draw)) return
2952
+
2953
+ // Mask pass: fill the selected material's projected footprint with 1.0. Depth-always
2954
+ // (no depth attachment) so the outline traces complete boundaries even when the
2955
+ // material is partially occluded — matches Blender selection-through behaviour.
2956
+ const mpass = encoder.beginRenderPass(this.selectionMaskPassDescriptor)
2957
+ mpass.setPipeline(this.selectionMaskPipeline)
2958
+ mpass.setBindGroup(0, this.outlinePerFrameBindGroup)
2959
+ mpass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2960
+ mpass.setVertexBuffer(0, inst.vertexBuffer)
2961
+ mpass.setVertexBuffer(1, inst.jointsBuffer)
2962
+ mpass.setVertexBuffer(2, inst.weightsBuffer)
2963
+ mpass.setIndexBuffer(inst.indexBuffer, "uint32")
2964
+ mpass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2965
+ mpass.end()
2966
+
2967
+ // Edge pass: screen-space edge detect on the mask, alpha-blended over swapchain.
2968
+ const edgeAttachment = (this.selectionEdgePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2969
+ edgeAttachment.view = swapchainView
2970
+ const epass = encoder.beginRenderPass(this.selectionEdgePassDescriptor)
2971
+ epass.setPipeline(this.selectionEdgePipeline)
2972
+ epass.setBindGroup(0, this.selectionEdgeBindGroup)
2973
+ epass.draw(3)
2974
+ epass.end()
2975
+ }
2976
+
2977
+ // Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
2978
+ // then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
2979
+ // aligns rings with the bone's current world orientation, so clicking a ring
2980
+ // rotates around that bone's natural axis.
2981
+ private renderGizmoPass(encoder: GPUCommandEncoder, swapchainView: GPUTextureView): void {
2982
+ if (!this.selectedBone || !this.camera) return
2983
+ const inst = this.modelInstances.get(this.selectedBone.modelName)
2984
+ if (!inst) return
2985
+ const worldMats = inst.model.getWorldMatrices()
2986
+ if (this.selectedBone.boneIndex >= worldMats.length) return
2987
+
2988
+ const boneMat = worldMats[this.selectedBone.boneIndex]
2989
+ const bonePos = boneMat.getPosition()
2990
+ const q = boneMat.toQuat().normalize() // world rotation
2991
+ const s = Engine.GIZMO_WORLD_SIZE
2992
+
2993
+ // Column-major mat4: rotation columns × scale, then translation in col 3.
2994
+ const xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z
2995
+ const xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z
2996
+ const wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z
2997
+ const u = new Float32Array(20)
2998
+ u[0] = s * (1 - 2 * (yy + zz)); u[1] = s * 2 * (xy + wz); u[2] = s * 2 * (xz - wy); u[3] = 0
2999
+ u[4] = s * 2 * (xy - wz); u[5] = s * (1 - 2 * (xx + zz)); u[6] = s * 2 * (yz + wx); u[7] = 0
3000
+ u[8] = s * 2 * (xz + wy); u[9] = s * 2 * (yz - wx); u[10] = s * (1 - 2 * (xx + yy)); u[11] = 0
3001
+ u[12] = bonePos.x; u[13] = bonePos.y; u[14] = bonePos.z; u[15] = 1
3002
+ u[16] = this.canvas.width
3003
+ u[17] = this.canvas.height
3004
+ u[18] = Engine.GIZMO_THICKNESS_PX
3005
+ u[19] = 0
3006
+ this.device.queue.writeBuffer(this.gizmoTransformBuffer, 0, u)
3007
+
3008
+ const att = (this.gizmoPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
3009
+ att.view = swapchainView
3010
+ const pass = encoder.beginRenderPass(this.gizmoPassDescriptor)
3011
+ pass.setPipeline(this.gizmoPipeline)
3012
+ pass.setBindGroup(0, this.gizmoBindGroup0)
3013
+ pass.setVertexBuffer(0, this.gizmoVertexBuffer)
3014
+ for (const d of this.gizmoDraws) {
3015
+ pass.setBindGroup(1, this.gizmoColorBindGroups[d.color])
3016
+ pass.draw(d.count, 1, d.first, 0)
3017
+ }
3018
+ pass.end()
3019
+ }
3020
+
3021
+ // ──────────────────────────────────────────────────────────────────
3022
+ // Gizmo drag — hit test + input handlers + rotation/translation math
3023
+ // ──────────────────────────────────────────────────────────────────
3024
+
3025
+ private rotateVec3ByQuat(v: Vec3, q: Quat): Vec3 {
3026
+ // Standard rodrigues-via-quat formulation. Cheaper than q * v * q_conj.
3027
+ const tx = 2 * (q.y * v.z - q.z * v.y)
3028
+ const ty = 2 * (q.z * v.x - q.x * v.z)
3029
+ const tz = 2 * (q.x * v.y - q.y * v.x)
3030
+ return new Vec3(
3031
+ v.x + q.w * tx + (q.y * tz - q.z * ty),
3032
+ v.y + q.w * ty + (q.z * tx - q.x * tz),
3033
+ v.z + q.w * tz + (q.x * ty - q.y * tx),
3034
+ )
3035
+ }
3036
+
3037
+ private unproject(invVP: Mat4, ndcX: number, ndcY: number, ndcZ: number): Vec3 | null {
3038
+ const m = invVP.values
3039
+ const x = m[0] * ndcX + m[4] * ndcY + m[8] * ndcZ + m[12]
3040
+ const y = m[1] * ndcX + m[5] * ndcY + m[9] * ndcZ + m[13]
3041
+ const z = m[2] * ndcX + m[6] * ndcY + m[10] * ndcZ + m[14]
3042
+ const w = m[3] * ndcX + m[7] * ndcY + m[11] * ndcZ + m[15]
3043
+ if (Math.abs(w) < 1e-9) return null
3044
+ return new Vec3(x / w, y / w, z / w)
3045
+ }
3046
+
3047
+ // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
3048
+ private buildMouseRay(px: number, py: number): { origin: Vec3; dir: Vec3 } | null {
3049
+ if (!this.camera) return null
3050
+ const width = this.canvas.clientWidth
3051
+ const height = this.canvas.clientHeight
3052
+ if (width <= 0 || height <= 0) return null
3053
+ const ndcX = (px / width) * 2 - 1
3054
+ const ndcY = -((py / height) * 2 - 1)
3055
+ const view = this.camera.getViewMatrix()
3056
+ const proj = this.camera.getProjectionMatrix()
3057
+ const invVP = proj.multiply(view).inverse()
3058
+ const near = this.unproject(invVP, ndcX, ndcY, 0)
3059
+ const far = this.unproject(invVP, ndcX, ndcY, 1)
3060
+ if (!near || !far) return null
3061
+ return { origin: near, dir: far.subtract(near).normalize() }
3062
+ }
3063
+
3064
+ // Finds the closest gizmo handle to the mouse ray, within `worldThreshold`.
3065
+ // `worldAxes[i]` is the i-th local axis rotated into world by bone world rotation.
3066
+ private hitTestGizmo(
3067
+ ray: { origin: Vec3; dir: Vec3 },
3068
+ bonePos: Vec3,
3069
+ gizmoSize: number,
3070
+ worldThreshold: number,
3071
+ worldAxes: [Vec3, Vec3, Vec3],
3072
+ ): { kind: "axis" | "ring"; axis: 0 | 1 | 2 } | null {
3073
+ let bestKind: "axis" | "ring" | null = null
3074
+ let bestAxis: 0 | 1 | 2 = 0
3075
+ let bestDist = worldThreshold
3076
+
3077
+ // Axes only hit on their OUTER portion (past the ring radius). Inside the
3078
+ // ring the axis line passes through the plane of the perpendicular ring
3079
+ // (e.g. X-axis passes through the interior of the Y ring), so including the
3080
+ // full axis produced ring-vs-axis ties and constant misclicks. Axis extends
3081
+ // to AXIS_LENGTH, so the hit zone is roughly half the visible axis length —
3082
+ // easy to grab while leaving the ring's interior unambiguous.
3083
+ const axisHitStart = gizmoSize * (Engine.GIZMO_RING_RADIUS + 0.05)
3084
+ const axisHitEnd = gizmoSize * Engine.GIZMO_AXIS_LENGTH
3085
+ for (let i = 0; i < 3; i++) {
3086
+ const segA = bonePos.add(worldAxes[i].scale(axisHitStart))
3087
+ const segB = bonePos.add(worldAxes[i].scale(axisHitEnd))
3088
+ const d = this.distSegmentRay(segA, segB, ray.origin, ray.dir)
3089
+ if (d < bestDist) {
3090
+ bestDist = d
3091
+ bestKind = "axis"
3092
+ bestAxis = i as 0 | 1 | 2
3093
+ }
3094
+ }
3095
+
3096
+ const ringR = gizmoSize * Engine.GIZMO_RING_RADIUS
3097
+ for (let i = 0; i < 3; i++) {
3098
+ const n = worldAxes[i]
3099
+ const denom = ray.dir.dot(n)
3100
+ if (Math.abs(denom) < 1e-6) continue
3101
+ const t = bonePos.subtract(ray.origin).dot(n) / denom
3102
+ if (t < 0) continue
3103
+ const hit = ray.origin.add(ray.dir.scale(t))
3104
+ const rel = hit.subtract(bonePos)
3105
+ const radial = rel.subtract(n.scale(rel.dot(n)))
3106
+ const radius = radial.length()
3107
+ const d = Math.abs(radius - ringR)
3108
+ if (d < bestDist) {
3109
+ bestDist = d
3110
+ bestKind = "ring"
3111
+ bestAxis = i as 0 | 1 | 2
3112
+ }
3113
+ }
3114
+
3115
+ return bestKind ? { kind: bestKind, axis: bestAxis } : null
3116
+ }
3117
+
3118
+ // Shortest distance between segment [A, B] and ray (origin, dir-unit).
3119
+ private distSegmentRay(A: Vec3, B: Vec3, rayO: Vec3, rayD: Vec3): number {
3120
+ const u = B.subtract(A) // segment direction (not normalized)
3121
+ const w = A.subtract(rayO)
3122
+ const a = u.dot(u)
3123
+ const b = u.dot(rayD)
3124
+ const d = u.dot(w)
3125
+ const e = rayD.dot(w)
3126
+ const denom = a - b * b // since |rayD|=1
3127
+ let sc: number, tc: number
3128
+ if (Math.abs(denom) < 1e-9) {
3129
+ sc = 0
3130
+ tc = e
3131
+ } else {
3132
+ sc = (b * e - d) / denom
3133
+ tc = (a * e - b * d) / denom
3134
+ }
3135
+ sc = Math.max(0, Math.min(1, sc))
3136
+ if (tc < 0) tc = 0
3137
+ const ps = new Vec3(A.x + sc * u.x, A.y + sc * u.y, A.z + sc * u.z)
3138
+ const pr = new Vec3(rayO.x + tc * rayD.x, rayO.y + tc * rayD.y, rayO.z + tc * rayD.z)
3139
+ return ps.subtract(pr).length()
3140
+ }
3141
+
3142
+ // Line-line closest point: returns the parameter t on line (A, dir) where the
3143
+ // closest approach to the ray is. Used by axis-translation drag so frame N
3144
+ // reads a signed delta vs the mouse-down snapshot.
3145
+ private closestParamOnAxisLine(A: Vec3, dir: Vec3, rayO: Vec3, rayD: Vec3): number {
3146
+ const w = A.subtract(rayO)
3147
+ const b = dir.dot(rayD)
3148
+ const d = dir.dot(w)
3149
+ const e = rayD.dot(w)
3150
+ const denom = 1 - b * b // |dir|=|rayD|=1
3151
+ if (Math.abs(denom) < 1e-9) return -d // lines parallel
3152
+ return (b * e - d) / denom
3153
+ }
3154
+
3155
+ // Ray-vs-plane (point bonePos, normal n). Returns the hit point or null.
3156
+ private rayPlane(rayO: Vec3, rayD: Vec3, bonePos: Vec3, n: Vec3): Vec3 | null {
3157
+ const denom = rayD.dot(n)
3158
+ if (Math.abs(denom) < 1e-6) return null
3159
+ const t = bonePos.subtract(rayO).dot(n) / denom
3160
+ if (t < 0) return null
3161
+ return rayO.add(rayD.scale(t))
3162
+ }
3163
+
3164
+ // 2D angle of `hit` around `bonePos` in a plane spanned by (u, v). Basis vectors
3165
+ // are snapshotted at drag start so the angle frame is stable even if the bone
3166
+ // (and gizmo visual) rotates during the drag.
3167
+ private angleInRingPlane(hit: Vec3, bonePos: Vec3, u: Vec3, v: Vec3): number {
3168
+ const rel = hit.subtract(bonePos)
3169
+ return Math.atan2(rel.dot(v), rel.dot(u))
3170
+ }
3171
+
3172
+ private handleGizmoMouseDown = (e: MouseEvent) => {
3173
+ if (!this.selectedBone || !this.camera || !this.device || e.button !== 0) return
3174
+ const inst = this.modelInstances.get(this.selectedBone.modelName)
3175
+ if (!inst) return
3176
+ const worldMats = inst.model.getWorldMatrices()
3177
+ const boneMat = worldMats[this.selectedBone.boneIndex]
3178
+ if (!boneMat) return
3179
+ const bonePos = boneMat.getPosition()
3180
+ const boneWorldRot = boneMat.toQuat().normalize()
3181
+
3182
+ const rect = this.canvas.getBoundingClientRect()
3183
+ const px = e.clientX - rect.left
3184
+ const py = e.clientY - rect.top
3185
+ const ray = this.buildMouseRay(px, py)
3186
+ if (!ray) return
3187
+
3188
+ const gizmoSize = Engine.GIZMO_WORLD_SIZE
3189
+
3190
+ // Bounding-sphere check: if the mouse ray passes inside an imaginary sphere
3191
+ // around the gizmo, ALWAYS consume the event — so the user never accidentally
3192
+ // orbits the camera while trying to click near a handle. Outside the sphere,
3193
+ // let the camera handler take over as normal.
3194
+ const sphereR = gizmoSize * Engine.GIZMO_AXIS_LENGTH * 1.05
3195
+ const f = ray.origin.subtract(bonePos)
3196
+ const fd = f.dot(ray.dir)
3197
+ const rayInsideSphere = fd * fd - (f.dot(f) - sphereR * sphereR) >= 0
3198
+ if (!rayInsideSphere) return
3199
+
3200
+ // We're inside the gizmo's claim area — the event is ours regardless of hit.
3201
+ e.stopImmediatePropagation()
3202
+ e.preventDefault()
3203
+
3204
+ // Pick threshold stays pixel-based — clicking should feel the same at any zoom.
3205
+ const camPos = this.camera.getPosition()
3206
+ const dist = Math.max(0.01, bonePos.subtract(camPos).length())
3207
+ const worldPerPixel = (dist * Math.tan(this.camera.fov * 0.5) * 2) / Math.max(1, this.canvas.clientHeight)
3208
+ const worldThreshold = Engine.GIZMO_PICK_THRESHOLD_PX * worldPerPixel
3209
+
3210
+ // World-rotated local axes (where the visible gizmo arms actually point).
3211
+ const worldAxes: [Vec3, Vec3, Vec3] = [
3212
+ this.rotateVec3ByQuat(new Vec3(1, 0, 0), boneWorldRot),
3213
+ this.rotateVec3ByQuat(new Vec3(0, 1, 0), boneWorldRot),
3214
+ this.rotateVec3ByQuat(new Vec3(0, 0, 1), boneWorldRot),
3215
+ ]
3216
+
3217
+ const hit = this.hitTestGizmo(ray, bonePos, gizmoSize, worldThreshold, worldAxes)
3218
+ if (!hit) return // Inside sphere but didn't hit a handle — event consumed, no drag.
3219
+
3220
+ this.camera.setInputLocked(true)
3221
+
3222
+ const parentIdx = inst.model.getSkeleton().bones[this.selectedBone.boneIndex].parentIndex
3223
+ const parentWorldRot =
3224
+ parentIdx >= 0 && parentIdx < worldMats.length ? worldMats[parentIdx].toQuat().normalize() : Quat.identity()
3225
+ const parentWorldRotInv = parentWorldRot.clone().conjugate()
3226
+
3227
+ const worldAxis = worldAxes[hit.axis]
3228
+ // In-plane basis for the ring: u/v are the OTHER two world-rotated axes.
3229
+ // X ring (normal X) → (u=Y, v=Z); Y ring → (u=Z, v=X); Z ring → (u=X, v=Y)
3230
+ const basisU = hit.axis === 0 ? worldAxes[1] : hit.axis === 1 ? worldAxes[2] : worldAxes[0]
3231
+ const basisV = hit.axis === 0 ? worldAxes[2] : hit.axis === 1 ? worldAxes[0] : worldAxes[1]
3232
+
3233
+ let initialAngle = 0
3234
+ let initialAxisParam = 0
3235
+ if (hit.kind === "ring") {
3236
+ const p = this.rayPlane(ray.origin, ray.dir, bonePos, worldAxis)
3237
+ if (p) initialAngle = this.angleInRingPlane(p, bonePos, basisU, basisV)
3238
+ } else {
3239
+ initialAxisParam = this.closestParamOnAxisLine(bonePos, worldAxis, ray.origin, ray.dir)
3240
+ }
3241
+
3242
+ const initialLocalRot = inst.model.getBoneLocalRotation(this.selectedBone.boneIndex).clone()
3243
+ const initTrans = inst.model.getBoneLocalTranslation(this.selectedBone.boneIndex)
3244
+ const initialLocalTrans = new Vec3(initTrans.x, initTrans.y, initTrans.z)
3245
+
3246
+ this.gizmoDrag = {
3247
+ kind: hit.kind,
3248
+ axis: hit.axis,
3249
+ bonePos,
3250
+ worldAxis,
3251
+ basisU,
3252
+ basisV,
3253
+ initialLocalRot,
3254
+ initialLocalTrans,
3255
+ parentWorldRot,
3256
+ parentWorldRotInv,
3257
+ initialAngle,
3258
+ initialAxisParam,
3259
+ }
3260
+
3261
+ if (this.onGizmoDrag) {
3262
+ this.onGizmoDrag({
3263
+ modelName: this.selectedBone.modelName,
3264
+ boneName: this.selectedBone.boneName,
3265
+ boneIndex: this.selectedBone.boneIndex,
3266
+ kind: hit.kind === "ring" ? "rotate" : "translate",
3267
+ localRotation: initialLocalRot.clone(),
3268
+ localTranslation: new Vec3(initialLocalTrans.x, initialLocalTrans.y, initialLocalTrans.z),
3269
+ phase: "start",
3270
+ })
3271
+ }
3272
+ }
3273
+
3274
+ private handleGizmoMouseMove = (e: MouseEvent) => {
3275
+ const drag = this.gizmoDrag
3276
+ if (!drag || !this.selectedBone || !this.camera) return
3277
+ const inst = this.modelInstances.get(this.selectedBone.modelName)
3278
+ if (!inst) return
3279
+
3280
+ const rect = this.canvas.getBoundingClientRect()
3281
+ const px = e.clientX - rect.left
3282
+ const py = e.clientY - rect.top
3283
+ const ray = this.buildMouseRay(px, py)
3284
+ if (!ray) return
3285
+
3286
+ // Compute the target local rotation / translation. The engine never writes
3287
+ // to the skeleton itself — we hand the result to the host callback and let
3288
+ // it decide (runtime write, tween, clip keyframe edit, …).
3289
+ let nextRot = drag.initialLocalRot
3290
+ let nextTrans = drag.initialLocalTrans
3291
+ if (drag.kind === "ring") {
3292
+ const p = this.rayPlane(ray.origin, ray.dir, drag.bonePos, drag.worldAxis)
3293
+ if (!p) return
3294
+ const currentAngle = this.angleInRingPlane(p, drag.bonePos, drag.basisU, drag.basisV)
3295
+ const deltaAngle = currentAngle - drag.initialAngle
3296
+ const qWorld = Quat.fromAxisAngle(drag.worldAxis, deltaAngle)
3297
+ // L_new = P_inv · Q_world · P · L_initial
3298
+ const lNew = drag.parentWorldRotInv
3299
+ .multiply(qWorld)
3300
+ .multiply(drag.parentWorldRot)
3301
+ .multiply(drag.initialLocalRot)
3302
+ lNew.normalize()
3303
+ nextRot = lNew
3304
+ } else {
3305
+ const tNow = this.closestParamOnAxisLine(drag.bonePos, drag.worldAxis, ray.origin, ray.dir)
3306
+ const deltaParam = tNow - drag.initialAxisParam
3307
+ const worldDelta = drag.worldAxis.scale(deltaParam)
3308
+ const localDelta = this.rotateVec3ByQuat(worldDelta, drag.parentWorldRotInv)
3309
+ nextTrans = new Vec3(
3310
+ drag.initialLocalTrans.x + localDelta.x,
3311
+ drag.initialLocalTrans.y + localDelta.y,
3312
+ drag.initialLocalTrans.z + localDelta.z,
3313
+ )
3314
+ }
3315
+
3316
+ this.onGizmoDrag?.({
3317
+ modelName: this.selectedBone.modelName,
3318
+ boneName: this.selectedBone.boneName,
3319
+ boneIndex: this.selectedBone.boneIndex,
3320
+ kind: drag.kind === "ring" ? "rotate" : "translate",
3321
+ localRotation: nextRot,
3322
+ localTranslation: nextTrans,
3323
+ })
3324
+ }
3325
+
3326
+ private handleGizmoMouseUp = () => {
3327
+ const drag = this.gizmoDrag
3328
+ if (!drag) return
3329
+ if (this.onGizmoDrag && this.selectedBone) {
3330
+ const inst = this.modelInstances.get(this.selectedBone.modelName)
3331
+ if (inst) {
3332
+ const finalRot = inst.model.getBoneLocalRotation(this.selectedBone.boneIndex).clone()
3333
+ const t = inst.model.getBoneLocalTranslation(this.selectedBone.boneIndex)
3334
+ const finalTrans = new Vec3(t.x, t.y, t.z)
3335
+ this.onGizmoDrag({
3336
+ modelName: this.selectedBone.modelName,
3337
+ boneName: this.selectedBone.boneName,
3338
+ boneIndex: this.selectedBone.boneIndex,
3339
+ kind: drag.kind === "ring" ? "rotate" : "translate",
3340
+ localRotation: finalRot,
3341
+ localTranslation: finalTrans,
3342
+ phase: "end",
3343
+ })
3344
+ }
3345
+ }
3346
+ this.gizmoDrag = null
3347
+ this.camera?.setInputLocked(false)
3348
+ }
3349
+
2538
3350
  private renderPickPass(encoder: GPUCommandEncoder): void {
2539
3351
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture) return
2540
3352
 
@@ -2588,10 +3400,11 @@ export class Engine {
2588
3400
  const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange())
2589
3401
  const modelId = data[0]
2590
3402
  const materialId = data[1]
3403
+ const boneId = data[2]
2591
3404
  this.pickReadbackBuffer.unmap()
2592
3405
 
2593
3406
  if (modelId === 0) {
2594
- this.onRaycast("", null, screenX, screenY)
3407
+ this.onRaycast("", null, null, screenX, screenY)
2595
3408
  return
2596
3409
  }
2597
3410
 
@@ -2606,11 +3419,12 @@ export class Engine {
2606
3419
  idx++
2607
3420
  }
2608
3421
 
2609
- // Find material by 1-based index (skipping zero-vertex materials)
2610
3422
  let hitMaterial: string | null = null
3423
+ let hitBone: string | null = null
2611
3424
  if (hitModel) {
2612
3425
  const inst = this.modelInstances.get(hitModel)
2613
3426
  if (inst) {
3427
+ // Find material by 1-based index (skipping zero-vertex materials)
2614
3428
  const materials = inst.model.getMaterials()
2615
3429
  let matIdx = 0
2616
3430
  for (const mat of materials) {
@@ -2621,10 +3435,13 @@ export class Engine {
2621
3435
  break
2622
3436
  }
2623
3437
  }
3438
+ // Bone index is 0-based (matches joints0 attribute values fed to pick shader).
3439
+ const bones = inst.model.getSkeleton().bones
3440
+ if (boneId < bones.length) hitBone = bones[boneId].name
2624
3441
  }
2625
3442
  }
2626
3443
 
2627
- this.onRaycast(hitModel, hitMaterial, screenX, screenY)
3444
+ this.onRaycast(hitModel, hitMaterial, hitBone, screenX, screenY)
2628
3445
  }
2629
3446
 
2630
3447
  render() {
@@ -2716,8 +3533,9 @@ export class Engine {
2716
3533
  }
2717
3534
 
2718
3535
  // Composite: HDR + bloom → Filmic tonemap → swapchain.
3536
+ const swapchainView = this.context.getCurrentTexture().createView()
2719
3537
  const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2720
- compositeAttachment.view = this.context.getCurrentTexture().createView()
3538
+ compositeAttachment.view = swapchainView
2721
3539
  const cpass = encoder.beginRenderPass(this.compositePassDescriptor)
2722
3540
  const compositePipeline =
2723
3541
  this.viewTransform.gamma === 1.0 ? this.compositePipelineIdentity : this.compositePipelineGamma
@@ -2726,6 +3544,9 @@ export class Engine {
2726
3544
  cpass.draw(3)
2727
3545
  cpass.end()
2728
3546
 
3547
+ if (this.selectedMaterial && hasModels) this.renderSelectionPasses(encoder, swapchainView)
3548
+ if (this.selectedBone && hasModels) this.renderGizmoPass(encoder, swapchainView)
3549
+
2729
3550
  const pick = this.pendingPick
2730
3551
  if (pick && hasModels) this.renderPickPass(encoder)
2731
3552