reze-engine 0.13.5 → 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 (41) 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 +730 -9
  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/materials/body.d.ts +1 -1
  14. package/dist/shaders/materials/cloth_rough.d.ts +1 -1
  15. package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
  16. package/dist/shaders/materials/common.d.ts +1 -1
  17. package/dist/shaders/materials/common.js +2 -2
  18. package/dist/shaders/materials/default.d.ts +1 -1
  19. package/dist/shaders/materials/eye.d.ts +1 -1
  20. package/dist/shaders/materials/face.d.ts +1 -1
  21. package/dist/shaders/materials/hair.d.ts +1 -1
  22. package/dist/shaders/materials/metal.d.ts +1 -1
  23. package/dist/shaders/materials/stockings.d.ts +1 -1
  24. package/dist/shaders/passes/gizmo.d.ts +2 -0
  25. package/dist/shaders/passes/gizmo.d.ts.map +1 -0
  26. package/dist/shaders/passes/gizmo.js +75 -0
  27. package/dist/shaders/passes/pick.d.ts +1 -1
  28. package/dist/shaders/passes/pick.d.ts.map +1 -1
  29. package/dist/shaders/passes/pick.js +29 -5
  30. package/dist/shaders/passes/selection.d.ts +3 -0
  31. package/dist/shaders/passes/selection.d.ts.map +1 -0
  32. package/dist/shaders/passes/selection.js +65 -0
  33. package/package.json +1 -1
  34. package/src/camera.ts +14 -0
  35. package/src/engine.ts +831 -10
  36. package/src/index.ts +3 -0
  37. package/src/model.ts +38 -1
  38. package/src/shaders/materials/common.ts +2 -2
  39. package/src/shaders/passes/gizmo.ts +76 -0
  40. package/src/shaders/passes/pick.ts +29 -5
  41. package/src/shaders/passes/selection.ts +67 -0
package/dist/engine.js 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 { PmxLoader } from "./pmx-loader";
4
4
  import { Physics } from "./physics";
5
5
  import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
@@ -17,6 +17,8 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
17
17
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
18
18
  import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground";
19
19
  import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline";
20
+ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
21
+ import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
20
22
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
21
23
  import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite";
22
24
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
@@ -65,6 +67,16 @@ export class Engine {
65
67
  this.lightData = new Float32Array(64);
66
68
  this.lightCount = 0;
67
69
  this.resizeObserver = null;
70
+ this.selectedMaterial = null;
71
+ // ─── Transform gizmo ───────────────────────────────────────────────
72
+ this.selectedBone = null;
73
+ this.gizmoColorBindGroups = [];
74
+ // Drag state — set on mousedown if the pointer is over a gizmo handle; cleared
75
+ // on mouseup. While non-null, the camera is locked and mousemove/up are routed
76
+ // to the drag handler. All vectors/quats stored are in world / local frames as
77
+ // indicated; we snapshot "initial" values on drag start so the drag is driven
78
+ // by mouse-delta relative to the click point (not cumulative frame-to-frame).
79
+ this.gizmoDrag = null;
68
80
  // HDR intermediate format. rg11b10ufloat when the adapter exposes the
69
81
  // `rg11b10ufloat-renderable` feature (Chrome + Safari on Apple Silicon both
70
82
  // do), else fall back to rgba16float.
@@ -154,6 +166,173 @@ export class Engine {
154
166
  this.lastTouchTime = currentTime;
155
167
  }
156
168
  };
