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