reze-engine 0.13.4 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -13
- package/dist/camera.d.ts +2 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +17 -0
- package/dist/engine.d.ts +71 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +729 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/model.d.ts +6 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +36 -1
- package/dist/shaders/body.d.ts +1 -1
- package/dist/shaders/body.d.ts.map +1 -1
- package/dist/shaders/body.js +7 -28
- package/dist/shaders/cloth_rough.d.ts +1 -1
- package/dist/shaders/cloth_rough.d.ts.map +1 -1
- package/dist/shaders/cloth_rough.js +4 -16
- package/dist/shaders/cloth_smooth.d.ts +1 -1
- package/dist/shaders/cloth_smooth.d.ts.map +1 -1
- package/dist/shaders/cloth_smooth.js +5 -17
- package/dist/shaders/default.d.ts +1 -1
- package/dist/shaders/default.d.ts.map +1 -1
- package/dist/shaders/eye.d.ts +1 -1
- package/dist/shaders/eye.d.ts.map +1 -1
- package/dist/shaders/face.d.ts +1 -1
- package/dist/shaders/face.d.ts.map +1 -1
- package/dist/shaders/face.js +21 -57
- package/dist/shaders/hair.d.ts +1 -1
- package/dist/shaders/hair.d.ts.map +1 -1
- package/dist/shaders/hair.js +7 -27
- package/dist/shaders/metal.d.ts +1 -1
- package/dist/shaders/metal.d.ts.map +1 -1
- package/dist/shaders/metal.js +4 -17
- package/dist/shaders/nodes.d.ts +1 -1
- package/dist/shaders/nodes.d.ts.map +1 -1
- package/dist/shaders/nodes.js +0 -9
- package/dist/shaders/passes/gizmo.d.ts +2 -0
- package/dist/shaders/passes/gizmo.d.ts.map +1 -0
- package/dist/shaders/passes/gizmo.js +75 -0
- package/dist/shaders/passes/pick.d.ts +1 -1
- package/dist/shaders/passes/pick.d.ts.map +1 -1
- package/dist/shaders/passes/pick.js +29 -5
- package/dist/shaders/passes/selection.d.ts +3 -0
- package/dist/shaders/passes/selection.d.ts.map +1 -0
- package/dist/shaders/passes/selection.js +65 -0
- package/dist/shaders/stockings.d.ts +1 -1
- package/dist/shaders/stockings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/camera.ts +14 -0
- package/src/engine.ts +830 -9
- package/src/index.ts +3 -0
- package/src/model.ts +38 -1
- package/src/shaders/passes/gizmo.ts +76 -0
- package/src/shaders/passes/pick.ts +29 -5
- package/src/shaders/passes/selection.ts +67 -0
- package/dist/bezier-interpolate.d.ts +0 -15
- package/dist/bezier-interpolate.d.ts.map +0 -1
- package/dist/bezier-interpolate.js +0 -40
- package/dist/engine_ts.d.ts +0 -143
- package/dist/engine_ts.d.ts.map +0 -1
- package/dist/engine_ts.js +0 -1575
- package/dist/ik.d.ts +0 -32
- package/dist/ik.d.ts.map +0 -1
- package/dist/ik.js +0 -337
- package/dist/player.d.ts +0 -64
- package/dist/player.d.ts.map +0 -1
- package/dist/player.js +0 -220
- package/dist/pool-scene.d.ts +0 -52
- package/dist/pool-scene.d.ts.map +0 -1
- package/dist/pool-scene.js +0 -1122
- package/dist/pool.d.ts +0 -38
- package/dist/pool.d.ts.map +0 -1
- package/dist/pool.js +0 -422
- package/dist/rzm-converter.d.ts +0 -12
- package/dist/rzm-converter.d.ts.map +0 -1
- package/dist/rzm-converter.js +0 -40
- package/dist/rzm-loader.d.ts +0 -24
- package/dist/rzm-loader.d.ts.map +0 -1
- package/dist/rzm-loader.js +0 -488
- package/dist/rzm-writer.d.ts +0 -27
- package/dist/rzm-writer.d.ts.map +0 -1
- package/dist/rzm-writer.js +0 -701
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(-
|
|
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 =
|
|
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 =
|
|
3116
|
+
Engine.BLOOM_MAX_LEVELS = 5;
|
|
2396
3117
|
Engine.SHADOW_MAP_SIZE = 2048;
|