169
+ this.handleGizmoMouseDown = (e) => {
170
+ if (!this.selectedBone || !this.camera || !this.device || e.button !== 0)
171
+ return;
172
+ const inst = this.modelInstances.get(this.selectedBone.modelName);
173
+ if (!inst)
174
+ return;
175
+ const worldMats = inst.model.getWorldMatrices();
176
+ const boneMat = worldMats[this.selectedBone.boneIndex];
177
+ if (!boneMat)
178
+ return;
179
+ const bonePos = boneMat.getPosition();
180
+ const boneWorldRot = boneMat.toQuat().normalize();
181
+ const rect = this.canvas.getBoundingClientRect();
182
+ const px = e.clientX - rect.left;
183
+ const py = e.clientY - rect.top;
184
+ const ray = this.buildMouseRay(px, py);
185
+ if (!ray)
186
+ return;
187
+ const gizmoSize = Engine.GIZMO_WORLD_SIZE;
188
+ // Bounding-sphere check: if the mouse ray passes inside an imaginary sphere
189
+ // around the gizmo, ALWAYS consume the event — so the user never accidentally
190
+ // orbits the camera while trying to click near a handle. Outside the sphere,
191
+ // let the camera handler take over as normal.
192
+ const sphereR = gizmoSize * Engine.GIZMO_AXIS_LENGTH * 1.05;
193
+ const f = ray.origin.subtract(bonePos);
194
+ const fd = f.dot(ray.dir);
195
+ const rayInsideSphere = fd * fd - (f.dot(f) - sphereR * sphereR) >= 0;
196
+ if (!rayInsideSphere)
197
+ return;
198
+ // We're inside the gizmo's claim area — the event is ours regardless of hit.
199
+ e.stopImmediatePropagation();
200
+ e.preventDefault();
201
+ // Pick threshold stays pixel-based — clicking should feel the same at any zoom.
202
+ const camPos = this.camera.getPosition();
203
+ const dist = Math.max(0.01, bonePos.subtract(camPos).length());
204
+ const worldPerPixel = (dist * Math.tan(this.camera.fov * 0.5) * 2) / Math.max(1, this.canvas.clientHeight);
205
+ const worldThreshold = Engine.GIZMO_PICK_THRESHOLD_PX * worldPerPixel;
206
+ // World-rotated local axes (where the visible gizmo arms actually point).
207
+ const worldAxes = [
208
+ this.rotateVec3ByQuat(new Vec3(1, 0, 0), boneWorldRot),
209
+ this.rotateVec3ByQuat(new Vec3(0, 1, 0), boneWorldRot),
210
+ this.rotateVec3ByQuat(new Vec3(0, 0, 1), boneWorldRot),
211
+ ];
212
+ const hit = this.hitTestGizmo(ray, bonePos, gizmoSize, worldThreshold, worldAxes);
213
+ if (!hit)
214
+ return; // Inside sphere but didn't hit a handle — event consumed, no drag.
215
+ this.camera.setInputLocked(true);
216
+ const parentIdx = inst.model.getSkeleton().bones[this.selectedBone.boneIndex].parentIndex;
217
+ const parentWorldRot = parentIdx >= 0 && parentIdx < worldMats.length ? worldMats[parentIdx].toQuat().normalize() : Quat.identity();
218
+ const parentWorldRotInv = parentWorldRot.clone().conjugate();
219
+ const worldAxis = worldAxes[hit.axis];
220
+ // In-plane basis for the ring: u/v are the OTHER two world-rotated axes.
221
+ // X ring (normal X) → (u=Y, v=Z); Y ring → (u=Z, v=X); Z ring → (u=X, v=Y)
222
+ const basisU = hit.axis === 0 ? worldAxes[1] : hit.axis === 1 ? worldAxes[2] : worldAxes[0];
223
+ const basisV = hit.axis === 0 ? worldAxes[2] : hit.axis === 1 ? worldAxes[0] : worldAxes[1];
224
+ let initialAngle = 0;
225
+ let initialAxisParam = 0;
226
+ if (hit.kind === "ring") {
227
+ const p = this.rayPlane(ray.origin, ray.dir, bonePos, worldAxis);
228
+ if (p)
229
+ initialAngle = this.angleInRingPlane(p, bonePos, basisU, basisV);
230
+ }
231
+ else {
232
+ initialAxisParam = this.closestParamOnAxisLine(bonePos, worldAxis, ray.origin, ray.dir);
233
+ }
234
+ const initialLocalRot = inst.model.getBoneLocalRotation(this.selectedBone.boneIndex).clone();
235
+ const initTrans = inst.model.getBoneLocalTranslation(this.selectedBone.boneIndex);
236
+ const initialLocalTrans = new Vec3(initTrans.x, initTrans.y, initTrans.z);
237
+ this.gizmoDrag = {
238
+ kind: hit.kind,
239
+ axis: hit.axis,
240
+ bonePos,
241
+ worldAxis,
242
+ basisU,
243
+ basisV,
244
+ initialLocalRot,
245
+ initialLocalTrans,
246
+ parentWorldRot,
247
+ parentWorldRotInv,
248
+ initialAngle,
249
+ initialAxisParam,
250
+ };
251
+ if (this.onGizmoDrag) {
252
+ this.onGizmoDrag({
253
+ modelName: this.selectedBone.modelName,
254
+ boneName: this.selectedBone.boneName,
255
+ boneIndex: this.selectedBone.boneIndex,
256
+ kind: hit.kind === "ring" ? "rotate" : "translate",
257
+ localRotation: initialLocalRot.clone(),
258
+ localTranslation: new Vec3(initialLocalTrans.x, initialLocalTrans.y, initialLocalTrans.z),
259
+ phase: "start",
260
+ });
261
+ }
262
+ };
263
+ this.handleGizmoMouseMove = (e) => {
264
+ const drag = this.gizmoDrag;
265
+ if (!drag || !this.selectedBone || !this.camera)
266
+ return;
267
+ const inst = this.modelInstances.get(this.selectedBone.modelName);
268
+ if (!inst)
269
+ return;
270
+ const rect = this.canvas.getBoundingClientRect();
271
+ const px = e.clientX - rect.left;
272
+ const py = e.clientY - rect.top;
273
+ const ray = this.buildMouseRay(px, py);
274
+ if (!ray)
275
+ return;
276
+ // Compute the target local rotation / translation. The engine never writes
277
+ // to the skeleton itself — we hand the result to the host callback and let
278
+ // it decide (runtime write, tween, clip keyframe edit, …).
279
+ let nextRot = drag.initialLocalRot;
280
+ let nextTrans = drag.initialLocalTrans;
281
+ if (drag.kind === "ring") {
282
+ const p = this.rayPlane(ray.origin, ray.dir, drag.bonePos, drag.worldAxis);
283
+ if (!p)
284
+ return;
285
+ const currentAngle = this.angleInRingPlane(p, drag.bonePos, drag.basisU, drag.basisV);
286
+ const deltaAngle = currentAngle - drag.initialAngle;
287
+ const qWorld = Quat.fromAxisAngle(drag.worldAxis, deltaAngle);
288
+ // L_new = P_inv · Q_world · P · L_initial
289
+ const lNew = drag.parentWorldRotInv
290
+ .multiply(qWorld)
291
+ .multiply(drag.parentWorldRot)
292
+ .multiply(drag.initialLocalRot);
293
+ lNew.normalize();
294
+ nextRot = lNew;
295
+ }
296
+ else {
297
+ const tNow = this.closestParamOnAxisLine(drag.bonePos, drag.worldAxis, ray.origin, ray.dir);
298
+ const deltaParam = tNow - drag.initialAxisParam;
299
+ const worldDelta = drag.worldAxis.scale(deltaParam);
300
+ const localDelta = this.rotateVec3ByQuat(worldDelta, drag.parentWorldRotInv);
301
+ nextTrans = new Vec3(drag.initialLocalTrans.x + localDelta.x, drag.initialLocalTrans.y + localDelta.y, drag.initialLocalTrans.z + localDelta.z);
302
+ }
303
+ this.onGizmoDrag?.({
304
+ modelName: this.selectedBone.modelName,
305
+ boneName: this.selectedBone.boneName,
306
+ boneIndex: this.selectedBone.boneIndex,
307
+ kind: drag.kind === "ring" ? "rotate" : "translate",
308
+ localRotation: nextRot,
309
+ localTranslation: nextTrans,
310
+ });
311
+ };
312
+ this.handleGizmoMouseUp = () => {
313
+ const drag = this.gizmoDrag;
314
+ if (!drag)
315
+ return;
316
+ if (this.onGizmoDrag && this.selectedBone) {
317
+ const inst = this.modelInstances.get(this.selectedBone.modelName);
318
+ if (inst) {
319
+ const finalRot = inst.model.getBoneLocalRotation(this.selectedBone.boneIndex).clone();
320
+ const t = inst.model.getBoneLocalTranslation(this.selectedBone.boneIndex);
321
+ const finalTrans = new Vec3(t.x, t.y, t.z);
322
+ this.onGizmoDrag({
323
+ modelName: this.selectedBone.modelName,
324
+ boneName: this.selectedBone.boneName,
325
+ boneIndex: this.selectedBone.boneIndex,
326
+ kind: drag.kind === "ring" ? "rotate" : "translate",
327
+ localRotation: finalRot,
328
+ localTranslation: finalTrans,
329
+ phase: "end",
330
+ });
331
+ }
332
+ }
333
+ this.gizmoDrag = null;
334
+ this.camera?.setInputLocked(false);
335
+ };
157
336
  this.canvas = canvas;
