reze-engine 0.54.16 → 0.55.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/dist/engine.d.ts +210 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1063 -11
- package/dist/graph/presets/pool_floor.d.ts +3 -0
- package/dist/graph/presets/pool_floor.d.ts.map +1 -0
- package/dist/graph/presets/pool_floor.js +42 -0
- package/dist/graph/presets/water.d.ts +3 -0
- package/dist/graph/presets/water.d.ts.map +1 -0
- package/dist/graph/presets/water.js +31 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +10 -0
- package/dist/overlay.d.ts +200 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +806 -0
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -0
- package/dist/pmx-document.d.ts +218 -0
- package/dist/pmx-document.d.ts.map +1 -0
- package/dist/pmx-document.js +685 -0
- package/dist/shaders/passes/field-blit.d.ts +26 -0
- package/dist/shaders/passes/field-blit.d.ts.map +1 -0
- package/dist/shaders/passes/field-blit.js +65 -0
- package/dist/shaders/passes/ground-noise.d.ts +7 -0
- package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
- package/dist/shaders/passes/ground-noise.js +88 -0
- package/dist/shaders/passes/overlay.d.ts +3 -0
- package/dist/shaders/passes/overlay.d.ts.map +1 -0
- package/dist/shaders/passes/overlay.js +164 -0
- package/dist/shaders/passes/sim.d.ts +34 -0
- package/dist/shaders/passes/sim.d.ts.map +1 -0
- package/dist/shaders/passes/sim.js +169 -0
- package/dist/shaders/passes/wireframe.d.ts +2 -0
- package/dist/shaders/passes/wireframe.d.ts.map +1 -0
- package/dist/shaders/passes/wireframe.js +107 -0
- package/dist/shaders/score-api.d.ts +10 -0
- package/dist/shaders/score-api.d.ts.map +1 -0
- package/dist/shaders/score-api.js +114 -0
- package/package.json +1 -1
- package/src/engine.ts +1147 -13
- package/src/index.ts +50 -0
- package/src/model.ts +9 -0
- package/src/overlay.ts +1000 -0
- package/src/pmx-document.ts +882 -0
- package/src/shaders/passes/overlay.ts +166 -0
- package/src/shaders/passes/wireframe.ts +108 -0
package/src/engine.ts
CHANGED
|
@@ -56,6 +56,28 @@ import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/
|
|
|
56
56
|
import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
|
|
57
57
|
import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
|
|
58
58
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
|
|
59
|
+
import { OVERLAY_SHADER_WGSL, OVERLAY_COMPOSITE_SHADER_WGSL } from "./shaders/passes/overlay"
|
|
60
|
+
import { WIREFRAME_SHADER_WGSL } from "./shaders/passes/wireframe"
|
|
61
|
+
import {
|
|
62
|
+
boneOverlay,
|
|
63
|
+
boneMarkerPositions,
|
|
64
|
+
buildOverlayShapes,
|
|
65
|
+
jointOverlay,
|
|
66
|
+
rigidbodyOverlay,
|
|
67
|
+
writeOverlayInstance,
|
|
68
|
+
OVERLAY_INSTANCE_FLOATS,
|
|
69
|
+
OVERLAY_VERTEX_FLOATS,
|
|
70
|
+
OVERLAY_SHAPES,
|
|
71
|
+
OVERLAY_SOLID_SHAPES,
|
|
72
|
+
DEFAULT_VERTEX_COLOR,
|
|
73
|
+
OVERLAY_STYLE,
|
|
74
|
+
type BoneOverlayOptions,
|
|
75
|
+
type JointOverlayOptions,
|
|
76
|
+
type OverlayGeometry,
|
|
77
|
+
type OverlayPrimitive,
|
|
78
|
+
type OverlayShape,
|
|
79
|
+
type RigidbodyOverlayOptions,
|
|
80
|
+
} from "./overlay"
|
|
59
81
|
import {
|
|
60
82
|
BLOOM_BLIT_SHADER_WGSL,
|
|
61
83
|
BLOOM_DOWNSAMPLE_SHADER_WGSL,
|
|
@@ -772,6 +794,15 @@ interface ModelInstance {
|
|
|
772
794
|
materialMorphTargets: MaterialMorphTarget[] | null
|
|
773
795
|
/** The same targets by PMX material index, so a named offset is one lookup. */
|
|
774
796
|
materialMorphByIndex: Map<number, MaterialMorphTarget> | null
|
|
797
|
+
/** The mesh's unique edges as a line-list index buffer, built on first use.
|
|
798
|
+
* Deduplicated: an interior edge belongs to two triangles, so drawing the
|
|
799
|
+
* triangle list's edges directly would draw most of the mesh twice.
|
|
800
|
+
*
|
|
801
|
+
* Keyed by the material the edges were cut from, "" for the whole mesh. A
|
|
802
|
+
* material is a consecutive index run, so scoping is a slice of the same
|
|
803
|
+
* build — and both stay cached, because narrowing to one material and
|
|
804
|
+
* widening back out is the loop somebody auditing a model is in. */
|
|
805
|
+
wireEdges: Map<string, { buffer: GPUBuffer; count: number; bindGroup: GPUBindGroup } | null>
|
|
775
806
|
physics: RezePhysics | null
|
|
776
807
|
vertexBufferNeedsUpdate: boolean
|
|
777
808
|
gpuMorph: GpuMorph | null
|
|
@@ -1493,8 +1524,73 @@ export class Engine {
|
|
|
1493
1524
|
private selectionEdgePassDescriptor!: GPURenderPassDescriptor
|
|
1494
1525
|
private selectionSampler!: GPUSampler
|
|
1495
1526
|
|
|
1527
|
+
// ─── Editor overlays (bones, rigidbodies, joints) ──────────────────
|
|
1528
|
+
// One instanced pass over unit wireframes. Static layers are whatever the
|
|
1529
|
+
// host handed setOverlay; the three live ones are rebuilt from the model
|
|
1530
|
+
// every frame, because a posed skeleton has moved by the next one.
|
|
1531
|
+
private overlayVertexBuffer!: GPUBuffer
|
|
1532
|
+
private overlayInstanceBuffer: GPUBuffer | null = null
|
|
1533
|
+
private overlayInstanceCapacity = 0
|
|
1534
|
+
private overlayPipeline!: GPURenderPipeline
|
|
1535
|
+
private overlaySolidPipeline!: GPURenderPipeline
|
|
1536
|
+
private overlayBindGroup!: GPUBindGroup
|
|
1537
|
+
private overlayGeometry!: OverlayGeometry
|
|
1538
|
+
private overlayPassDescriptor!: GPURenderPassDescriptor
|
|
1539
|
+
private overlayDepthTexture: GPUTexture | null = null
|
|
1540
|
+
private overlayMsaaTexture: GPUTexture | null = null
|
|
1541
|
+
private overlayResolveTexture: GPUTexture | null = null
|
|
1542
|
+
private overlayUniformBuffer!: GPUBuffer
|
|
1543
|
+
private overlayUniformData = new Float32Array(4)
|
|
1544
|
+
private overlayCompositePipeline!: GPURenderPipeline
|
|
1545
|
+
private overlayCompositeLayout!: GPUBindGroupLayout
|
|
1546
|
+
private overlayCompositeBindGroup: GPUBindGroup | null = null
|
|
1547
|
+
private overlayCompositePassDescriptor!: GPURenderPassDescriptor
|
|
1548
|
+
private overlayTargetSize: [number, number] = [0, 0]
|
|
1549
|
+
/** The overlay renders multisampled into its own layer; the scene's own depth
|
|
1550
|
+
* is discarded before the composite (see the depthRead note in render), so it
|
|
1551
|
+
* could not have shared either that or the single-sample swapchain. */
|
|
1552
|
+
private static readonly OVERLAY_SAMPLE_COUNT = 4
|
|
1553
|
+
/** Dash period in device pixels — dashes are geometry, so this is only the
|
|
1554
|
+
* reference the dashedLine shape is cut against. */
|
|
1555
|
+
private static readonly OVERLAY_DASH_PERIOD_PX = 8.0
|
|
1556
|
+
private overlayLayers = new Map<string, OverlayPrimitive[]>()
|
|
1557
|
+
private overlayBones: { modelName: string; options: BoneOverlayOptions } | null = null
|
|
1558
|
+
private overlayBodies: { modelName: string; options: RigidbodyOverlayOptions } | null = null
|
|
1559
|
+
private overlayJoints: { modelName: string; options: JointOverlayOptions } | null = null
|
|
1560
|
+
private overlayVertices: { modelName: string; xray: boolean; material: string | null } | null = null
|
|
1561
|
+
private wireframePipeline!: GPURenderPipeline
|
|
1562
|
+
private wireframeDepthPipeline!: GPURenderPipeline
|
|
1563
|
+
private wireframeUniformBuffer!: GPUBuffer
|
|
1564
|
+
private wireframeBindGroup!: GPUBindGroup
|
|
1565
|
+
/** The seam pass draws in the same frame at a different stroke and alpha, and
|
|
1566
|
+
* every queue write lands before the command buffer runs — writing the one
|
|
1567
|
+
* buffer twice would give both draws the second value. */
|
|
1568
|
+
private wireframeSeamUniformBuffer!: GPUBuffer
|
|
1569
|
+
private wireframeSeamBindGroup!: GPUBindGroup
|
|
1570
|
+
/** Same reason: the hovered material draws its own stroke, self-occluding, in
|
|
1571
|
+
* the same frame as the base mesh and the seams. */
|
|
1572
|
+
private wireframeHoverUniformBuffer!: GPUBuffer
|
|
1573
|
+
private wireframeHoverBindGroup!: GPUBindGroup
|
|
1574
|
+
private wireframeSkinLayout!: GPUBindGroupLayout
|
|
1575
|
+
private wireframeColorData = new Float32Array(8)
|
|
1576
|
+
/** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
|
|
1577
|
+
private overlayByShape = new Map<OverlayShape, OverlayPrimitive[]>()
|
|
1578
|
+
private overlayScratch: OverlayPrimitive[] = []
|
|
1579
|
+
private bonePickScratch: Float32Array = new Float32Array(0)
|
|
1580
|
+
private overlayInstanceData = new Float32Array(0)
|
|
1581
|
+
|
|
1496
1582
|
// ─── Transform gizmo ───────────────────────────────────────────────
|
|
1497
1583
|
private selectedBone: { modelName: string; boneName: string; boneIndex: number } | null = null
|
|
1584
|
+
/** The material a pointer is currently over, or null. Cheap and separate from
|
|
1585
|
+
* setVertexOverlay on purpose — the same split setSelectedBone takes from
|
|
1586
|
+
* setBoneOverlay — because this is written every frame the pointer moves and
|
|
1587
|
+
* the overlay's own option object is not something to reconstruct that often. */
|
|
1588
|
+
private hoverMaterial: { modelName: string; materialName: string } | null = null
|
|
1589
|
+
/** The transform gizmo follows setSelectedBone, which is also what selects a
|
|
1590
|
+
* bone to INSPECT. A model editor selects bones constantly and poses them
|
|
1591
|
+
* rarely, so the two need separating: off leaves selection working and takes
|
|
1592
|
+
* the handles away. */
|
|
1593
|
+
private gizmoEnabled = true
|
|
1498
1594
|
private gizmoVertexBuffer!: GPUBuffer
|
|
1499
1595
|
private gizmoTransformBuffer!: GPUBuffer
|
|
1500
1596
|
private gizmoPipeline!: GPURenderPipeline
|
|
@@ -1872,6 +1968,7 @@ export class Engine {
|
|
|
1872
1968
|
"field",
|
|
1873
1969
|
"bloom",
|
|
1874
1970
|
"composite",
|
|
1971
|
+
"overlay",
|
|
1875
1972
|
] as const
|
|
1876
1973
|
private timestampQuerySet: GPUQuerySet | null = null
|
|
1877
1974
|
private timestampResolve: GPUBuffer | null = null
|
|
@@ -5062,15 +5159,6 @@ export class Engine {
|
|
|
5062
5159
|
this.createPipelines()
|
|
5063
5160
|
this.setupResize()
|
|
5064
5161
|
Engine.instance = this
|
|
5065
|
-
// One line, at init, naming the three answers that differ between two
|
|
5066
|
-
// browsers on the same machine. Not a debug flag and not a readout — it is
|
|
5067
|
-
// the identity of the renderer that was actually built, and on a device that
|
|
5068
|
-
// cannot be attached to a debugger it is the only way to know which of the
|
|
5069
|
-
// three paths is running. Every graphics application prints this.
|
|
5070
|
-
const r = this.gpuReport()
|
|
5071
|
-
console.info(
|
|
5072
|
-
`[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`,
|
|
5073
|
-
)
|
|
5074
5162
|
}
|
|
5075
5163
|
|
|
5076
5164
|
// One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
|
|
@@ -6122,6 +6210,9 @@ export class Engine {
|
|
|
6122
6210
|
// ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
|
|
6123
6211
|
this.setupGizmo()
|
|
6124
6212
|
|
|
6213
|
+
// ─── Editor overlays (instanced wireframe primitives) ────────────
|
|
6214
|
+
this.setupOverlay()
|
|
6215
|
+
|
|
6125
6216
|
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
6126
6217
|
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
6127
6218
|
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
@@ -7094,6 +7185,330 @@ export class Engine {
|
|
|
7094
7185
|
}
|
|
7095
7186
|
}
|
|
7096
7187
|
|
|
7188
|
+
// Builds the overlay pipeline and the one vertex buffer holding every unit
|
|
7189
|
+
// wireframe. The instance buffer is grown on demand in renderOverlayPass — a
|
|
7190
|
+
// scene with no overlays on never allocates one.
|
|
7191
|
+
private setupOverlay() {
|
|
7192
|
+
this.overlayGeometry = buildOverlayShapes()
|
|
7193
|
+
const verts = this.overlayGeometry.vertices
|
|
7194
|
+
this.overlayVertexBuffer = this.device.createBuffer({
|
|
7195
|
+
label: "overlay vertex buffer",
|
|
7196
|
+
size: verts.byteLength,
|
|
7197
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
7198
|
+
})
|
|
7199
|
+
this.device.queue.writeBuffer(this.overlayVertexBuffer, 0, verts)
|
|
7200
|
+
|
|
7201
|
+
this.overlayUniformBuffer = this.device.createBuffer({
|
|
7202
|
+
label: "overlay uniforms",
|
|
7203
|
+
size: 16, // vec2 viewport + dash period + pad
|
|
7204
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7205
|
+
})
|
|
7206
|
+
const bgLayout = this.device.createBindGroupLayout({
|
|
7207
|
+
label: "overlay group 0 layout (camera + overlay)",
|
|
7208
|
+
entries: [
|
|
7209
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
7210
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
7211
|
+
],
|
|
7212
|
+
})
|
|
7213
|
+
const shader = this.device.createShaderModule({ label: "overlay shader", code: OVERLAY_SHADER_WGSL })
|
|
7214
|
+
const overlayPipelineDescriptor = {
|
|
7215
|
+
label: "overlay pipeline",
|
|
7216
|
+
layout: this.device.createPipelineLayout({
|
|
7217
|
+
label: "overlay pipeline layout",
|
|
7218
|
+
bindGroupLayouts: [bgLayout],
|
|
7219
|
+
}),
|
|
7220
|
+
vertex: {
|
|
7221
|
+
module: shader,
|
|
7222
|
+
entryPoint: "vs",
|
|
7223
|
+
buffers: [
|
|
7224
|
+
{
|
|
7225
|
+
arrayStride: OVERLAY_VERTEX_FLOATS * 4,
|
|
7226
|
+
attributes: [
|
|
7227
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }, // pos
|
|
7228
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" as GPUVertexFormat }, // dir
|
|
7229
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" as GPUVertexFormat }, // caps
|
|
7230
|
+
{ shaderLocation: 3, offset: 8 * 4, format: "float32" as GPUVertexFormat }, // side
|
|
7231
|
+
{ shaderLocation: 4, offset: 9 * 4, format: "float32" as GPUVertexFormat }, // t
|
|
7232
|
+
{ shaderLocation: 5, offset: 10 * 4, format: "float32" as GPUVertexFormat }, // mode
|
|
7233
|
+
],
|
|
7234
|
+
},
|
|
7235
|
+
{
|
|
7236
|
+
arrayStride: OVERLAY_INSTANCE_FLOATS * 4,
|
|
7237
|
+
stepMode: "instance",
|
|
7238
|
+
attributes: [
|
|
7239
|
+
{ shaderLocation: 6, offset: 0, format: "float32x4" as GPUVertexFormat }, // rotation
|
|
7240
|
+
{ shaderLocation: 7, offset: 4 * 4, format: "float32x4" as GPUVertexFormat }, // position + extent
|
|
7241
|
+
{ shaderLocation: 8, offset: 8 * 4, format: "float32x4" as GPUVertexFormat }, // scale + thickness
|
|
7242
|
+
{ shaderLocation: 9, offset: 12 * 4, format: "float32x4" as GPUVertexFormat }, // color
|
|
7243
|
+
],
|
|
7244
|
+
},
|
|
7245
|
+
],
|
|
7246
|
+
},
|
|
7247
|
+
fragment: {
|
|
7248
|
+
module: shader,
|
|
7249
|
+
entryPoint: "fs",
|
|
7250
|
+
targets: [
|
|
7251
|
+
{
|
|
7252
|
+
format: this.presentationFormat,
|
|
7253
|
+
// Premultiplied: the FS already scaled rgb by alpha. See the shader.
|
|
7254
|
+
blend: {
|
|
7255
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
7256
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
7257
|
+
},
|
|
7258
|
+
},
|
|
7259
|
+
],
|
|
7260
|
+
},
|
|
7261
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
7262
|
+
// The rig ignores depth entirely. It shares this pass's buffer with the
|
|
7263
|
+
// wireframe's mesh prepass, and that prepass exists to hide the far side
|
|
7264
|
+
// of the BODY — not to hide the skeleton inside it. An editor wants the
|
|
7265
|
+
// rig in front of the mesh, which is what "always" says. The cost is that
|
|
7266
|
+
// the rig no longer sorts against itself; for line work a few pixels wide,
|
|
7267
|
+
// draw order reads the same.
|
|
7268
|
+
depthStencil: {
|
|
7269
|
+
format: "depth24plus",
|
|
7270
|
+
depthWriteEnabled: false,
|
|
7271
|
+
depthCompare: "always",
|
|
7272
|
+
},
|
|
7273
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
7274
|
+
} satisfies GPURenderPipelineDescriptor
|
|
7275
|
+
this.overlayPipeline = this.device.createRenderPipeline(overlayPipelineDescriptor)
|
|
7276
|
+
|
|
7277
|
+
// The solid volumes: the same shader and layout, with no depth write and no
|
|
7278
|
+
// culling. A translucent body must not hide the rig behind it, and you have
|
|
7279
|
+
// to see its far wall for it to read as a volume rather than a silhouette.
|
|
7280
|
+
this.overlaySolidPipeline = this.device.createRenderPipeline({
|
|
7281
|
+
...overlayPipelineDescriptor,
|
|
7282
|
+
label: "overlay solid pipeline",
|
|
7283
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
7284
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
|
|
7285
|
+
})
|
|
7286
|
+
|
|
7287
|
+
const compositeShader = this.device.createShaderModule({
|
|
7288
|
+
label: "overlay composite shader",
|
|
7289
|
+
code: OVERLAY_COMPOSITE_SHADER_WGSL,
|
|
7290
|
+
})
|
|
7291
|
+
this.overlayCompositeLayout = this.device.createBindGroupLayout({
|
|
7292
|
+
label: "overlay composite layout",
|
|
7293
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }],
|
|
7294
|
+
})
|
|
7295
|
+
this.overlayCompositePipeline = this.device.createRenderPipeline({
|
|
7296
|
+
label: "overlay composite pipeline",
|
|
7297
|
+
layout: this.device.createPipelineLayout({
|
|
7298
|
+
label: "overlay composite pipeline layout",
|
|
7299
|
+
bindGroupLayouts: [this.overlayCompositeLayout],
|
|
7300
|
+
}),
|
|
7301
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
7302
|
+
fragment: {
|
|
7303
|
+
module: compositeShader,
|
|
7304
|
+
entryPoint: "fs",
|
|
7305
|
+
targets: [
|
|
7306
|
+
{
|
|
7307
|
+
format: this.presentationFormat,
|
|
7308
|
+
blend: {
|
|
7309
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
7310
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
7311
|
+
},
|
|
7312
|
+
},
|
|
7313
|
+
],
|
|
7314
|
+
},
|
|
7315
|
+
primitive: { topology: "triangle-list" },
|
|
7316
|
+
multisample: { count: 1 },
|
|
7317
|
+
})
|
|
7318
|
+
this.overlayCompositePassDescriptor = {
|
|
7319
|
+
label: "overlay composite pass",
|
|
7320
|
+
colorAttachments: [
|
|
7321
|
+
{ view: undefined as unknown as GPUTextureView, loadOp: "load", storeOp: "store" },
|
|
7322
|
+
],
|
|
7323
|
+
}
|
|
7324
|
+
|
|
7325
|
+
this.overlayBindGroup = this.device.createBindGroup({
|
|
7326
|
+
label: "overlay bind group",
|
|
7327
|
+
layout: bgLayout,
|
|
7328
|
+
entries: [
|
|
7329
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
7330
|
+
{ binding: 1, resource: { buffer: this.overlayUniformBuffer } },
|
|
7331
|
+
],
|
|
7332
|
+
})
|
|
7333
|
+
|
|
7334
|
+
// The mesh wireframe: the same line-list target, its own pipeline, because it
|
|
7335
|
+
// draws the model's OWN vertex buffer through the model's OWN skin matrices.
|
|
7336
|
+
// That is the whole reason it exists rather than emitting lines from the
|
|
7337
|
+
// loader's positions — those are bind pose, and a wireframe built from them
|
|
7338
|
+
// sits perfectly on a T-posed model and slides off every animated one.
|
|
7339
|
+
this.wireframeUniformBuffer = this.device.createBuffer({
|
|
7340
|
+
label: "wireframe color",
|
|
7341
|
+
size: 32, // vec4 colour + vec2 viewport + thickness + pad
|
|
7342
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7343
|
+
})
|
|
7344
|
+
this.wireframeSeamUniformBuffer = this.device.createBuffer({
|
|
7345
|
+
label: "wireframe color (material borders)",
|
|
7346
|
+
size: 32,
|
|
7347
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7348
|
+
})
|
|
7349
|
+
this.wireframeHoverUniformBuffer = this.device.createBuffer({
|
|
7350
|
+
label: "wireframe color (hovered material)",
|
|
7351
|
+
size: 32,
|
|
7352
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7353
|
+
})
|
|
7354
|
+
const wireBg0 = this.device.createBindGroupLayout({
|
|
7355
|
+
label: "wireframe group 0 layout (camera + wire)",
|
|
7356
|
+
entries: [
|
|
7357
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
7358
|
+
// Both stages: the FS takes the colour, the VS takes the viewport and
|
|
7359
|
+
// the stroke width it extrudes each edge quad to.
|
|
7360
|
+
{
|
|
7361
|
+
binding: 1,
|
|
7362
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
7363
|
+
buffer: { type: "uniform" },
|
|
7364
|
+
},
|
|
7365
|
+
],
|
|
7366
|
+
})
|
|
7367
|
+
// Spelled out rather than mapped over a range: tests/bindings.test.mjs reads
|
|
7368
|
+
// these statically to check every bind group covers its layout, and a loop
|
|
7369
|
+
// hides the bindings from it.
|
|
7370
|
+
this.wireframeSkinLayout = this.device.createBindGroupLayout({
|
|
7371
|
+
label: "wireframe group 1 layout (mesh + skin)",
|
|
7372
|
+
entries: [
|
|
7373
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
7374
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
7375
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
7376
|
+
{ binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
7377
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
7378
|
+
],
|
|
7379
|
+
})
|
|
7380
|
+
const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL })
|
|
7381
|
+
this.wireframePipeline = this.device.createRenderPipeline({
|
|
7382
|
+
label: "wireframe pipeline",
|
|
7383
|
+
layout: this.device.createPipelineLayout({
|
|
7384
|
+
label: "wireframe pipeline layout",
|
|
7385
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
7386
|
+
}),
|
|
7387
|
+
// No vertex stream: an edge quad's corners come from two different model
|
|
7388
|
+
// vertices, so the mesh is read through storage instead.
|
|
7389
|
+
vertex: { module: wireShader, entryPoint: "vs" },
|
|
7390
|
+
fragment: {
|
|
7391
|
+
module: wireShader,
|
|
7392
|
+
entryPoint: "fs",
|
|
7393
|
+
targets: [
|
|
7394
|
+
{
|
|
7395
|
+
format: this.presentationFormat,
|
|
7396
|
+
blend: {
|
|
7397
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
7398
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
7399
|
+
},
|
|
7400
|
+
},
|
|
7401
|
+
],
|
|
7402
|
+
},
|
|
7403
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
7404
|
+
// Depth-TESTED but not written: the mesh is a haze the rig reads against,
|
|
7405
|
+
// so a bone behind a triangle must not be punched out by it.
|
|
7406
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
7407
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
7408
|
+
})
|
|
7409
|
+
// The mesh's own depth, so the wireframe can be occluded by the body it
|
|
7410
|
+
// belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
|
|
7411
|
+
// three's and Babylon's wireframe materials all depth-test, and X-ray is a
|
|
7412
|
+
// toggle beside them. Seeing both walls of a 30k-triangle body at once is
|
|
7413
|
+
// moire, not information.
|
|
7414
|
+
//
|
|
7415
|
+
// It writes depth and nothing else — but it still DECLARES the colour
|
|
7416
|
+
// target, at writeMask 0. A pipeline's attachment state has to match its
|
|
7417
|
+
// pass's, and a pass with a colour attachment will not take a pipeline that
|
|
7418
|
+
// has none. Same trick the scene's own depth prepass uses.
|
|
7419
|
+
//
|
|
7420
|
+
// Its own pass rather than the scene's, because the scene's depth is
|
|
7421
|
+
// multisampled and discarded before the composite.
|
|
7422
|
+
this.wireframeDepthPipeline = this.device.createRenderPipeline({
|
|
7423
|
+
label: "wireframe depth prepass pipeline",
|
|
7424
|
+
layout: this.device.createPipelineLayout({
|
|
7425
|
+
label: "wireframe depth prepass layout",
|
|
7426
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
7427
|
+
}),
|
|
7428
|
+
vertex: {
|
|
7429
|
+
module: wireShader,
|
|
7430
|
+
entryPoint: "vsDepth",
|
|
7431
|
+
buffers: [
|
|
7432
|
+
{ arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }] },
|
|
7433
|
+
{ arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" as GPUVertexFormat }] },
|
|
7434
|
+
{ arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" as GPUVertexFormat }] },
|
|
7435
|
+
],
|
|
7436
|
+
},
|
|
7437
|
+
fragment: {
|
|
7438
|
+
module: wireShader,
|
|
7439
|
+
entryPoint: "fs",
|
|
7440
|
+
targets: [{ format: this.presentationFormat, writeMask: 0 }],
|
|
7441
|
+
},
|
|
7442
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
7443
|
+
depthStencil: {
|
|
7444
|
+
format: "depth24plus",
|
|
7445
|
+
depthWriteEnabled: true,
|
|
7446
|
+
depthCompare: this.depthAhead,
|
|
7447
|
+
// The wireframe lies exactly ON the surface this writes, so every edge
|
|
7448
|
+
// ties with its own triangles and loses wherever rounding goes the wrong
|
|
7449
|
+
// way — lines that break up and shift as the camera turns. Push the
|
|
7450
|
+
// solid mesh back so the edges win their own ties. The slope term is
|
|
7451
|
+
// what handles a surface seen at a grazing angle, where a pixel spans
|
|
7452
|
+
// far more depth than a constant bias can cover.
|
|
7453
|
+
//
|
|
7454
|
+
// SIGNED BY CONVENTION, as the outline hulls are: bias adds to the depth
|
|
7455
|
+
// VALUE, and reversed-Z inverts what a larger value means.
|
|
7456
|
+
depthBias: this.reversedZ ? -64 : 64,
|
|
7457
|
+
depthBiasSlopeScale: this.reversedZ ? -2 : 2,
|
|
7458
|
+
depthBiasClamp: 0,
|
|
7459
|
+
},
|
|
7460
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
7461
|
+
})
|
|
7462
|
+
|
|
7463
|
+
this.wireframeBindGroup = this.device.createBindGroup({
|
|
7464
|
+
label: "wireframe bind group",
|
|
7465
|
+
layout: wireBg0,
|
|
7466
|
+
entries: [
|
|
7467
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
7468
|
+
{ binding: 1, resource: { buffer: this.wireframeUniformBuffer } },
|
|
7469
|
+
],
|
|
7470
|
+
})
|
|
7471
|
+
this.wireframeSeamBindGroup = this.device.createBindGroup({
|
|
7472
|
+
label: "wireframe bind group (material borders)",
|
|
7473
|
+
layout: wireBg0,
|
|
7474
|
+
entries: [
|
|
7475
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
7476
|
+
{ binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
|
|
7477
|
+
],
|
|
7478
|
+
})
|
|
7479
|
+
this.wireframeHoverBindGroup = this.device.createBindGroup({
|
|
7480
|
+
label: "wireframe bind group (hovered material)",
|
|
7481
|
+
layout: wireBg0,
|
|
7482
|
+
entries: [
|
|
7483
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
7484
|
+
{ binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
|
|
7485
|
+
],
|
|
7486
|
+
})
|
|
7487
|
+
|
|
7488
|
+
this.overlayPassDescriptor = {
|
|
7489
|
+
label: "overlay pass",
|
|
7490
|
+
timestampWrites: this.stamps("overlay"),
|
|
7491
|
+
colorAttachments: [
|
|
7492
|
+
{
|
|
7493
|
+
view: undefined as unknown as GPUTextureView,
|
|
7494
|
+
resolveTarget: undefined,
|
|
7495
|
+
// Transparent, because this layer is composited over the frame rather
|
|
7496
|
+
// than drawn into it. storeOp discard keeps the 4 samples in tile
|
|
7497
|
+
// memory on a TBDR part — only the resolve reaches RAM.
|
|
7498
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
7499
|
+
loadOp: "clear",
|
|
7500
|
+
storeOp: "discard",
|
|
7501
|
+
},
|
|
7502
|
+
],
|
|
7503
|
+
depthStencilAttachment: {
|
|
7504
|
+
view: undefined as unknown as GPUTextureView,
|
|
7505
|
+
depthClearValue: this.depthClear,
|
|
7506
|
+
depthLoadOp: "clear",
|
|
7507
|
+
depthStoreOp: "discard",
|
|
7508
|
+
},
|
|
7509
|
+
}
|
|
7510
|
+
}
|
|
7511
|
+
|
|
7097
7512
|
// Step 4: Create camera and uniform buffer
|
|
7098
7513
|
private setupCamera() {
|
|
7099
7514
|
this.cameraUniformBuffer = this.device.createBuffer({
|
|
@@ -7667,6 +8082,13 @@ export class Engine {
|
|
|
7667
8082
|
* scaled by how far the reflected geometry sits behind the surface. */
|
|
7668
8083
|
mirrorBlur?: number
|
|
7669
8084
|
}): void {
|
|
8085
|
+
// NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
|
|
8086
|
+
// deferred a frame by useSceneSync's own rAF batching, and a hot reload
|
|
8087
|
+
// that swaps in a new (uninitialized) engine between the schedule and the
|
|
8088
|
+
// callback lands this on a `device` that has not been assigned yet. The
|
|
8089
|
+
// effect that scheduled it re-fires once the new engine is ready, so
|
|
8090
|
+
// dropping this one loses nothing.
|
|
8091
|
+
if (!this.device) return
|
|
7670
8092
|
const opts = {
|
|
7671
8093
|
width: 160,
|
|
7672
8094
|
height: 160,
|
|
@@ -7767,7 +8189,14 @@ export class Engine {
|
|
|
7767
8189
|
return this.lightHeader[0]
|
|
7768
8190
|
}
|
|
7769
8191
|
|
|
8192
|
+
/** Guarded, unlike most private writers here, because its callers are not:
|
|
8193
|
+
* setWorld/setSun are public and can be called before init() finishes
|
|
8194
|
+
* assigning `device` — a scene-settings effect firing on mount races the
|
|
8195
|
+
* engine's own async setup. The state write still lands immediately either
|
|
8196
|
+
* way; only the GPU upload defers, and setupLighting's own writeWorld/
|
|
8197
|
+
* writeSun calls during init pick up whatever was already set. */
|
|
7770
8198
|
private updateLightBuffer() {
|
|
8199
|
+
if (!this.device || !this.lightUniformBuffer) return
|
|
7771
8200
|
this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData)
|
|
7772
8201
|
}
|
|
7773
8202
|
|
|
@@ -7817,6 +8246,19 @@ export class Engine {
|
|
|
7817
8246
|
this.canvas.removeEventListener("touchend", this.handleCanvasTouch)
|
|
7818
8247
|
}
|
|
7819
8248
|
|
|
8249
|
+
this.overlayDepthTexture?.destroy()
|
|
8250
|
+
this.overlayDepthTexture = null
|
|
8251
|
+
this.overlayMsaaTexture?.destroy()
|
|
8252
|
+
this.overlayMsaaTexture = null
|
|
8253
|
+
this.overlayResolveTexture?.destroy()
|
|
8254
|
+
this.overlayResolveTexture = null
|
|
8255
|
+
this.overlayInstanceBuffer?.destroy()
|
|
8256
|
+
this.overlayInstanceBuffer = null
|
|
8257
|
+
for (const inst of this.modelInstances.values()) {
|
|
8258
|
+
for (const edges of inst.wireEdges.values()) edges?.buffer.destroy()
|
|
8259
|
+
inst.wireEdges.clear()
|
|
8260
|
+
}
|
|
8261
|
+
|
|
7820
8262
|
// Remove gizmo drag listeners
|
|
7821
8263
|
this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true })
|
|
7822
8264
|
window.removeEventListener("mousemove", this.handleGizmoMouseMove)
|
|
@@ -8278,6 +8720,18 @@ export class Engine {
|
|
|
8278
8720
|
this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null
|
|
8279
8721
|
}
|
|
8280
8722
|
|
|
8723
|
+
/** Show the transform gizmo on the selected bone. On by default. */
|
|
8724
|
+
setGizmoEnabled(on: boolean): void {
|
|
8725
|
+
this.gizmoEnabled = on
|
|
8726
|
+
}
|
|
8727
|
+
|
|
8728
|
+
/** A pointer-driven preview of a pick, not a pick itself — see pickMaterial
|
|
8729
|
+
* for the click that actually selects one. Cheap: a field write, nothing
|
|
8730
|
+
* rebuilt, safe to call every frame the pointer is over the canvas. */
|
|
8731
|
+
setHoveredMaterial(modelName: string | null, materialName: string | null): void {
|
|
8732
|
+
this.hoverMaterial = modelName && materialName ? { modelName, materialName } : null
|
|
8733
|
+
}
|
|
8734
|
+
|
|
8281
8735
|
setSelectedBone(modelName: string | null, boneName: string | null): void {
|
|
8282
8736
|
if (!modelName || !boneName) {
|
|
8283
8737
|
this.selectedBone = null
|
|
@@ -8292,6 +8746,246 @@ export class Engine {
|
|
|
8292
8746
|
this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null
|
|
8293
8747
|
}
|
|
8294
8748
|
|
|
8749
|
+
// ─── Editor overlays ───────────────────────────────────────────────
|
|
8750
|
+
//
|
|
8751
|
+
// Two ways in. setOverlay takes a list and draws exactly that list, so a host
|
|
8752
|
+
// can paste one in, hand one to a test, or print one back out. The three live
|
|
8753
|
+
// layers name a model instead and are rebuilt from its pose every frame,
|
|
8754
|
+
// which is the only way a skeleton overlay can be right on an animated model.
|
|
8755
|
+
|
|
8756
|
+
/**
|
|
8757
|
+
* Replace one named layer of overlay primitives. World space, drawn as given
|
|
8758
|
+
* until it is replaced. An empty list removes the layer.
|
|
8759
|
+
*/
|
|
8760
|
+
setOverlay(layer: string, primitives: OverlayPrimitive[]): void {
|
|
8761
|
+
if (primitives.length === 0) this.overlayLayers.delete(layer)
|
|
8762
|
+
else this.overlayLayers.set(layer, primitives)
|
|
8763
|
+
}
|
|
8764
|
+
|
|
8765
|
+
/** Drop one named layer, or every one. Live layers keep drawing. */
|
|
8766
|
+
clearOverlay(layer?: string): void {
|
|
8767
|
+
if (layer === undefined) this.overlayLayers.clear()
|
|
8768
|
+
else this.overlayLayers.delete(layer)
|
|
8769
|
+
}
|
|
8770
|
+
|
|
8771
|
+
/**
|
|
8772
|
+
* The bone whose marker is nearest a point on the canvas, or null.
|
|
8773
|
+
*
|
|
8774
|
+
* On the CPU, and exact. A few hundred bones with known world positions is a
|
|
8775
|
+
* loop, not a render pass — and having the answer synchronously is what makes
|
|
8776
|
+
* cycling through overlapping bones possible at all. Only VERTICES justify GPU
|
|
8777
|
+
* picking, at tens of thousands.
|
|
8778
|
+
*
|
|
8779
|
+
* `x`/`y` are CSS pixels relative to the canvas, which is what a MouseEvent
|
|
8780
|
+
* gives once getBoundingClientRect is subtracted.
|
|
8781
|
+
*
|
|
8782
|
+
* It projects boneMarkerPositions, the same points the overlay draws markers
|
|
8783
|
+
* at, so the hit box cannot drift away from the circle you are aiming at.
|
|
8784
|
+
*/
|
|
8785
|
+
pickBone(
|
|
8786
|
+
x: number,
|
|
8787
|
+
y: number,
|
|
8788
|
+
options: { radiusPx?: number; modelName?: string } = {},
|
|
8789
|
+
): { modelName: string; boneName: string; boneIndex: number } | null {
|
|
8790
|
+
if (!this.camera) return null
|
|
8791
|
+
const width = this.canvas.clientWidth
|
|
8792
|
+
const height = this.canvas.clientHeight
|
|
8793
|
+
if (width <= 0 || height <= 0) return null
|
|
8794
|
+
const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
|
|
8795
|
+
|
|
8796
|
+
let best: { modelName: string; boneName: string; boneIndex: number } | null = null
|
|
8797
|
+
let bestDist = options.radiusPx ?? 14
|
|
8798
|
+
let bestDepth = Infinity
|
|
8799
|
+
|
|
8800
|
+
for (const inst of this.modelInstances.values()) {
|
|
8801
|
+
if (options.modelName !== undefined && inst.name !== options.modelName) continue
|
|
8802
|
+
if (inst.isStage || inst.isPlane) continue
|
|
8803
|
+
const bones = inst.model.getSkeleton().bones
|
|
8804
|
+
this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch)
|
|
8805
|
+
const pos = this.bonePickScratch
|
|
8806
|
+
for (let i = 0; i < bones.length; i++) {
|
|
8807
|
+
const px = pos[i * 3]
|
|
8808
|
+
const py = pos[i * 3 + 1]
|
|
8809
|
+
const pz = pos[i * 3 + 2]
|
|
8810
|
+
const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15]
|
|
8811
|
+
if (cw <= 1e-6) continue // behind the camera
|
|
8812
|
+
const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]
|
|
8813
|
+
const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]
|
|
8814
|
+
const sx = ((cx / cw) * 0.5 + 0.5) * width
|
|
8815
|
+
const sy = (1 - ((cy / cw) * 0.5 + 0.5)) * height
|
|
8816
|
+
const d = Math.hypot(sx - x, sy - y)
|
|
8817
|
+
if (d > bestDist) continue
|
|
8818
|
+
// Within a couple of pixels the two are the same click, and MMD stacks
|
|
8819
|
+
// control bones on one point — so the nearer bone takes it.
|
|
8820
|
+
if (d < bestDist - 2 || cw < bestDepth) {
|
|
8821
|
+
best = { modelName: inst.name, boneName: bones[i].name, boneIndex: i }
|
|
8822
|
+
bestDist = d
|
|
8823
|
+
bestDepth = cw
|
|
8824
|
+
}
|
|
8825
|
+
}
|
|
8826
|
+
}
|
|
8827
|
+
return best
|
|
8828
|
+
}
|
|
8829
|
+
|
|
8830
|
+
/** Skinned positions for picking, grown on demand. One click's worth of work
|
|
8831
|
+
* reused across clicks — a model's vertex count does not change. */
|
|
8832
|
+
private materialPickScratch: Float32Array | null = null
|
|
8833
|
+
|
|
8834
|
+
/**
|
|
8835
|
+
* The material under a point on the canvas, or null for a miss.
|
|
8836
|
+
*
|
|
8837
|
+
* On the CPU, like pickBone, and for the same reason: a click (or a hover) is
|
|
8838
|
+
* rare and an answer you have synchronously is worth more than one that
|
|
8839
|
+
* arrives a frame later. Tens of thousands of triangles is a loop that costs
|
|
8840
|
+
* a few milliseconds ONCE, against a GPU id pass that costs an attachment and
|
|
8841
|
+
* a readback every frame whether anyone is pointing at the model or not.
|
|
8842
|
+
*
|
|
8843
|
+
* Skinned on the CPU with getSkinMatrices — the same matrices the vertex
|
|
8844
|
+
* shader uses — so the pick lands on the POSED mesh. Bind-pose geometry would
|
|
8845
|
+
* be right on a T-posed model and wrong on every animated one, which is
|
|
8846
|
+
* exactly when someone is clicking around a costume.
|
|
8847
|
+
*
|
|
8848
|
+
* Morph offsets are NOT applied: they move a face, never move it into another
|
|
8849
|
+
* material, and reading them back per click would cost more than the pick.
|
|
8850
|
+
*
|
|
8851
|
+
* `x`/`y` are CSS pixels relative to the canvas, as pickBone takes them.
|
|
8852
|
+
*/
|
|
8853
|
+
pickMaterial(
|
|
8854
|
+
x: number,
|
|
8855
|
+
y: number,
|
|
8856
|
+
options: { modelName?: string } = {},
|
|
8857
|
+
): { modelName: string; materialName: string; materialIndex: number } | null {
|
|
8858
|
+
if (!this.camera) return null
|
|
8859
|
+
const width = this.canvas.clientWidth
|
|
8860
|
+
const height = this.canvas.clientHeight
|
|
8861
|
+
if (width <= 0 || height <= 0) return null
|
|
8862
|
+
const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
|
|
8863
|
+
|
|
8864
|
+
let best: { modelName: string; materialName: string; materialIndex: number } | null = null
|
|
8865
|
+
let bestDepth = Infinity
|
|
8866
|
+
|
|
8867
|
+
for (const inst of this.modelInstances.values()) {
|
|
8868
|
+
if (options.modelName !== undefined && inst.name !== options.modelName) continue
|
|
8869
|
+
if (inst.isStage || inst.isPlane) continue
|
|
8870
|
+
const model = inst.model
|
|
8871
|
+
const { positions } = model.getGeometry()
|
|
8872
|
+
const count = positions.length / 3
|
|
8873
|
+
const { joints, weights } = model.getSkinning()
|
|
8874
|
+
const skin = model.getSkinMatrices()
|
|
8875
|
+
|
|
8876
|
+
// Project every vertex ONCE into screen x, y and clip w. The triangle
|
|
8877
|
+
// loop then reads three of these rather than re-skinning shared vertices
|
|
8878
|
+
// — a closed mesh uses each vertex about six times.
|
|
8879
|
+
if (!this.materialPickScratch || this.materialPickScratch.length !== count * 3) {
|
|
8880
|
+
this.materialPickScratch = new Float32Array(count * 3)
|
|
8881
|
+
}
|
|
8882
|
+
const proj = this.materialPickScratch
|
|
8883
|
+
for (let v = 0; v < count; v++) {
|
|
8884
|
+
const bx = positions[v * 3]
|
|
8885
|
+
const by = positions[v * 3 + 1]
|
|
8886
|
+
const bz = positions[v * 3 + 2]
|
|
8887
|
+
let px = 0
|
|
8888
|
+
let py = 0
|
|
8889
|
+
let pz = 0
|
|
8890
|
+
for (let k = 0; k < 4; k++) {
|
|
8891
|
+
const w = weights[v * 4 + k] / 255
|
|
8892
|
+
if (w === 0) continue
|
|
8893
|
+
const m = joints[v * 4 + k] * 16
|
|
8894
|
+
px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12])
|
|
8895
|
+
py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13])
|
|
8896
|
+
pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14])
|
|
8897
|
+
}
|
|
8898
|
+
const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15]
|
|
8899
|
+
proj[v * 3 + 2] = cw
|
|
8900
|
+
if (cw <= 1e-6) continue
|
|
8901
|
+
const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]
|
|
8902
|
+
const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]
|
|
8903
|
+
proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width
|
|
8904
|
+
proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height
|
|
8905
|
+
}
|
|
8906
|
+
|
|
8907
|
+
// Point-in-triangle in SCREEN space, nearest w wins. The same projection
|
|
8908
|
+
// pickBone uses, so the two agree about where things are, and it needs no
|
|
8909
|
+
// inverse view-projection to build a ray from.
|
|
8910
|
+
const indices = model.getIndices()
|
|
8911
|
+
const materials = model.getMaterials()
|
|
8912
|
+
let m = 0
|
|
8913
|
+
let matEnd = materials.length > 0 ? materials[0].vertexCount : indices.length
|
|
8914
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
8915
|
+
while (i >= matEnd && m + 1 < materials.length) {
|
|
8916
|
+
m++
|
|
8917
|
+
matEnd += materials[m].vertexCount
|
|
8918
|
+
}
|
|
8919
|
+
const a = indices[i] * 3
|
|
8920
|
+
const b = indices[i + 1] * 3
|
|
8921
|
+
const c = indices[i + 2] * 3
|
|
8922
|
+
if (proj[a + 2] <= 1e-6 || proj[b + 2] <= 1e-6 || proj[c + 2] <= 1e-6) continue
|
|
8923
|
+
const ax = proj[a]
|
|
8924
|
+
const ay = proj[a + 1]
|
|
8925
|
+
const bx = proj[b]
|
|
8926
|
+
const by = proj[b + 1]
|
|
8927
|
+
const cx2 = proj[c]
|
|
8928
|
+
const cy2 = proj[c + 1]
|
|
8929
|
+
// Barycentric sign test, both windings: PMX faces are one winding but a
|
|
8930
|
+
// double-sided material is legitimately seen from behind.
|
|
8931
|
+
const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by)
|
|
8932
|
+
const d2 = (x - cx2) * (by - cy2) - (bx - cx2) * (y - cy2)
|
|
8933
|
+
const d3 = (x - ax) * (cy2 - ay) - (cx2 - ax) * (y - ay)
|
|
8934
|
+
const neg = d1 < 0 || d2 < 0 || d3 < 0
|
|
8935
|
+
const pos = d1 > 0 || d2 > 0 || d3 > 0
|
|
8936
|
+
if (neg && pos) continue
|
|
8937
|
+
const depth = (proj[a + 2] + proj[b + 2] + proj[c + 2]) / 3
|
|
8938
|
+
if (depth >= bestDepth) continue
|
|
8939
|
+
bestDepth = depth
|
|
8940
|
+
best = { modelName: inst.name, materialName: materials[m].name, materialIndex: m }
|
|
8941
|
+
}
|
|
8942
|
+
}
|
|
8943
|
+
return best
|
|
8944
|
+
}
|
|
8945
|
+
|
|
8946
|
+
/** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
|
|
8947
|
+
setBoneOverlay(modelName: string | null, options: BoneOverlayOptions = {}): void {
|
|
8948
|
+
this.overlayBones = modelName ? { modelName, options } : null
|
|
8949
|
+
}
|
|
8950
|
+
|
|
8951
|
+
/** Draw every rigidbody of `modelName` where the simulation has it, rebuilt
|
|
8952
|
+
* each frame. Null off. */
|
|
8953
|
+
setRigidbodyOverlay(modelName: string | null, options: RigidbodyOverlayOptions = {}): void {
|
|
8954
|
+
this.overlayBodies = modelName ? { modelName, options } : null
|
|
8955
|
+
}
|
|
8956
|
+
|
|
8957
|
+
/** Draw a cross per joint of `modelName` plus dashed lines to the bodies it
|
|
8958
|
+
* holds together, rebuilt each frame. Null off. */
|
|
8959
|
+
setJointOverlay(modelName: string | null, options: JointOverlayOptions = {}): void {
|
|
8960
|
+
this.overlayJoints = modelName ? { modelName, options } : null
|
|
8961
|
+
}
|
|
8962
|
+
|
|
8963
|
+
/**
|
|
8964
|
+
* Draw `modelName`'s mesh as a wireframe — its vertices and its topology.
|
|
8965
|
+
*
|
|
8966
|
+
* Skinned on the GPU from the model's own vertex buffer and skin matrices, so
|
|
8967
|
+
* it sits on the POSED mesh. The loader's CPU-side positions are bind pose: a
|
|
8968
|
+
* wireframe built from those looks right on a T-posed model and slides off
|
|
8969
|
+
* every animated one, which is exactly the state a user is in while looking at
|
|
8970
|
+
* weights.
|
|
8971
|
+
*
|
|
8972
|
+
* The edge list is deduplicated and built once, on the first frame this is on.
|
|
8973
|
+
*
|
|
8974
|
+
* `material` narrows the wireframe to one material's faces. The mesh still
|
|
8975
|
+
* writes depth in full, so the material reads as part of the body rather than
|
|
8976
|
+
* as a shell floating in front of it — which is the point of scoping it: you
|
|
8977
|
+
* are asking where this material's faces ARE, and an answer that ignores the
|
|
8978
|
+
* torso in front of them is not one.
|
|
8979
|
+
*/
|
|
8980
|
+
setVertexOverlay(
|
|
8981
|
+
modelName: string | null,
|
|
8982
|
+
options: { xray?: boolean; material?: string | null } = {},
|
|
8983
|
+
): void {
|
|
8984
|
+
this.overlayVertices = modelName
|
|
8985
|
+
? { modelName, xray: options.xray ?? false, material: options.material ?? null }
|
|
8986
|
+
: null
|
|
8987
|
+
}
|
|
8988
|
+
|
|
8295
8989
|
// Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
|
|
8296
8990
|
// (the group's buffer when grouped, or the shared zero buffer when ungrouped).
|
|
8297
8991
|
/** A group's uniform buffer and its maps have the same lifetime — freeing one
|
|
@@ -8387,8 +9081,23 @@ export class Engine {
|
|
|
8387
9081
|
return this.ikEnabled
|
|
8388
9082
|
}
|
|
8389
9083
|
|
|
9084
|
+
/**
|
|
9085
|
+
* Run the solver, or stop it.
|
|
9086
|
+
*
|
|
9087
|
+
* Turning it OFF snaps every body back onto its bone. Merely halting the step
|
|
9088
|
+
* leaves hair and skirts hanging wherever the simulation happened to be — a
|
|
9089
|
+
* pose nothing in the document describes, which is the opposite of what "off"
|
|
9090
|
+
* is asked for: you switch physics off to see what the RIG does, and a frozen
|
|
9091
|
+
* mid-swing is still the solver's answer, just a stale one.
|
|
9092
|
+
*/
|
|
8390
9093
|
setPhysicsEnabled(enabled: boolean): void {
|
|
9094
|
+
if (this.physicsEnabled === enabled) return
|
|
8391
9095
|
this.physicsEnabled = enabled
|
|
9096
|
+
if (enabled) return
|
|
9097
|
+
for (const inst of this.modelInstances.values()) {
|
|
9098
|
+
if (!inst.physics) continue
|
|
9099
|
+
inst.physics.reset(inst.model.getWorldMatrices())
|
|
9100
|
+
}
|
|
8392
9101
|
}
|
|
8393
9102
|
|
|
8394
9103
|
getPhysicsEnabled(): boolean {
|
|
@@ -9498,7 +10207,9 @@ export class Engine {
|
|
|
9498
10207
|
const jointsBuffer = this.device.createBuffer({
|
|
9499
10208
|
label: `${name}: joints buffer`,
|
|
9500
10209
|
size: skinning.joints.byteLength,
|
|
9501
|
-
|
|
10210
|
+
// STORAGE so the wireframe overlay can skin from it: its quads read two
|
|
10211
|
+
// different model vertices per corner, which no vertex stream can supply.
|
|
10212
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
|
|
9502
10213
|
})
|
|
9503
10214
|
this.device.queue.writeBuffer(
|
|
9504
10215
|
jointsBuffer,
|
|
@@ -9511,7 +10222,7 @@ export class Engine {
|
|
|
9511
10222
|
const weightsBuffer = this.device.createBuffer({
|
|
9512
10223
|
label: `${name}: weights buffer`,
|
|
9513
10224
|
size: skinning.weights.byteLength,
|
|
9514
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
10225
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
|
|
9515
10226
|
})
|
|
9516
10227
|
this.device.queue.writeBuffer(
|
|
9517
10228
|
weightsBuffer,
|
|
@@ -9614,6 +10325,7 @@ export class Engine {
|
|
|
9614
10325
|
jointsBuffer,
|
|
9615
10326
|
weightsBuffer,
|
|
9616
10327
|
skinMatrixBuffer,
|
|
10328
|
+
wireEdges: new Map(),
|
|
9617
10329
|
drawCalls: [],
|
|
9618
10330
|
shadowDrawCalls: [],
|
|
9619
10331
|
shadowBindGroups,
|
|
@@ -10630,6 +11342,425 @@ export class Engine {
|
|
|
10630
11342
|
epass.end()
|
|
10631
11343
|
}
|
|
10632
11344
|
|
|
11345
|
+
// Unique edges of the mesh, as a line-list index buffer. Each interior edge is
|
|
11346
|
+
// shared by two triangles, so deduplicating halves both the buffer and the
|
|
11347
|
+
// draw. Built once per model, on the first frame its wireframe is asked for.
|
|
11348
|
+
/** The index run `material` owns, or the whole list when it is null. Materials
|
|
11349
|
+
* are consecutive runs in declaration order, so the offset is a prefix sum —
|
|
11350
|
+
* the same walk the draw list does. Returns null for a name the model does
|
|
11351
|
+
* not have, which is what a stale selection looks like after a reload. */
|
|
11352
|
+
private materialIndexRange(inst: ModelInstance, material: string | null): [number, number] | null {
|
|
11353
|
+
const indices = inst.model.getIndices()
|
|
11354
|
+
if (!material) return [0, indices.length]
|
|
11355
|
+
let offset = 0
|
|
11356
|
+
for (const m of inst.model.getMaterials()) {
|
|
11357
|
+
if (m.name === material) return [offset, offset + m.vertexCount]
|
|
11358
|
+
offset += m.vertexCount
|
|
11359
|
+
}
|
|
11360
|
+
return null
|
|
11361
|
+
}
|
|
11362
|
+
|
|
11363
|
+
/** A cache key no material can collide with — a PMX name is never empty and
|
|
11364
|
+
* never contains a NUL. */
|
|
11365
|
+
private static readonly SEAM_KEY = "\u0000seams"
|
|
11366
|
+
|
|
11367
|
+
/**
|
|
11368
|
+
* @param material one material's own edges, or null for the whole mesh
|
|
11369
|
+
* @param seams every material's OUTLINE instead — the borders between them
|
|
11370
|
+
*/
|
|
11371
|
+
private ensureEdgeBuffer(inst: ModelInstance, material: string | null, seams = false): boolean {
|
|
11372
|
+
const key = material ?? (seams ? Engine.SEAM_KEY : "")
|
|
11373
|
+
if (inst.wireEdges.has(key)) return inst.wireEdges.get(key) !== null
|
|
11374
|
+
const indices = inst.model.getIndices()
|
|
11375
|
+
const vertexCount = inst.model.getGeometry().positions.length / 3
|
|
11376
|
+
const edges: number[] = []
|
|
11377
|
+
|
|
11378
|
+
if (seams && !material) {
|
|
11379
|
+
// Inside one material's run an interior edge belongs to two triangles and
|
|
11380
|
+
// a border edge to one, so counting uses within the run and keeping the
|
|
11381
|
+
// singles gives exactly that material's outline.
|
|
11382
|
+
//
|
|
11383
|
+
// Per run, never over the whole mesh: an edge two materials share is
|
|
11384
|
+
// interior to the model and a border to both, and only the per-run count
|
|
11385
|
+
// can tell those two cases apart.
|
|
11386
|
+
const used = new Map<number, number>()
|
|
11387
|
+
const seen = new Set<number>()
|
|
11388
|
+
let offset = 0
|
|
11389
|
+
for (const m of inst.model.getMaterials()) {
|
|
11390
|
+
const end = offset + m.vertexCount
|
|
11391
|
+
used.clear()
|
|
11392
|
+
const bump = (a: number, b: number) => {
|
|
11393
|
+
const k = (a < b ? a : b) * vertexCount + (a < b ? b : a)
|
|
11394
|
+
used.set(k, (used.get(k) ?? 0) + 1)
|
|
11395
|
+
}
|
|
11396
|
+
for (let i = offset; i + 2 < end; i += 3) {
|
|
11397
|
+
bump(indices[i], indices[i + 1])
|
|
11398
|
+
bump(indices[i + 1], indices[i + 2])
|
|
11399
|
+
bump(indices[i + 2], indices[i])
|
|
11400
|
+
}
|
|
11401
|
+
for (const [k, count] of used) {
|
|
11402
|
+
if (count !== 1 || seen.has(k)) continue
|
|
11403
|
+
seen.add(k)
|
|
11404
|
+
edges.push(Math.floor(k / vertexCount), k % vertexCount)
|
|
11405
|
+
}
|
|
11406
|
+
offset = end
|
|
11407
|
+
}
|
|
11408
|
+
} else {
|
|
11409
|
+
const range = this.materialIndexRange(inst, material)
|
|
11410
|
+
if (!range) return false
|
|
11411
|
+
const [start, end] = range
|
|
11412
|
+
const seen = new Set<number>()
|
|
11413
|
+
const add = (a: number, b: number) => {
|
|
11414
|
+
const lo = a < b ? a : b
|
|
11415
|
+
const hi = a < b ? b : a
|
|
11416
|
+
const k = lo * vertexCount + hi
|
|
11417
|
+
if (seen.has(k)) return
|
|
11418
|
+
seen.add(k)
|
|
11419
|
+
edges.push(lo, hi)
|
|
11420
|
+
}
|
|
11421
|
+
for (let i = start; i + 2 < end; i += 3) {
|
|
11422
|
+
add(indices[i], indices[i + 1])
|
|
11423
|
+
add(indices[i + 1], indices[i + 2])
|
|
11424
|
+
add(indices[i + 2], indices[i])
|
|
11425
|
+
}
|
|
11426
|
+
}
|
|
11427
|
+
if (edges.length === 0) {
|
|
11428
|
+
inst.wireEdges.set(key, null)
|
|
11429
|
+
return false
|
|
11430
|
+
}
|
|
11431
|
+
const data = new Uint32Array(edges)
|
|
11432
|
+
const buffer = this.device.createBuffer({
|
|
11433
|
+
label: `wireframe edges ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
|
|
11434
|
+
size: data.byteLength,
|
|
11435
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
11436
|
+
})
|
|
11437
|
+
this.device.queue.writeBuffer(buffer, 0, data)
|
|
11438
|
+
const bindGroup = this.device.createBindGroup({
|
|
11439
|
+
label: `wireframe mesh ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
|
|
11440
|
+
layout: this.wireframeSkinLayout,
|
|
11441
|
+
entries: [
|
|
11442
|
+
{ binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
|
|
11443
|
+
{ binding: 1, resource: { buffer: inst.vertexBuffer } },
|
|
11444
|
+
{ binding: 2, resource: { buffer: inst.jointsBuffer } },
|
|
11445
|
+
{ binding: 3, resource: { buffer: inst.weightsBuffer } },
|
|
11446
|
+
{ binding: 4, resource: { buffer } },
|
|
11447
|
+
],
|
|
11448
|
+
})
|
|
11449
|
+
inst.wireEdges.set(key, { buffer, count: edges.length, bindGroup })
|
|
11450
|
+
return true
|
|
11451
|
+
}
|
|
11452
|
+
|
|
11453
|
+
private renderWireframe(pass: GPURenderPassEncoder): void {
|
|
11454
|
+
if (!this.overlayVertices) return
|
|
11455
|
+
const inst = this.overlayModel(this.overlayVertices.modelName)
|
|
11456
|
+
const { material, xray } = this.overlayVertices
|
|
11457
|
+
if (!inst || !this.ensureEdgeBuffer(inst, material)) return
|
|
11458
|
+
const edges = inst.wireEdges.get(material ?? "")
|
|
11459
|
+
if (!edges) return
|
|
11460
|
+
|
|
11461
|
+
// The whole mesh gets its material BORDERS drawn over it — that is what
|
|
11462
|
+
// makes the view read as every material at once rather than as one body of
|
|
11463
|
+
// undifferentiated lines. A single material asked for by name is already
|
|
11464
|
+
// one material, so it needs no borders to separate it from anything.
|
|
11465
|
+
const seams =
|
|
11466
|
+
material === null && this.ensureEdgeBuffer(inst, null, true)
|
|
11467
|
+
? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
|
|
11468
|
+
: null
|
|
11469
|
+
|
|
11470
|
+
// A pointer over a material previews EXACTLY what clicking it would pick —
|
|
11471
|
+
// the same self-occluding reveal, layered over the section-wide view rather
|
|
11472
|
+
// than replacing it, so the rest of the mesh stays legible while one
|
|
11473
|
+
// material calls attention to itself. Meaningless once something IS
|
|
11474
|
+
// picked, since only the picked material draws at all then.
|
|
11475
|
+
const hm = this.hoverMaterial
|
|
11476
|
+
const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null
|
|
11477
|
+
const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null
|
|
11478
|
+
const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName!) : null
|
|
11479
|
+
|
|
11480
|
+
this.wireframeColorData.set(DEFAULT_VERTEX_COLOR)
|
|
11481
|
+
this.wireframeColorData[4] = this.canvas.width
|
|
11482
|
+
this.wireframeColorData[5] = this.canvas.height
|
|
11483
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.meshStrokePx
|
|
11484
|
+
// The triangulation steps back only when there is something drawn over it
|
|
11485
|
+
// to step back FROM.
|
|
11486
|
+
if (seams) this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3] * OVERLAY_STYLE.meshAlpha
|
|
11487
|
+
this.device.queue.writeBuffer(this.wireframeUniformBuffer, 0, this.wireframeColorData)
|
|
11488
|
+
if (seams) {
|
|
11489
|
+
this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3]
|
|
11490
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx
|
|
11491
|
+
this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData)
|
|
11492
|
+
}
|
|
11493
|
+
if (hover) {
|
|
11494
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx
|
|
11495
|
+
this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData)
|
|
11496
|
+
}
|
|
11497
|
+
|
|
11498
|
+
// Both bind groups, before the FIRST draw call below, regardless of which
|
|
11499
|
+
// branch runs first — the depth prepass reads the camera from group 0 and
|
|
11500
|
+
// the skin matrices from group 1 same as the edge pass does, and every
|
|
11501
|
+
// draw in this function needs both set to SOMETHING before it runs. Each
|
|
11502
|
+
// block below is free to swap either one out for its own draws.
|
|
11503
|
+
pass.setBindGroup(0, this.wireframeBindGroup)
|
|
11504
|
+
pass.setBindGroup(1, edges.bindGroup)
|
|
11505
|
+
|
|
11506
|
+
const bindMesh = () => {
|
|
11507
|
+
pass.setPipeline(this.wireframeDepthPipeline)
|
|
11508
|
+
pass.setVertexBuffer(0, inst.vertexBuffer)
|
|
11509
|
+
pass.setVertexBuffer(1, inst.jointsBuffer)
|
|
11510
|
+
pass.setVertexBuffer(2, inst.weightsBuffer)
|
|
11511
|
+
pass.setIndexBuffer(inst.indexBuffer, "uint32")
|
|
11512
|
+
}
|
|
11513
|
+
|
|
11514
|
+
// The hover preview writes and draws against its OWN depth first, while the
|
|
11515
|
+
// shared depth buffer is still empty — the same trick a pick uses, run
|
|
11516
|
+
// before the base mesh below gets a chance to occlude it. The base mesh's
|
|
11517
|
+
// depth write further down repeats the SAME geometry for these faces
|
|
11518
|
+
// (identical z), so it neither disturbs this nor needs to skip them.
|
|
11519
|
+
if (hover && hoverRange && !xray) {
|
|
11520
|
+
bindMesh()
|
|
11521
|
+
pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0])
|
|
11522
|
+
pass.setBindGroup(0, this.wireframeHoverBindGroup)
|
|
11523
|
+
pass.setBindGroup(1, hover.bindGroup)
|
|
11524
|
+
pass.setPipeline(this.wireframePipeline)
|
|
11525
|
+
pass.draw(6, hover.count / 2)
|
|
11526
|
+
pass.setBindGroup(0, this.wireframeBindGroup)
|
|
11527
|
+
pass.setBindGroup(1, edges.bindGroup)
|
|
11528
|
+
}
|
|
11529
|
+
|
|
11530
|
+
// What writes depth is what is allowed to hide the wireframe, and that
|
|
11531
|
+
// differs between the two views.
|
|
11532
|
+
//
|
|
11533
|
+
// The whole mesh, so the far wall of a 30k-triangle body does not draw on
|
|
11534
|
+
// top of the near one — occluded is the default everywhere, Blender's edit
|
|
11535
|
+
// mode and Maya included, and seeing both walls at once is moire rather than
|
|
11536
|
+
// information.
|
|
11537
|
+
//
|
|
11538
|
+
// A PICKED material writes only its OWN faces. The question a pick asks is
|
|
11539
|
+
// where this material is, and half of it is usually under a coat; letting
|
|
11540
|
+
// the coat hide it does not answer that. Its own depth still goes in, so its
|
|
11541
|
+
// back faces stay hidden and it reads as an object instead of a haze —
|
|
11542
|
+
// which is the difference between this and turning x-ray on.
|
|
11543
|
+
const range = material !== null ? this.materialIndexRange(inst, material) : null
|
|
11544
|
+
if (!xray) {
|
|
11545
|
+
bindMesh()
|
|
11546
|
+
if (range) pass.drawIndexed(range[1] - range[0], 1, range[0])
|
|
11547
|
+
else pass.drawIndexed(inst.model.getIndices().length)
|
|
11548
|
+
}
|
|
11549
|
+
|
|
11550
|
+
// Six vertices an edge, instanced.
|
|
11551
|
+
pass.setPipeline(this.wireframePipeline)
|
|
11552
|
+
pass.draw(6, edges.count / 2)
|
|
11553
|
+
|
|
11554
|
+
if (seams) {
|
|
11555
|
+
pass.setBindGroup(0, this.wireframeSeamBindGroup)
|
|
11556
|
+
pass.setBindGroup(1, seams.bindGroup)
|
|
11557
|
+
pass.draw(6, seams.count / 2)
|
|
11558
|
+
}
|
|
11559
|
+
}
|
|
11560
|
+
|
|
11561
|
+
/** The bone overlay's options with `selected` filled in from setSelectedBone,
|
|
11562
|
+
* so clicking a bone highlights it without the host mirroring the state. An
|
|
11563
|
+
* explicit `selected` in the options still wins. */
|
|
11564
|
+
private boneOverlayOptions(modelName: string): BoneOverlayOptions {
|
|
11565
|
+
const options = this.overlayBones?.options ?? {}
|
|
11566
|
+
if (options.selected !== undefined) return options
|
|
11567
|
+
const chosen = this.selectedBone?.modelName === modelName ? this.selectedBone.boneName : null
|
|
11568
|
+
this.boneOptionsScratch.selected = chosen
|
|
11569
|
+
this.boneOptionsScratch.include = options.include
|
|
11570
|
+
return this.boneOptionsScratch
|
|
11571
|
+
}
|
|
11572
|
+
private boneOptionsScratch: BoneOverlayOptions = {}
|
|
11573
|
+
|
|
11574
|
+
private overlayActive(): boolean {
|
|
11575
|
+
return (
|
|
11576
|
+
this.overlayLayers.size > 0 ||
|
|
11577
|
+
this.overlayBones !== null ||
|
|
11578
|
+
this.overlayBodies !== null ||
|
|
11579
|
+
this.overlayJoints !== null ||
|
|
11580
|
+
this.overlayVertices !== null
|
|
11581
|
+
)
|
|
11582
|
+
}
|
|
11583
|
+
|
|
11584
|
+
private overlayModel(name: string): ModelInstance | null {
|
|
11585
|
+
return this.modelInstances.get(name) ?? null
|
|
11586
|
+
}
|
|
11587
|
+
|
|
11588
|
+
/** The primitives a live layer would draw right now. Same list the pass uses,
|
|
11589
|
+
* so a host can show it as data, diff it, or hit-test it on the CPU. */
|
|
11590
|
+
getOverlayPrimitives(layer: "bones" | "rigidbodies" | "joints"): OverlayPrimitive[] {
|
|
11591
|
+
if (layer === "bones") {
|
|
11592
|
+
const inst = this.overlayBones ? this.overlayModel(this.overlayBones.modelName) : null
|
|
11593
|
+
return inst ? boneOverlay(inst.model, this.boneOverlayOptions(inst.name)) : []
|
|
11594
|
+
}
|
|
11595
|
+
if (layer === "rigidbodies") {
|
|
11596
|
+
const inst = this.overlayBodies ? this.overlayModel(this.overlayBodies.modelName) : null
|
|
11597
|
+
return inst ? rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies!.options) : []
|
|
11598
|
+
}
|
|
11599
|
+
const inst = this.overlayJoints ? this.overlayModel(this.overlayJoints.modelName) : null
|
|
11600
|
+
return inst ? jointOverlay(inst.model, inst.physics, this.overlayJoints!.options) : []
|
|
11601
|
+
}
|
|
11602
|
+
|
|
11603
|
+
// The overlay's own layer: a 4x multisampled colour target, its resolve, and a
|
|
11604
|
+
// matching depth. All three are allocated the first frame an overlay is
|
|
11605
|
+
// actually on, so a scene that never shows one never pays for any of it.
|
|
11606
|
+
private ensureOverlayTargets(width: number, height: number): void {
|
|
11607
|
+
if (this.overlayResolveTexture && this.overlayTargetSize[0] === width && this.overlayTargetSize[1] === height) {
|
|
11608
|
+
return
|
|
11609
|
+
}
|
|
11610
|
+
const samples = Engine.OVERLAY_SAMPLE_COUNT
|
|
11611
|
+
this.overlayDepthTexture?.destroy()
|
|
11612
|
+
this.overlayMsaaTexture?.destroy()
|
|
11613
|
+
this.overlayResolveTexture?.destroy()
|
|
11614
|
+
|
|
11615
|
+
this.overlayMsaaTexture = this.device.createTexture({
|
|
11616
|
+
label: "overlay msaa",
|
|
11617
|
+
size: [width, height],
|
|
11618
|
+
sampleCount: samples,
|
|
11619
|
+
format: this.presentationFormat,
|
|
11620
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
11621
|
+
})
|
|
11622
|
+
this.overlayResolveTexture = this.device.createTexture({
|
|
11623
|
+
label: "overlay resolve",
|
|
11624
|
+
size: [width, height],
|
|
11625
|
+
format: this.presentationFormat,
|
|
11626
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
11627
|
+
})
|
|
11628
|
+
this.overlayDepthTexture = this.device.createTexture({
|
|
11629
|
+
label: "overlay depth",
|
|
11630
|
+
size: [width, height],
|
|
11631
|
+
sampleCount: samples,
|
|
11632
|
+
format: "depth24plus",
|
|
11633
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
11634
|
+
})
|
|
11635
|
+
this.overlayTargetSize = [width, height]
|
|
11636
|
+
|
|
11637
|
+
const colorAtt = (this.overlayPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
11638
|
+
colorAtt.view = this.overlayMsaaTexture.createView()
|
|
11639
|
+
colorAtt.resolveTarget = this.overlayResolveTexture.createView()
|
|
11640
|
+
const depthAtt = this.overlayPassDescriptor.depthStencilAttachment as GPURenderPassDepthStencilAttachment
|
|
11641
|
+
depthAtt.view = this.overlayDepthTexture.createView()
|
|
11642
|
+
|
|
11643
|
+
this.overlayCompositeBindGroup = this.device.createBindGroup({
|
|
11644
|
+
label: "overlay composite bind group",
|
|
11645
|
+
layout: this.overlayCompositeLayout,
|
|
11646
|
+
entries: [{ binding: 0, resource: this.overlayResolveTexture.createView() }],
|
|
11647
|
+
})
|
|
11648
|
+
}
|
|
11649
|
+
|
|
11650
|
+
private ensureOverlayInstanceCapacity(count: number): void {
|
|
11651
|
+
if (this.overlayInstanceBuffer && this.overlayInstanceCapacity >= count) return
|
|
11652
|
+
// Grow in powers of two so a skirt gaining bodies one at a time does not
|
|
11653
|
+
// reallocate once per body.
|
|
11654
|
+
let capacity = Math.max(64, this.overlayInstanceCapacity || 64)
|
|
11655
|
+
while (capacity < count) capacity *= 2
|
|
11656
|
+
this.overlayInstanceBuffer?.destroy()
|
|
11657
|
+
this.overlayInstanceBuffer = this.device.createBuffer({
|
|
11658
|
+
label: "overlay instance buffer",
|
|
11659
|
+
size: capacity * OVERLAY_INSTANCE_FLOATS * 4,
|
|
11660
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
11661
|
+
})
|
|
11662
|
+
this.overlayInstanceCapacity = capacity
|
|
11663
|
+
this.overlayInstanceData = new Float32Array(capacity * OVERLAY_INSTANCE_FLOATS)
|
|
11664
|
+
}
|
|
11665
|
+
|
|
11666
|
+
// Collects every layer, groups it by shape so each shape is one instanced
|
|
11667
|
+
// draw, and runs them into the swapchain over the finished frame.
|
|
11668
|
+
private renderOverlayPass(encoder: GPUCommandEncoder, swapchainView: GPUTextureView): void {
|
|
11669
|
+
if (!this.camera) return
|
|
11670
|
+
|
|
11671
|
+
const byShape = this.overlayByShape
|
|
11672
|
+
for (const shape of OVERLAY_SHAPES) {
|
|
11673
|
+
const list = byShape.get(shape)
|
|
11674
|
+
if (list) list.length = 0
|
|
11675
|
+
}
|
|
11676
|
+
let total = 0
|
|
11677
|
+
const collect = (primitives: readonly OverlayPrimitive[]) => {
|
|
11678
|
+
for (const primitive of primitives) {
|
|
11679
|
+
let list = byShape.get(primitive.shape)
|
|
11680
|
+
if (!list) {
|
|
11681
|
+
list = []
|
|
11682
|
+
byShape.set(primitive.shape, list)
|
|
11683
|
+
}
|
|
11684
|
+
list.push(primitive)
|
|
11685
|
+
total++
|
|
11686
|
+
}
|
|
11687
|
+
}
|
|
11688
|
+
|
|
11689
|
+
for (const layer of this.overlayLayers.values()) collect(layer)
|
|
11690
|
+
if (this.overlayBones) {
|
|
11691
|
+
const inst = this.overlayModel(this.overlayBones.modelName)
|
|
11692
|
+
if (inst) collect(boneOverlay(inst.model, this.boneOverlayOptions(inst.name)))
|
|
11693
|
+
}
|
|
11694
|
+
if (this.overlayBodies) {
|
|
11695
|
+
const inst = this.overlayModel(this.overlayBodies.modelName)
|
|
11696
|
+
if (inst) collect(rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options))
|
|
11697
|
+
}
|
|
11698
|
+
if (this.overlayJoints) {
|
|
11699
|
+
const inst = this.overlayModel(this.overlayJoints.modelName)
|
|
11700
|
+
if (inst) collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options))
|
|
11701
|
+
}
|
|
11702
|
+
if (total === 0 && !this.overlayVertices) return
|
|
11703
|
+
|
|
11704
|
+
this.ensureOverlayInstanceCapacity(total)
|
|
11705
|
+
const data = this.overlayInstanceData
|
|
11706
|
+
const draws: { shape: OverlayShape; first: number; count: number }[] = []
|
|
11707
|
+
let written = 0
|
|
11708
|
+
for (const shape of OVERLAY_SHAPES) {
|
|
11709
|
+
const list = byShape.get(shape)
|
|
11710
|
+
if (!list || list.length === 0) continue
|
|
11711
|
+
draws.push({ shape, first: written, count: list.length })
|
|
11712
|
+
for (const primitive of list) {
|
|
11713
|
+
writeOverlayInstance(primitive, data, written * OVERLAY_INSTANCE_FLOATS)
|
|
11714
|
+
written++
|
|
11715
|
+
}
|
|
11716
|
+
}
|
|
11717
|
+
this.device.queue.writeBuffer(
|
|
11718
|
+
this.overlayInstanceBuffer!,
|
|
11719
|
+
0,
|
|
11720
|
+
data.buffer,
|
|
11721
|
+
data.byteOffset,
|
|
11722
|
+
written * OVERLAY_INSTANCE_FLOATS * 4,
|
|
11723
|
+
)
|
|
11724
|
+
|
|
11725
|
+
const width = this.canvas.width
|
|
11726
|
+
const height = this.canvas.height
|
|
11727
|
+
this.ensureOverlayTargets(width, height)
|
|
11728
|
+
this.overlayUniformData[0] = width
|
|
11729
|
+
this.overlayUniformData[1] = height
|
|
11730
|
+
this.overlayUniformData[2] = Engine.OVERLAY_DASH_PERIOD_PX
|
|
11731
|
+
this.device.queue.writeBuffer(this.overlayUniformBuffer, 0, this.overlayUniformData)
|
|
11732
|
+
|
|
11733
|
+
const pass = encoder.beginRenderPass(this.overlayPassDescriptor)
|
|
11734
|
+
// Under everything: the mesh is the haze the rig is read against.
|
|
11735
|
+
this.renderWireframe(pass)
|
|
11736
|
+
pass.setBindGroup(0, this.overlayBindGroup)
|
|
11737
|
+
pass.setVertexBuffer(0, this.overlayVertexBuffer)
|
|
11738
|
+
pass.setVertexBuffer(1, this.overlayInstanceBuffer!)
|
|
11739
|
+
// Volumes first and without depth writes, then the line work over them.
|
|
11740
|
+
for (const solid of [true, false]) {
|
|
11741
|
+
let bound = false
|
|
11742
|
+
for (const draw of draws) {
|
|
11743
|
+
if (OVERLAY_SOLID_SHAPES.has(draw.shape) !== solid) continue
|
|
11744
|
+
if (!bound) {
|
|
11745
|
+
pass.setPipeline(solid ? this.overlaySolidPipeline : this.overlayPipeline)
|
|
11746
|
+
bound = true
|
|
11747
|
+
}
|
|
11748
|
+
const range = this.overlayGeometry.ranges[draw.shape]
|
|
11749
|
+
pass.draw(range.count, draw.count, range.first, draw.first)
|
|
11750
|
+
}
|
|
11751
|
+
}
|
|
11752
|
+
pass.end()
|
|
11753
|
+
|
|
11754
|
+
// The resolved layer over the finished frame, premultiplied.
|
|
11755
|
+
const compositeAtt = (this.overlayCompositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
11756
|
+
compositeAtt.view = swapchainView
|
|
11757
|
+
const composite = encoder.beginRenderPass(this.overlayCompositePassDescriptor)
|
|
11758
|
+
composite.setPipeline(this.overlayCompositePipeline)
|
|
11759
|
+
composite.setBindGroup(0, this.overlayCompositeBindGroup!)
|
|
11760
|
+
composite.draw(3)
|
|
11761
|
+
composite.end()
|
|
11762
|
+
}
|
|
11763
|
+
|
|
10633
11764
|
// Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
|
|
10634
11765
|
// then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
|
|
10635
11766
|
// aligns rings with the bone's current world orientation, so clicking a ring
|
|
@@ -10844,7 +11975,7 @@ export class Engine {
|
|
|
10844
11975
|
}
|
|
10845
11976
|
|
|
10846
11977
|
private handleGizmoMouseDown = (e: MouseEvent) => {
|
|
10847
|
-
if (!this.selectedBone || !this.camera || !this.device || e.button !== 0) return
|
|
11978
|
+
if (!this.gizmoEnabled || !this.selectedBone || !this.camera || !this.device || e.button !== 0) return
|
|
10848
11979
|
const inst = this.modelInstances.get(this.selectedBone.modelName)
|
|
10849
11980
|
if (!inst) return
|
|
10850
11981
|
const worldMats = inst.model.getWorldMatrices()
|
|
@@ -11489,7 +12620,10 @@ export class Engine {
|
|
|
11489
12620
|
this.renderIdDebugPass(encoder, swapchainView)
|
|
11490
12621
|
|
|
11491
12622
|
if (this.selectedMaterial && hasModels) this.renderSelectionPasses(encoder, swapchainView)
|
|
11492
|
-
|
|
12623
|
+
// Under the gizmo: the handles you drag stay on top of the rig you are
|
|
12624
|
+
// reading them against.
|
|
12625
|
+
if (this.overlayActive()) this.renderOverlayPass(encoder, swapchainView)
|
|
12626
|
+
if (this.gizmoEnabled && this.selectedBone && hasModels) this.renderGizmoPass(encoder, swapchainView)
|
|
11493
12627
|
|
|
11494
12628
|
const pick = this.pendingPick
|
|
11495
12629
|
if (pick && hasModels) this.renderPickPass(encoder)
|