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