158
337
  const d = DEFAULT_ENGINE_OPTIONS;
159
338
  this.world = {
@@ -171,6 +350,7 @@ export class Engine {
171
350
  fov: options?.camera?.fov ?? d.camera.fov,
172
351
  };
173
352
  this.onRaycast = options?.onRaycast;
353
+ this.onGizmoDrag = options?.onGizmoDrag;
174
354
  this.physicsOptions = options?.physicsOptions ?? d.physicsOptions;
175
355
  this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom);
176
356
  this.viewTransform = Engine.mergeViewTransformDefaults(options?.view);
@@ -866,6 +1046,79 @@ export class Engine {
866
1046
  stencilWriteMask: 0,
867
1047
  },
868
1048
  });
1049
+ // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
1050
+ // Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
1051
+ const selectionMaskPipelineLayout = this.device.createPipelineLayout({
1052
+ label: "selection mask pipeline layout",
1053
+ bindGroupLayouts: [this.outlinePerFrameBindGroupLayout, this.mainPerInstanceBindGroupLayout],
1054
+ });
1055
+ const selectionMaskShaderModule = this.device.createShaderModule({
1056
+ label: "selection mask shader",
1057
+ code: SELECTION_MASK_SHADER_WGSL,
1058
+ });
1059
+ this.selectionMaskPipeline = this.device.createRenderPipeline({
1060
+ label: "selection mask pipeline",
1061
+ layout: selectionMaskPipelineLayout,
1062
+ vertex: { module: selectionMaskShaderModule, entryPoint: "vs", buffers: outlineVertexBuffers },
1063
+ fragment: {
1064
+ module: selectionMaskShaderModule,
1065
+ entryPoint: "fs",
1066
+ targets: [{ format: "r8unorm" }],
1067
+ },
1068
+ primitive: { cullMode: "none" },
1069
+ // Single-sample, no depth (depth-always via not attaching a depth buffer at all).
1070
+ multisample: { count: 1 },
1071
+ });
1072
+ this.selectionEdgeBindGroupLayout = this.device.createBindGroupLayout({
1073
+ label: "selection edge bind group layout",
1074
+ entries: [
1075
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1076
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
1077
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1078
+ ],
1079
+ });
1080
+ const selectionEdgePipelineLayout = this.device.createPipelineLayout({
1081
+ label: "selection edge pipeline layout",
1082
+ bindGroupLayouts: [this.selectionEdgeBindGroupLayout],
1083
+ });
1084
+ const selectionEdgeShaderModule = this.device.createShaderModule({
1085
+ label: "selection edge shader",
1086
+ code: SELECTION_EDGE_SHADER_WGSL,
1087
+ });
1088
+ this.selectionEdgePipeline = this.device.createRenderPipeline({
1089
+ label: "selection edge pipeline",
1090
+ layout: selectionEdgePipelineLayout,
1091
+ vertex: { module: selectionEdgeShaderModule, entryPoint: "vs" },
1092
+ fragment: {
1093
+ module: selectionEdgeShaderModule,
1094
+ entryPoint: "fs",
1095
+ targets: [
1096
+ {
1097
+ format: this.presentationFormat,
1098
+ blend: {
1099
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
1100
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
1101
+ },
1102
+ },
1103
+ ],
1104
+ },
1105
+ primitive: { topology: "triangle-list" },
1106
+ multisample: { count: 1 },
1107
+ });
1108
+ this.selectionSampler = this.device.createSampler({
1109
+ label: "selection sampler",
1110
+ magFilter: "linear",
1111
+ minFilter: "linear",
1112
+ });
1113
+ this.selectionEdgeUniformBuffer = this.device.createBuffer({
1114
+ label: "selection edge uniforms",
1115
+ size: 16,
1116
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1117
+ });
1118
+ // thickness (pixels), + 3 floats padding
1119
+ this.device.queue.writeBuffer(this.selectionEdgeUniformBuffer, 0, new Float32Array([5.0, 0, 0, 0]));
1120
+ // ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
1121
+ this.setupGizmo();
869
1122
  // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
870
1123
  // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
871
1124
  // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
@@ -1061,6 +1314,13 @@ export class Engine {
1061
1314
  this.canvas.addEventListener("dblclick", this.handleCanvasDoubleClick);
1062
1315
  this.canvas.addEventListener("touchend", this.handleCanvasTouch);
1063
1316
  }
1317
+ // Gizmo drag. mousedown registered in capture phase so we can consume the
1318
+ // event via stopImmediatePropagation before the camera's mousedown handler
1319
+ // runs (both listen on the canvas). move/up on window so drag tracks even
1320
+ // if the cursor leaves the canvas.
1321
+ this.canvas.addEventListener("mousedown", this.handleGizmoMouseDown, { capture: true });
1322
+ window.addEventListener("mousemove", this.handleGizmoMouseMove);
1323
+ window.addEventListener("mouseup", this.handleGizmoMouseUp);
1064
1324
  }
1065
1325
  handleResize() {
1066
1326
  const displayWidth = this.canvas.clientWidth;
@@ -1181,6 +1441,45 @@ export class Engine {
1181
1441
  },
1182
1442
  ],
1183
1443
  };
1444
+ // Selection mask: single-channel canvas-res texture. Depth-always (no depth attachment).
1445
+ this.selectionMaskTexture = this.device.createTexture({
1446
+ label: "selection mask",
1447
+ size: [width, height],
1448
+ format: "r8unorm",
1449
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1450
+ });
1451
+ this.selectionMaskView = this.selectionMaskTexture.createView();
1452
+ this.selectionMaskPassDescriptor = {
1453
+ label: "selection mask pass",
1454
+ colorAttachments: [
1455
+ {
1456
+ view: this.selectionMaskView,
1457
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1458
+ loadOp: "clear",
1459
+ storeOp: "store",
1460
+ },
1461
+ ],
1462
+ };
1463
+ this.selectionEdgeBindGroup = this.device.createBindGroup({
1464
+ label: "selection edge bind group",
1465
+ layout: this.selectionEdgeBindGroupLayout,
1466
+ entries: [
1467
+ { binding: 0, resource: this.selectionMaskView },
1468
+ { binding: 1, resource: this.selectionSampler },
1469
+ { binding: 2, resource: { buffer: this.selectionEdgeUniformBuffer } },
1470
+ ],
1471
+ });
1472
+ // Edge pass draws on top of the composite output — load-store on swapchain.
1473
+ this.selectionEdgePassDescriptor = {
1474
+ label: "selection edge pass",
1475
+ colorAttachments: [
1476
+ {
1477
+ view: undefined,
1478
+ loadOp: "load",
1479
+ storeOp: "store",
1480
+ },
1481
+ ],
1482
+ };
1184
1483
  this.writeBloomUniforms();
1185
1484
  if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
1186
1485
  // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
@@ -1254,6 +1553,162 @@ export class Engine {
1254
1553
  }
1255
1554
  }
1256
1555
  }
1556
+ // Builds the gizmo pipeline, its shared transform bind group, 3 per-color bind
1557
+ // groups (R/G/B), and the packed triangle-list vertex buffer. Each original
1558
+ // line segment is expanded to 6 verts (2 triangles) carrying (pos, dir, side)
1559
+ // so the VS can extrude to a uniform pixel-width ribbon.
1560
+ setupGizmo() {
1561
+ const SEG = Engine.GIZMO_RING_SEGMENTS;
1562
+ const R = Engine.GIZMO_RING_RADIUS;
1563
+ const ringVerts = SEG * 6;
1564
+ this.gizmoDraws = [
1565
+ { first: 0, count: 6, color: 0 }, // X axis
1566
+ { first: 6, count: 6, color: 1 }, // Y axis
1567
+ { first: 12, count: 6, color: 2 }, // Z axis
1568
+ { first: 18, count: ringVerts, color: 0 }, // X ring (YZ plane)
1569
+ { first: 18 + ringVerts, count: ringVerts, color: 1 }, // Y ring (XZ plane)
1570
+ { first: 18 + 2 * ringVerts, count: ringVerts, color: 2 }, // Z ring (XY plane)
1571
+ ];
1572
+ const verts = [];
1573
+ // Per-vertex layout: pos(3), segDir(3), side(1), axisT(1) = 8 floats.
1574
+ // axisT encodes "parameter along the axis" for axis verts (0 at center, 1
1575
+ // at tip). Ring verts use -1 as a "not an axis" flag the FS uses to skip
1576
+ // the dash + fade treatment.
1577
+ const pushSeg = (p0, p1, t0, t1) => {
1578
+ const d = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
1579
+ const dn = [-d[0], -d[1], -d[2]];
1580
+ verts.push(p0[0], p0[1], p0[2], d[0], d[1], d[2], -1, t0);
1581
+ verts.push(p0[0], p0[1], p0[2], d[0], d[1], d[2], 1, t0);
1582
+ verts.push(p1[0], p1[1], p1[2], dn[0], dn[1], dn[2], -1, t1);
1583
+ verts.push(p0[0], p0[1], p0[2], d[0], d[1], d[2], 1, t0);
1584
+ verts.push(p1[0], p1[1], p1[2], dn[0], dn[1], dn[2], 1, t1);
1585
+ verts.push(p1[0], p1[1], p1[2], dn[0], dn[1], dn[2], -1, t1);
1586
+ };
1587
+ // Axes (open). t = 0 at center → 1 at tip. FS dashes + dims the inside-ring part.
1588
+ const L = Engine.GIZMO_AXIS_LENGTH;
1589
+ pushSeg([0, 0, 0], [L, 0, 0], 0, 1);
1590
+ pushSeg([0, 0, 0], [0, L, 0], 0, 1);
1591
+ pushSeg([0, 0, 0], [0, 0, L], 0, 1);
1592
+ // Rings (closed). t = -1 signals "not an axis".
1593
+ for (let plane = 0; plane < 3; plane++) {
1594
+ for (let i = 0; i < SEG; i++) {
1595
+ const t0 = (i / SEG) * Math.PI * 2;
1596
+ const t1 = ((i + 1) / SEG) * Math.PI * 2;
1597
+ const c0 = Math.cos(t0) * R, s0 = Math.sin(t0) * R;
1598
+ const c1 = Math.cos(t1) * R, s1 = Math.sin(t1) * R;
1599
+ if (plane === 0)
1600
+ pushSeg([0, c0, s0], [0, c1, s1], -1, -1);
1601
+ else if (plane === 1)
1602
+ pushSeg([s0, 0, c0], [s1, 0, c1], -1, -1);
1603
+ else
1604
+ pushSeg([c0, s0, 0], [c1, s1, 0], -1, -1);
1605
+ }
1606
+ }
1607
+ const geom = new Float32Array(verts);
1608
+ this.gizmoVertexBuffer = this.device.createBuffer({
1609
+ label: "gizmo vertex buffer",
1610
+ size: geom.byteLength,
1611
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1612
+ });
1613
+ this.device.queue.writeBuffer(this.gizmoVertexBuffer, 0, geom);
1614
+ // Shared transform+viewport+thickness uniform. Rewritten per frame.
1615
+ this.gizmoTransformBuffer = this.device.createBuffer({
1616
+ label: "gizmo transform",
1617
+ size: 80, // mat4 (64) + vec2 viewport (8) + thickness f32 (4) + pad (4)
1618
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1619
+ });
1620
+ const bg0Layout = this.device.createBindGroupLayout({
1621
+ label: "gizmo group 0 layout (camera + transform)",
1622
+ entries: [
1623
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
1624
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
1625
+ ],
1626
+ });
1627
+ const bg1Layout = this.device.createBindGroupLayout({
1628
+ label: "gizmo group 1 layout (color)",
1629
+ entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
1630
+ });
1631
+ const pipelineLayout = this.device.createPipelineLayout({
1632
+ label: "gizmo pipeline layout",
1633
+ bindGroupLayouts: [bg0Layout, bg1Layout],
1634
+ });
1635
+ const shader = this.device.createShaderModule({ label: "gizmo shader", code: GIZMO_SHADER_WGSL });
1636
+ this.gizmoPipeline = this.device.createRenderPipeline({
1637
+ label: "gizmo pipeline",
1638
+ layout: pipelineLayout,
1639
+ vertex: {
1640
+ module: shader,
1641
+ entryPoint: "vs",
1642
+ buffers: [
1643
+ {
1644
+ arrayStride: 8 * 4, // pos(3) + segDir(3) + side(1) + axisT(1) = 8 floats
1645
+ attributes: [
1646
+ { shaderLocation: 0, offset: 0, format: "float32x3" }, // position
1647
+ { shaderLocation: 1, offset: 3 * 4, format: "float32x3" }, // segDir
1648
+ { shaderLocation: 2, offset: 6 * 4, format: "float32" }, // side
1649
+ { shaderLocation: 3, offset: 7 * 4, format: "float32" }, // axisT
1650
+ ],
1651
+ },
1652
+ ],
1653
+ },
1654
+ fragment: {
1655
+ module: shader,
1656
+ entryPoint: "fs",
1657
+ targets: [
1658
+ {
1659
+ format: this.presentationFormat,
1660
+ blend: {
1661
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
1662
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
1663
+ },
1664
+ },
1665
+ ],
1666
+ },
1667
+ primitive: { topology: "triangle-list", cullMode: "none" },
1668
+ multisample: { count: 1 },
1669
+ });
1670
+ this.gizmoBindGroup0 = this.device.createBindGroup({
1671
+ label: "gizmo bind group 0",
1672
+ layout: bg0Layout,
1673
+ entries: [
1674
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
1675
+ { binding: 1, resource: { buffer: this.gizmoTransformBuffer } },
1676
+ ],
1677
+ });
1678
+ // Vivid game-UI palette. FS applies an edge-to-center alpha falloff so these
1679
+ // full-saturation colors stay readable without feeling flat. Pipeline writes
1680
+ // straight to the LDR swapchain (no tonemap), so values > 1 clamp.
1681
+ const colors = [
1682
+ new Float32Array([1.0, 0.24, 0.38, 1.0]), // X: warm red, slight pink
1683
+ new Float32Array([0.35, 0.95, 0.52, 1.0]), // Y: emerald
1684
+ new Float32Array([0.33, 0.62, 1.0, 1.0]), // Z: azure
1685
+ ];
1686
+ this.gizmoColorBindGroups = [];
1687
+ for (let i = 0; i < 3; i++) {
1688
+ const buf = this.device.createBuffer({
1689
+ label: `gizmo color ${i}`,
1690
+ size: 16,
1691
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1692
+ });
1693
+ this.device.queue.writeBuffer(buf, 0, colors[i]);
1694
+ this.gizmoColorBindGroups.push(this.device.createBindGroup({
1695
+ label: `gizmo color bg ${i}`,
1696
+ layout: bg1Layout,
1697
+ entries: [{ binding: 0, resource: { buffer: buf } }],
1698
+ }));
1699
+ }
1700
+ // Gizmo pass — depth-less, loads the swapchain so it composites on top.
1701
+ this.gizmoPassDescriptor = {
1702
+ label: "gizmo pass",
1703
+ colorAttachments: [
1704
+ {
1705
+ view: undefined,
1706
+ loadOp: "load",
1707
+ storeOp: "store",
1708
+ },
1709
+ ],
1710
+ };
1711
+ }
1257
1712
  // Step 4: Create camera and uniform buffer
1258
1713
  setupCamera() {
1259
1714
  this.cameraUniformBuffer = this.device.createBuffer({
@@ -1445,6 +1900,10 @@ export class Engine {
1445
1900
  this.canvas.removeEventListener("dblclick", this.handleCanvasDoubleClick);
1446
1901
  this.canvas.removeEventListener("touchend", this.handleCanvasTouch);
1447
1902
  }
1903
+ // Remove gizmo drag listeners
1904
+ this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true });
1905
+ window.removeEventListener("mousemove", this.handleGizmoMouseMove);
1906
+ window.removeEventListener("mouseup", this.handleGizmoMouseUp);
1448
1907
  if (this.resizeObserver) {
1449
1908
  this.resizeObserver.disconnect();
1450
1909
  this.resizeObserver = null;
@@ -1523,6 +1982,22 @@ export class Engine {
1523
1982
  }
1524
1983
  }
1525
1984
  }
1985
+ setSelectedMaterial(modelName, materialName) {
1986
+ this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null;
1987
+ }
1988
+ setSelectedBone(modelName, boneName) {
1989
+ if (!modelName || !boneName) {
1990
+ this.selectedBone = null;
1991
+ return;
1992
+ }
1993
+ const inst = this.modelInstances.get(modelName);
1994
+ if (!inst) {
1995
+ this.selectedBone = null;
1996
+ return;
1997
+ }
1998
+ const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName);
1999
+ this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null;
2000
+ }
1526
2001
  setMaterialPresets(modelName, presets) {
1527
2002
  const inst = this.modelInstances.get(modelName);
1528
2003
  if (!inst)
@@ -1797,7 +2272,7 @@ export class Engine {
1797
2272
  const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
1798
2273
  const up = Math.abs(dir.y) > 0.99 ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0);
1799
2274
  const view = Mat4.lookAt(eye, target, up);
1800
- const proj = Mat4.orthographicLh(-72, 72, -72, 72, 1, 140);
2275
+ const proj = Mat4.orthographicLh(-32, 32, -32, 32, 1, 140);
1801
2276
  const vp = proj.multiply(view);
1802
2277
  this.shadowLightVPMatrix.set(vp.values);
1803
2278
  this.device.queue.writeBuffer(this.shadowLightVPBuffer, 0, this.shadowLightVPMatrix);
@@ -2033,12 +2508,239 @@ export class Engine {
2033
2508
  }
2034
2509
  performRaycast(screenX, screenY) {
2035
2510
  if (!this.onRaycast || this.modelInstances.size === 0) {
2036
- this.onRaycast?.("", null, screenX, screenY);
2511
+ this.onRaycast?.("", null, null, screenX, screenY);
2037
2512
  return;
2038
2513
  }
2039
2514
  const dpr = window.devicePixelRatio || 1;
2040
2515
  this.pendingPick = { x: Math.floor(screenX * dpr), y: Math.floor(screenY * dpr) };
2041
2516
  }
2517
+ renderSelectionPasses(encoder, swapchainView) {
2518
+ if (!this.selectedMaterial || !this.selectionEdgeBindGroup)
2519
+ return;
2520
+ const inst = this.modelInstances.get(this.selectedMaterial.modelName);
2521
+ if (!inst)
2522
+ return;
2523
+ const target = this.selectedMaterial.materialName;
2524
+ const draw = inst.drawCalls.find((d) => (d.type === "opaque" || d.type === "transparent") && d.materialName === target);
2525
+ if (!draw || !this.shouldRenderDrawCall(inst, draw))
2526
+ return;
2527
+ // Mask pass: fill the selected material's projected footprint with 1.0. Depth-always
2528
+ // (no depth attachment) so the outline traces complete boundaries even when the
2529
+ // material is partially occluded — matches Blender selection-through behaviour.
2530
+ const mpass = encoder.beginRenderPass(this.selectionMaskPassDescriptor);
2531
+ mpass.setPipeline(this.selectionMaskPipeline);
2532
+ mpass.setBindGroup(0, this.outlinePerFrameBindGroup);
2533
+ mpass.setBindGroup(1, inst.mainPerInstanceBindGroup);
2534
+ mpass.setVertexBuffer(0, inst.vertexBuffer);
2535
+ mpass.setVertexBuffer(1, inst.jointsBuffer);
2536
+ mpass.setVertexBuffer(2, inst.weightsBuffer);
2537
+ mpass.setIndexBuffer(inst.indexBuffer, "uint32");
2538
+ mpass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2539
+ mpass.end();
2540
+ // Edge pass: screen-space edge detect on the mask, alpha-blended over swapchain.
2541
+ const edgeAttachment = this.selectionEdgePassDescriptor.colorAttachments[0];
2542
+ edgeAttachment.view = swapchainView;
2543
+ const epass = encoder.beginRenderPass(this.selectionEdgePassDescriptor);
2544
+ epass.setPipeline(this.selectionEdgePipeline);
2545
+ epass.setBindGroup(0, this.selectionEdgeBindGroup);
2546
+ epass.draw(3);
2547
+ epass.end();
2548
+ }
2549
+ // Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
2550
+ // then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
2551
+ // aligns rings with the bone's current world orientation, so clicking a ring
2552
+ // rotates around that bone's natural axis.
2553
+ renderGizmoPass(encoder, swapchainView) {
2554
+ if (!this.selectedBone || !this.camera)
2555
+ return;
2556
+ const inst = this.modelInstances.get(this.selectedBone.modelName);
2557
+ if (!inst)
2558
+ return;
2559
+ const worldMats = inst.model.getWorldMatrices();
2560
+ if (this.selectedBone.boneIndex >= worldMats.length)
2561
+ return;
2562
+ const boneMat = worldMats[this.selectedBone.boneIndex];
2563
+ const bonePos = boneMat.getPosition();
2564
+ const q = boneMat.toQuat().normalize(); // world rotation
2565
+ const s = Engine.GIZMO_WORLD_SIZE;
2566
+ // Column-major mat4: rotation columns × scale, then translation in col 3.
2567
+ const xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
2568
+ const xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
2569
+ const wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
2570
+ const u = new Float32Array(20);
2571
+ u[0] = s * (1 - 2 * (yy + zz));
2572
+ u[1] = s * 2 * (xy + wz);
2573
+ u[2] = s * 2 * (xz - wy);
2574
+ u[3] = 0;
2575
+ u[4] = s * 2 * (xy - wz);
2576
+ u[5] = s * (1 - 2 * (xx + zz));
2577
+ u[6] = s * 2 * (yz + wx);
2578
+ u[7] = 0;
2579
+ u[8] = s * 2 * (xz + wy);
2580
+ u[9] = s * 2 * (yz - wx);
2581
+ u[10] = s * (1 - 2 * (xx + yy));
2582
+ u[11] = 0;
2583
+ u[12] = bonePos.x;
2584
+ u[13] = bonePos.y;
2585
+ u[14] = bonePos.z;
2586
+ u[15] = 1;
2587
+ u[16] = this.canvas.width;
2588
+ u[17] = this.canvas.height;
2589
+ u[18] = Engine.GIZMO_THICKNESS_PX;
2590
+ u[19] = 0;
2591
+ this.device.queue.writeBuffer(this.gizmoTransformBuffer, 0, u);
2592
+ const att = this.gizmoPassDescriptor.colorAttachments[0];
2593
+ att.view = swapchainView;
2594
+ const pass = encoder.beginRenderPass(this.gizmoPassDescriptor);
2595
+ pass.setPipeline(this.gizmoPipeline);
2596
+ pass.setBindGroup(0, this.gizmoBindGroup0);
2597
+ pass.setVertexBuffer(0, this.gizmoVertexBuffer);
2598
+ for (const d of this.gizmoDraws) {
2599
+ pass.setBindGroup(1, this.gizmoColorBindGroups[d.color]);
2600
+ pass.draw(d.count, 1, d.first, 0);
2601
+ }
2602
+ pass.end();
2603
+ }
2604
+ // ──────────────────────────────────────────────────────────────────
2605
+ // Gizmo drag — hit test + input handlers + rotation/translation math
2606
+ // ──────────────────────────────────────────────────────────────────
2607
+ rotateVec3ByQuat(v, q) {
2608
+ // Standard rodrigues-via-quat formulation. Cheaper than q * v * q_conj.
2609
+ const tx = 2 * (q.y * v.z - q.z * v.y);
2610
+ const ty = 2 * (q.z * v.x - q.x * v.z);
2611
+ const tz = 2 * (q.x * v.y - q.y * v.x);
2612
+ return new Vec3(v.x + q.w * tx + (q.y * tz - q.z * ty), v.y + q.w * ty + (q.z * tx - q.x * tz), v.z + q.w * tz + (q.x * ty - q.y * tx));
2613
+ }
2614
+ unproject(invVP, ndcX, ndcY, ndcZ) {
2615
+ const m = invVP.values;
2616
+ const x = m[0] * ndcX + m[4] * ndcY + m[8] * ndcZ + m[12];
2617
+ const y = m[1] * ndcX + m[5] * ndcY + m[9] * ndcZ + m[13];
2618
+ const z = m[2] * ndcX + m[6] * ndcY + m[10] * ndcZ + m[14];
2619
+ const w = m[3] * ndcX + m[7] * ndcY + m[11] * ndcZ + m[15];
2620
+ if (Math.abs(w) < 1e-9)
2621
+ return null;
2622
+ return new Vec3(x / w, y / w, z / w);
2623
+ }
2624
+ // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
2625
+ buildMouseRay(px, py) {
2626
+ if (!this.camera)
2627
+ return null;
2628
+ const width = this.canvas.clientWidth;
2629
+ const height = this.canvas.clientHeight;
2630
+ if (width <= 0 || height <= 0)
2631
+ return null;
2632
+ const ndcX = (px / width) * 2 - 1;
2633
+ const ndcY = -((py / height) * 2 - 1);
2634
+ const view = this.camera.getViewMatrix();
2635
+ const proj = this.camera.getProjectionMatrix();
2636
+ const invVP = proj.multiply(view).inverse();
2637
+ const near = this.unproject(invVP, ndcX, ndcY, 0);
2638
+ const far = this.unproject(invVP, ndcX, ndcY, 1);
2639
+ if (!near || !far)
2640
+ return null;
2641
+ return { origin: near, dir: far.subtract(near).normalize() };
2642
+ }
2643
+ // Finds the closest gizmo handle to the mouse ray, within `worldThreshold`.
2644
+ // `worldAxes[i]` is the i-th local axis rotated into world by bone world rotation.
2645
+ hitTestGizmo(ray, bonePos, gizmoSize, worldThreshold, worldAxes) {
2646
+ let bestKind = null;
2647
+ let bestAxis = 0;
2648
+ let bestDist = worldThreshold;
2649
+ // Axes only hit on their OUTER portion (past the ring radius). Inside the
2650
+ // ring the axis line passes through the plane of the perpendicular ring
2651
+ // (e.g. X-axis passes through the interior of the Y ring), so including the
2652
+ // full axis produced ring-vs-axis ties and constant misclicks. Axis extends
2653
+ // to AXIS_LENGTH, so the hit zone is roughly half the visible axis length —
2654
+ // easy to grab while leaving the ring's interior unambiguous.
2655
+ const axisHitStart = gizmoSize * (Engine.GIZMO_RING_RADIUS + 0.05);
2656
+ const axisHitEnd = gizmoSize * Engine.GIZMO_AXIS_LENGTH;
2657
+ for (let i = 0; i < 3; i++) {
2658
+ const segA = bonePos.add(worldAxes[i].scale(axisHitStart));
2659
+ const segB = bonePos.add(worldAxes[i].scale(axisHitEnd));
2660
+ const d = this.distSegmentRay(segA, segB, ray.origin, ray.dir);
2661
+ if (d < bestDist) {
2662
+ bestDist = d;
2663
+ bestKind = "axis";
2664
+ bestAxis = i;
2665
+ }
2666
+ }
2667
+ const ringR = gizmoSize * Engine.GIZMO_RING_RADIUS;
2668
+ for (let i = 0; i < 3; i++) {
2669
+ const n = worldAxes[i];
2670
+ const denom = ray.dir.dot(n);
2671
+ if (Math.abs(denom) < 1e-6)
2672
+ continue;
2673
+ const t = bonePos.subtract(ray.origin).dot(n) / denom;
2674
+ if (t < 0)
2675
+ continue;
2676
+ const hit = ray.origin.add(ray.dir.scale(t));
2677
+ const rel = hit.subtract(bonePos);
2678
+ const radial = rel.subtract(n.scale(rel.dot(n)));
2679
+ const radius = radial.length();
2680
+ const d = Math.abs(radius - ringR);
2681
+ if (d < bestDist) {
2682
+ bestDist = d;
2683
+ bestKind = "ring";
2684
+ bestAxis = i;
2685
+ }
2686
+ }
2687
+ return bestKind ? { kind: bestKind, axis: bestAxis } : null;
2688
+ }
2689
+ // Shortest distance between segment [A, B] and ray (origin, dir-unit).
2690
+ distSegmentRay(A, B, rayO, rayD) {
2691
+ const u = B.subtract(A); // segment direction (not normalized)
2692
+ const w = A.subtract(rayO);
2693
+ const a = u.dot(u);
2694
+ const b = u.dot(rayD);
2695
+ const d = u.dot(w);
2696
+ const e = rayD.dot(w);
2697
+ const denom = a - b * b; // since |rayD|=1
2698
+ let sc, tc;
2699
+ if (Math.abs(denom) < 1e-9) {
2700
+ sc = 0;
2701
+ tc = e;
2702
+ }
2703
+ else {
2704
+ sc = (b * e - d) / denom;
2705
+ tc = (a * e - b * d) / denom;
2706
+ }
2707
+ sc = Math.max(0, Math.min(1, sc));
2708
+ if (tc < 0)
2709
+ tc = 0;
2710
+ const ps = new Vec3(A.x + sc * u.x, A.y + sc * u.y, A.z + sc * u.z);
2711
+ const pr = new Vec3(rayO.x + tc * rayD.x, rayO.y + tc * rayD.y, rayO.z + tc * rayD.z);
2712
+ return ps.subtract(pr).length();
2713
+ }
2714
+ // Line-line closest point: returns the parameter t on line (A, dir) where the
2715
+ // closest approach to the ray is. Used by axis-translation drag so frame N
2716
+ // reads a signed delta vs the mouse-down snapshot.
2717
+ closestParamOnAxisLine(A, dir, rayO, rayD) {
2718
+ const w = A.subtract(rayO);
2719
+ const b = dir.dot(rayD);
2720
+ const d = dir.dot(w);
2721
+ const e = rayD.dot(w);
2722
+ const denom = 1 - b * b; // |dir|=|rayD|=1
2723
+ if (Math.abs(denom) < 1e-9)
2724
+ return -d; // lines parallel
2725
+ return (b * e - d) / denom;
2726
+ }
2727
+ // Ray-vs-plane (point bonePos, normal n). Returns the hit point or null.
2728
+ rayPlane(rayO, rayD, bonePos, n) {
2729
+ const denom = rayD.dot(n);
2730
+ if (Math.abs(denom) < 1e-6)
2731
+ return null;
2732
+ const t = bonePos.subtract(rayO).dot(n) / denom;
2733
+ if (t < 0)
2734
+ return null;
2735
+ return rayO.add(rayD.scale(t));
2736
+ }
2737
+ // 2D angle of `hit` around `bonePos` in a plane spanned by (u, v). Basis vectors
2738
+ // are snapshotted at drag start so the angle frame is stable even if the bone
2739
+ // (and gizmo visual) rotates during the drag.
2740
+ angleInRingPlane(hit, bonePos, u, v) {
2741
+ const rel = hit.subtract(bonePos);
2742
+ return Math.atan2(rel.dot(v), rel.dot(u));
2743
+ }
2042
2744
  renderPickPass(encoder) {
2043
2745
  if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
2044
2746
  return;
@@ -2084,9 +2786,10 @@ export class Engine {
2084
2786
  const data = new Uint8Array(this.pickReadbackBuffer.getMappedRange());
2085
2787
  const modelId = data[0];
2086
2788
  const materialId = data[1];
2789
+ const boneId = data[2];
2087
2790
  this.pickReadbackBuffer.unmap();
2088
2791
  if (modelId === 0) {
2089
- this.onRaycast("", null, screenX, screenY);
2792
+ this.onRaycast("", null, null, screenX, screenY);
2090
2793
  return;
2091
2794
  }
2092
2795
  // Find model by 1-based index
@@ -2099,11 +2802,12 @@ export class Engine {
2099
2802
  }
2100
2803
  idx++;
2101
2804
  }
2102
- // Find material by 1-based index (skipping zero-vertex materials)
2103
2805
  let hitMaterial = null;
2806
+ let hitBone = null;
2104
2807
  if (hitModel) {
2105
2808
  const inst = this.modelInstances.get(hitModel);
2106
2809
  if (inst) {
2810
+ // Find material by 1-based index (skipping zero-vertex materials)
2107
2811
  const materials = inst.model.getMaterials();
2108
2812
  let matIdx = 0;
2109
2813
  for (const mat of materials) {
@@ -2115,9 +2819,13 @@ export class Engine {
2115
2819
  break;
2116
2820
  }
2117
2821
  }
2822
+ // Bone index is 0-based (matches joints0 attribute values fed to pick shader).
2823
+ const bones = inst.model.getSkeleton().bones;
2824
+ if (boneId < bones.length)
2825
+ hitBone = bones[boneId].name;
2118
2826
  }
2119
2827
  }
2120
- this.onRaycast(hitModel, hitMaterial, screenX, screenY);
2828
+ this.onRaycast(hitModel, hitMaterial, hitBone, screenX, screenY);
2121
2829
  }
2122
2830
  render() {
2123
2831
  if (!this.multisampleTexture || !this.camera || !this.device)
@@ -2201,14 +2909,19 @@ export class Engine {
2201
2909
  }
2202
2910
  }
2203
2911
  // Composite: HDR + bloom → Filmic tonemap → swapchain.
2912
+ const swapchainView = this.context.getCurrentTexture().createView();
2204
2913
  const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
2205
- compositeAttachment.view = this.context.getCurrentTexture().createView();
2914
+ compositeAttachment.view = swapchainView;
2206
2915
  const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
2207
2916
  const compositePipeline = this.viewTransform.gamma === 1.0 ? this.compositePipelineIdentity : this.compositePipelineGamma;
2208
2917
  cpass.setPipeline(compositePipeline);
2209
2918
  cpass.setBindGroup(0, this.compositeBindGroup);
2210
2919
  cpass.draw(3);
2211
2920
  cpass.end();
2921
+ if (this.selectedMaterial && hasModels)
2922
+ this.renderSelectionPasses(encoder, swapchainView);
2923
+ if (this.selectedBone && hasModels)
2924
+ this.renderGizmoPass(encoder, swapchainView);
2212
2925
  const pick = this.pendingPick;
2213
2926
  if (pick && hasModels)
2214
2927
  this.renderPickPass(encoder);
@@ -2379,6 +3092,14 @@ export class Engine {
2379
3092
  }
2380
3093
  }
2381
3094
  Engine.instance = null;
3095
+ Engine.GIZMO_RING_SEGMENTS = 96;
3096
+ Engine.GIZMO_RING_RADIUS = 0.8;
3097
+ // Axis visible length (relative to gizmo size). Extends past ring radius so
3098
+ // the "arrow stub" sticking out of the ring is a comfortable click target.
3099
+ Engine.GIZMO_AXIS_LENGTH = 1.25;
3100
+ Engine.GIZMO_WORLD_SIZE = 1.5;
3101
+ Engine.GIZMO_THICKNESS_PX = 15.0;
3102
+ Engine.GIZMO_PICK_THRESHOLD_PX = 17.0;
2382
3103
  Engine.MULTISAMPLE_COUNT = 4;
2383
3104
  /** Stencil value stamped by eye draws so hair can stencil-test against it and
2384
3105
  * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
@@ -2392,5 +3113,5 @@ Engine.STENCIL_EYE_VALUE = 1;
2392
3113
  * cleared / edge-faded regions like before).
2393
3114
  * rg8unorm at 4× MSAA is 8 bytes/texel — still fits Apple TBDR tile memory comfortably. */
2394
3115
  Engine.BLOOM_MASK_FORMAT = "rg8unorm";
2395
- Engine.BLOOM_MAX_LEVELS = 7;
2396
- Engine.SHADOW_MAP_SIZE = 4096;
3116
+ Engine.BLOOM_MAX_LEVELS = 5;
3117
+ Engine.SHADOW_MAP_SIZE = 2048;