reze-engine 0.54.15 → 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 +1140 -13
- 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/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +2 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +2 -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/grid.d.ts.map +1 -1
- package/dist/shaders/passes/grid.js +2 -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/scene-tap.d.ts +60 -0
- package/dist/shaders/scene-tap.d.ts.map +1 -0
- package/dist/shaders/scene-tap.js +234 -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 +1221 -15
- 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/lights.ts +2 -0
- package/src/shaders/passes/composite.ts +2 -0
- package/src/shaders/passes/grid.ts +2 -0
- package/src/shaders/passes/overlay.ts +166 -0
- package/src/shaders/passes/wireframe.ts +108 -0
- package/src/shaders/scene-tap.ts +236 -0
package/dist/engine.js
CHANGED
|
@@ -32,6 +32,9 @@ import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/
|
|
|
32
32
|
import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
|
|
33
33
|
import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
|
|
34
34
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
|
|
35
|
+
import { OVERLAY_SHADER_WGSL, OVERLAY_COMPOSITE_SHADER_WGSL } from "./shaders/passes/overlay";
|
|
36
|
+
import { WIREFRAME_SHADER_WGSL } from "./shaders/passes/wireframe";
|
|
37
|
+
import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, rigidbodyOverlay, writeOverlayInstance, OVERLAY_INSTANCE_FLOATS, OVERLAY_VERTEX_FLOATS, OVERLAY_SHAPES, OVERLAY_SOLID_SHAPES, DEFAULT_VERTEX_COLOR, OVERLAY_STYLE, } from "./overlay";
|
|
35
38
|
import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
|
|
36
39
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
|
|
37
40
|
import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
|
|
@@ -673,8 +676,37 @@ export class Engine {
|
|
|
673
676
|
this.resizeObserver = null;
|
|
674
677
|
this.resizePending = false;
|
|
675
678
|
this.selectedMaterial = null;
|
|
679
|
+
this.overlayInstanceBuffer = null;
|
|
680
|
+
this.overlayInstanceCapacity = 0;
|
|
681
|
+
this.overlayDepthTexture = null;
|
|
682
|
+
this.overlayMsaaTexture = null;
|
|
683
|
+
this.overlayResolveTexture = null;
|
|
684
|
+
this.overlayUniformData = new Float32Array(4);
|
|
685
|
+
this.overlayCompositeBindGroup = null;
|
|
686
|
+
this.overlayTargetSize = [0, 0];
|
|
687
|
+
this.overlayLayers = new Map();
|
|
688
|
+
this.overlayBones = null;
|
|
689
|
+
this.overlayBodies = null;
|
|
690
|
+
this.overlayJoints = null;
|
|
691
|
+
this.overlayVertices = null;
|
|
692
|
+
this.wireframeColorData = new Float32Array(8);
|
|
693
|
+
/** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
|
|
694
|
+
this.overlayByShape = new Map();
|
|
695
|
+
this.overlayScratch = [];
|
|
696
|
+
this.bonePickScratch = new Float32Array(0);
|
|
697
|
+
this.overlayInstanceData = new Float32Array(0);
|
|
676
698
|
// ─── Transform gizmo ───────────────────────────────────────────────
|
|
677
699
|
this.selectedBone = null;
|
|
700
|
+
/** The material a pointer is currently over, or null. Cheap and separate from
|
|
701
|
+
* setVertexOverlay on purpose — the same split setSelectedBone takes from
|
|
702
|
+
* setBoneOverlay — because this is written every frame the pointer moves and
|
|
703
|
+
* the overlay's own option object is not something to reconstruct that often. */
|
|
704
|
+
this.hoverMaterial = null;
|
|
705
|
+
/** The transform gizmo follows setSelectedBone, which is also what selects a
|
|
706
|
+
* bone to INSPECT. A model editor selects bones constantly and poses them
|
|
707
|
+
* rarely, so the two need separating: off leaves selection working and takes
|
|
708
|
+
* the handles away. */
|
|
709
|
+
this.gizmoEnabled = true;
|
|
678
710
|
this.gizmoColorBindGroups = [];
|
|
679
711
|
// Drag state — set on mousedown if the pointer is over a gizmo handle; cleared
|
|
680
712
|
// on mouseup. While non-null, the camera is locked and mousemove/up are routed
|
|
@@ -1037,6 +1069,9 @@ export class Engine {
|
|
|
1037
1069
|
* Set by the cascade loop, which skips a cascade that is unwanted and already
|
|
1038
1070
|
* cleared rather than re-clearing it every frame. */
|
|
1039
1071
|
this.shadowCascadeCleared = [];
|
|
1072
|
+
/** Skinned positions for picking, grown on demand. One click's worth of work
|
|
1073
|
+
* reused across clicks — a model's vertex count does not change. */
|
|
1074
|
+
this.materialPickScratch = null;
|
|
1040
1075
|
// CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
|
|
1041
1076
|
// milliseconds actually go — animation/IK/blending vs physics vs everything
|
|
1042
1077
|
// else on the render thread. The first question of any perf report.
|
|
@@ -1098,8 +1133,9 @@ export class Engine {
|
|
|
1098
1133
|
this.lastTouchTime = currentTime;
|
|
1099
1134
|
}
|
|
1100
1135
|
};
|
|
1136
|
+
this.boneOptionsScratch = {};
|
|
1101
1137
|
this.handleGizmoMouseDown = (e) => {
|
|
1102
|
-
if (!this.selectedBone || !this.camera || !this.device || e.button !== 0)
|
|
1138
|
+
if (!this.gizmoEnabled || !this.selectedBone || !this.camera || !this.device || e.button !== 0)
|
|
1103
1139
|
return;
|
|
1104
1140
|
const inst = this.modelInstances.get(this.selectedBone.modelName);
|
|
1105
1141
|
if (!inst)
|
|
@@ -2060,10 +2096,11 @@ export class Engine {
|
|
|
2060
2096
|
* change that only ever toggles between two known states.
|
|
2061
2097
|
*/
|
|
2062
2098
|
rebuildFieldBindGroup() {
|
|
2063
|
-
if (!this.device || !this.depthReadView || this.fieldUniformBuffers.length === 0)
|
|
2099
|
+
if (!this.device || !this.depthReadView || !this.compositeBloomView || this.fieldUniformBuffers.length === 0)
|
|
2064
2100
|
return;
|
|
2065
|
-
// Captured, so the null
|
|
2101
|
+
// Captured, so the null guards above survive into the closure.
|
|
2066
2102
|
const depth = this.depthReadView;
|
|
2103
|
+
const bloom = this.compositeBloomView;
|
|
2067
2104
|
const build = (owner, grid) => this.device.createBindGroup({
|
|
2068
2105
|
label: "field layer bind group",
|
|
2069
2106
|
layout: this.fieldBindGroupLayout,
|
|
@@ -2084,6 +2121,13 @@ export class Engine {
|
|
|
2084
2121
|
{ binding: 17, resource: grid },
|
|
2085
2122
|
{ binding: 18, resource: this.simSampler },
|
|
2086
2123
|
{ binding: 26, resource: this.castDistView ?? this.castDistFallbackView },
|
|
2124
|
+
{ binding: 27, resource: this.hdrResolveTexture.createView() },
|
|
2125
|
+
{ binding: 28, resource: this.simSampler },
|
|
2126
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
2127
|
+
{ binding: 5, resource: this.filmicLutView },
|
|
2128
|
+
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
2129
|
+
{ binding: 1, resource: bloom },
|
|
2130
|
+
{ binding: 4, resource: this.maskResolveView },
|
|
2087
2131
|
],
|
|
2088
2132
|
});
|
|
2089
2133
|
// Per effect: the params buffer and the grid are both its own, so two
|
|
@@ -3859,6 +3903,14 @@ export class Engine {
|
|
|
3859
3903
|
const proj = this.camera.getProjectionMatrix().values;
|
|
3860
3904
|
u[8] = proj[10];
|
|
3861
3905
|
u[9] = proj[14];
|
|
3906
|
+
// THE FAR PLANE ITSELF, written rather than left to be re-derived. An effect
|
|
3907
|
+
// that resamples has to know whether the scene was drawn at a given pixel,
|
|
3908
|
+
// and "nothing was drawn" reads back as exactly this distance. Recovering it
|
|
3909
|
+
// from the pair above means inverting a cleared depth, which is a sign trap
|
|
3910
|
+
// that differs by projection convention and fails silently — as an effect
|
|
3911
|
+
// that draws nothing at all, or one that paints the sky. The camera knows
|
|
3912
|
+
// the number; this hands it over.
|
|
3913
|
+
u[10] = this.camera.far;
|
|
3862
3914
|
this.device.queue.writeBuffer(this.dofUniformBuffer, 0, u);
|
|
3863
3915
|
}
|
|
3864
3916
|
setBloomOptions(patch) {
|
|
@@ -4020,13 +4072,6 @@ export class Engine {
|
|
|
4020
4072
|
this.createPipelines();
|
|
4021
4073
|
this.setupResize();
|
|
4022
4074
|
Engine.instance = this;
|
|
4023
|
-
// One line, at init, naming the three answers that differ between two
|
|
4024
|
-
// browsers on the same machine. Not a debug flag and not a readout — it is
|
|
4025
|
-
// the identity of the renderer that was actually built, and on a device that
|
|
4026
|
-
// cannot be attached to a debugger it is the only way to know which of the
|
|
4027
|
-
// three paths is running. Every graphics application prints this.
|
|
4028
|
-
const r = this.gpuReport();
|
|
4029
|
-
console.info(`[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`);
|
|
4030
4075
|
}
|
|
4031
4076
|
/**
|
|
4032
4077
|
* Bake the ground's frost noise once — the same fbm the shader used to run
|
|
@@ -4362,6 +4407,23 @@ export class Engine {
|
|
|
4362
4407
|
// compiled, and a 1x1 stands in when the flood is not running, so an
|
|
4363
4408
|
// author never has to guard the name.
|
|
4364
4409
|
{ binding: 26, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d" } },
|
|
4410
|
+
// The finished scene, and a sampler of its own — see scene-tap.ts for
|
|
4411
|
+
// why it may not borrow the one at 18.
|
|
4412
|
+
{ binding: 27, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
4413
|
+
{ binding: 28, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
4414
|
+
// THE VIEW TRANSFORM'S OWN RESOURCES. viewTransform() lives in the
|
|
4415
|
+
// header both this module and the composite share, but its lookups did
|
|
4416
|
+
// not: an effect calling it compiled and then failed at pipeline
|
|
4417
|
+
// creation with "binding doesn't exist", naming a binding no effect
|
|
4418
|
+
// author ever wrote. Sharing the code meant sharing these.
|
|
4419
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
4420
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
4421
|
+
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
4422
|
+
// And the scene's COVERAGE and bloom, without which the tap cannot
|
|
4423
|
+
// reconstruct a pixel: the HDR target is premultiplied, so colour alone
|
|
4424
|
+
// reads a half-transparent ground as a dark opaque one.
|
|
4425
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
4426
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
4365
4427
|
],
|
|
4366
4428
|
});
|
|
4367
4429
|
this.fieldPipelineLayout = this.device.createPipelineLayout({
|
|
@@ -4979,6 +5041,8 @@ export class Engine {
|
|
|
4979
5041
|
this.device.queue.writeBuffer(this.selectionEdgeUniformBuffer, 0, new Float32Array([5.0, 0, 0, 0]));
|
|
4980
5042
|
// ─── Transform gizmo (3 axes + 3 rings) ─────────────────────────
|
|
4981
5043
|
this.setupGizmo();
|
|
5044
|
+
// ─── Editor overlays (instanced wireframe primitives) ────────────
|
|
5045
|
+
this.setupOverlay();
|
|
4982
5046
|
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
4983
5047
|
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
4984
5048
|
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
@@ -5884,6 +5948,322 @@ export class Engine {
|
|
|
5884
5948
|
],
|
|
5885
5949
|
};
|
|
5886
5950
|
}
|
|
5951
|
+
// Builds the overlay pipeline and the one vertex buffer holding every unit
|
|
5952
|
+
// wireframe. The instance buffer is grown on demand in renderOverlayPass — a
|
|
5953
|
+
// scene with no overlays on never allocates one.
|
|
5954
|
+
setupOverlay() {
|
|
5955
|
+
this.overlayGeometry = buildOverlayShapes();
|
|
5956
|
+
const verts = this.overlayGeometry.vertices;
|
|
5957
|
+
this.overlayVertexBuffer = this.device.createBuffer({
|
|
5958
|
+
label: "overlay vertex buffer",
|
|
5959
|
+
size: verts.byteLength,
|
|
5960
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
5961
|
+
});
|
|
5962
|
+
this.device.queue.writeBuffer(this.overlayVertexBuffer, 0, verts);
|
|
5963
|
+
this.overlayUniformBuffer = this.device.createBuffer({
|
|
5964
|
+
label: "overlay uniforms",
|
|
5965
|
+
size: 16, // vec2 viewport + dash period + pad
|
|
5966
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
5967
|
+
});
|
|
5968
|
+
const bgLayout = this.device.createBindGroupLayout({
|
|
5969
|
+
label: "overlay group 0 layout (camera + overlay)",
|
|
5970
|
+
entries: [
|
|
5971
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
5972
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
5973
|
+
],
|
|
5974
|
+
});
|
|
5975
|
+
const shader = this.device.createShaderModule({ label: "overlay shader", code: OVERLAY_SHADER_WGSL });
|
|
5976
|
+
const overlayPipelineDescriptor = {
|
|
5977
|
+
label: "overlay pipeline",
|
|
5978
|
+
layout: this.device.createPipelineLayout({
|
|
5979
|
+
label: "overlay pipeline layout",
|
|
5980
|
+
bindGroupLayouts: [bgLayout],
|
|
5981
|
+
}),
|
|
5982
|
+
vertex: {
|
|
5983
|
+
module: shader,
|
|
5984
|
+
entryPoint: "vs",
|
|
5985
|
+
buffers: [
|
|
5986
|
+
{
|
|
5987
|
+
arrayStride: OVERLAY_VERTEX_FLOATS * 4,
|
|
5988
|
+
attributes: [
|
|
5989
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" }, // pos
|
|
5990
|
+
{ shaderLocation: 1, offset: 3 * 4, format: "float32x3" }, // dir
|
|
5991
|
+
{ shaderLocation: 2, offset: 6 * 4, format: "float32x2" }, // caps
|
|
5992
|
+
{ shaderLocation: 3, offset: 8 * 4, format: "float32" }, // side
|
|
5993
|
+
{ shaderLocation: 4, offset: 9 * 4, format: "float32" }, // t
|
|
5994
|
+
{ shaderLocation: 5, offset: 10 * 4, format: "float32" }, // mode
|
|
5995
|
+
],
|
|
5996
|
+
},
|
|
5997
|
+
{
|
|
5998
|
+
arrayStride: OVERLAY_INSTANCE_FLOATS * 4,
|
|
5999
|
+
stepMode: "instance",
|
|
6000
|
+
attributes: [
|
|
6001
|
+
{ shaderLocation: 6, offset: 0, format: "float32x4" }, // rotation
|
|
6002
|
+
{ shaderLocation: 7, offset: 4 * 4, format: "float32x4" }, // position + extent
|
|
6003
|
+
{ shaderLocation: 8, offset: 8 * 4, format: "float32x4" }, // scale + thickness
|
|
6004
|
+
{ shaderLocation: 9, offset: 12 * 4, format: "float32x4" }, // color
|
|
6005
|
+
],
|
|
6006
|
+
},
|
|
6007
|
+
],
|
|
6008
|
+
},
|
|
6009
|
+
fragment: {
|
|
6010
|
+
module: shader,
|
|
6011
|
+
entryPoint: "fs",
|
|
6012
|
+
targets: [
|
|
6013
|
+
{
|
|
6014
|
+
format: this.presentationFormat,
|
|
6015
|
+
// Premultiplied: the FS already scaled rgb by alpha. See the shader.
|
|
6016
|
+
blend: {
|
|
6017
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6018
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6019
|
+
},
|
|
6020
|
+
},
|
|
6021
|
+
],
|
|
6022
|
+
},
|
|
6023
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6024
|
+
// The rig ignores depth entirely. It shares this pass's buffer with the
|
|
6025
|
+
// wireframe's mesh prepass, and that prepass exists to hide the far side
|
|
6026
|
+
// of the BODY — not to hide the skeleton inside it. An editor wants the
|
|
6027
|
+
// rig in front of the mesh, which is what "always" says. The cost is that
|
|
6028
|
+
// the rig no longer sorts against itself; for line work a few pixels wide,
|
|
6029
|
+
// draw order reads the same.
|
|
6030
|
+
depthStencil: {
|
|
6031
|
+
format: "depth24plus",
|
|
6032
|
+
depthWriteEnabled: false,
|
|
6033
|
+
depthCompare: "always",
|
|
6034
|
+
},
|
|
6035
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
6036
|
+
};
|
|
6037
|
+
this.overlayPipeline = this.device.createRenderPipeline(overlayPipelineDescriptor);
|
|
6038
|
+
// The solid volumes: the same shader and layout, with no depth write and no
|
|
6039
|
+
// culling. A translucent body must not hide the rig behind it, and you have
|
|
6040
|
+
// to see its far wall for it to read as a volume rather than a silhouette.
|
|
6041
|
+
this.overlaySolidPipeline = this.device.createRenderPipeline({
|
|
6042
|
+
...overlayPipelineDescriptor,
|
|
6043
|
+
label: "overlay solid pipeline",
|
|
6044
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6045
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
|
|
6046
|
+
});
|
|
6047
|
+
const compositeShader = this.device.createShaderModule({
|
|
6048
|
+
label: "overlay composite shader",
|
|
6049
|
+
code: OVERLAY_COMPOSITE_SHADER_WGSL,
|
|
6050
|
+
});
|
|
6051
|
+
this.overlayCompositeLayout = this.device.createBindGroupLayout({
|
|
6052
|
+
label: "overlay composite layout",
|
|
6053
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }],
|
|
6054
|
+
});
|
|
6055
|
+
this.overlayCompositePipeline = this.device.createRenderPipeline({
|
|
6056
|
+
label: "overlay composite pipeline",
|
|
6057
|
+
layout: this.device.createPipelineLayout({
|
|
6058
|
+
label: "overlay composite pipeline layout",
|
|
6059
|
+
bindGroupLayouts: [this.overlayCompositeLayout],
|
|
6060
|
+
}),
|
|
6061
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
6062
|
+
fragment: {
|
|
6063
|
+
module: compositeShader,
|
|
6064
|
+
entryPoint: "fs",
|
|
6065
|
+
targets: [
|
|
6066
|
+
{
|
|
6067
|
+
format: this.presentationFormat,
|
|
6068
|
+
blend: {
|
|
6069
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6070
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6071
|
+
},
|
|
6072
|
+
},
|
|
6073
|
+
],
|
|
6074
|
+
},
|
|
6075
|
+
primitive: { topology: "triangle-list" },
|
|
6076
|
+
multisample: { count: 1 },
|
|
6077
|
+
});
|
|
6078
|
+
this.overlayCompositePassDescriptor = {
|
|
6079
|
+
label: "overlay composite pass",
|
|
6080
|
+
colorAttachments: [
|
|
6081
|
+
{ view: undefined, loadOp: "load", storeOp: "store" },
|
|
6082
|
+
],
|
|
6083
|
+
};
|
|
6084
|
+
this.overlayBindGroup = this.device.createBindGroup({
|
|
6085
|
+
label: "overlay bind group",
|
|
6086
|
+
layout: bgLayout,
|
|
6087
|
+
entries: [
|
|
6088
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6089
|
+
{ binding: 1, resource: { buffer: this.overlayUniformBuffer } },
|
|
6090
|
+
],
|
|
6091
|
+
});
|
|
6092
|
+
// The mesh wireframe: the same line-list target, its own pipeline, because it
|
|
6093
|
+
// draws the model's OWN vertex buffer through the model's OWN skin matrices.
|
|
6094
|
+
// That is the whole reason it exists rather than emitting lines from the
|
|
6095
|
+
// loader's positions — those are bind pose, and a wireframe built from them
|
|
6096
|
+
// sits perfectly on a T-posed model and slides off every animated one.
|
|
6097
|
+
this.wireframeUniformBuffer = this.device.createBuffer({
|
|
6098
|
+
label: "wireframe color",
|
|
6099
|
+
size: 32, // vec4 colour + vec2 viewport + thickness + pad
|
|
6100
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6101
|
+
});
|
|
6102
|
+
this.wireframeSeamUniformBuffer = this.device.createBuffer({
|
|
6103
|
+
label: "wireframe color (material borders)",
|
|
6104
|
+
size: 32,
|
|
6105
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6106
|
+
});
|
|
6107
|
+
this.wireframeHoverUniformBuffer = this.device.createBuffer({
|
|
6108
|
+
label: "wireframe color (hovered material)",
|
|
6109
|
+
size: 32,
|
|
6110
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
6111
|
+
});
|
|
6112
|
+
const wireBg0 = this.device.createBindGroupLayout({
|
|
6113
|
+
label: "wireframe group 0 layout (camera + wire)",
|
|
6114
|
+
entries: [
|
|
6115
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
6116
|
+
// Both stages: the FS takes the colour, the VS takes the viewport and
|
|
6117
|
+
// the stroke width it extrudes each edge quad to.
|
|
6118
|
+
{
|
|
6119
|
+
binding: 1,
|
|
6120
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
6121
|
+
buffer: { type: "uniform" },
|
|
6122
|
+
},
|
|
6123
|
+
],
|
|
6124
|
+
});
|
|
6125
|
+
// Spelled out rather than mapped over a range: tests/bindings.test.mjs reads
|
|
6126
|
+
// these statically to check every bind group covers its layout, and a loop
|
|
6127
|
+
// hides the bindings from it.
|
|
6128
|
+
this.wireframeSkinLayout = this.device.createBindGroupLayout({
|
|
6129
|
+
label: "wireframe group 1 layout (mesh + skin)",
|
|
6130
|
+
entries: [
|
|
6131
|
+
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6132
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6133
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6134
|
+
{ binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6135
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
6136
|
+
],
|
|
6137
|
+
});
|
|
6138
|
+
const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL });
|
|
6139
|
+
this.wireframePipeline = this.device.createRenderPipeline({
|
|
6140
|
+
label: "wireframe pipeline",
|
|
6141
|
+
layout: this.device.createPipelineLayout({
|
|
6142
|
+
label: "wireframe pipeline layout",
|
|
6143
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
6144
|
+
}),
|
|
6145
|
+
// No vertex stream: an edge quad's corners come from two different model
|
|
6146
|
+
// vertices, so the mesh is read through storage instead.
|
|
6147
|
+
vertex: { module: wireShader, entryPoint: "vs" },
|
|
6148
|
+
fragment: {
|
|
6149
|
+
module: wireShader,
|
|
6150
|
+
entryPoint: "fs",
|
|
6151
|
+
targets: [
|
|
6152
|
+
{
|
|
6153
|
+
format: this.presentationFormat,
|
|
6154
|
+
blend: {
|
|
6155
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6156
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
6157
|
+
},
|
|
6158
|
+
},
|
|
6159
|
+
],
|
|
6160
|
+
},
|
|
6161
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6162
|
+
// Depth-TESTED but not written: the mesh is a haze the rig reads against,
|
|
6163
|
+
// so a bone behind a triangle must not be punched out by it.
|
|
6164
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
6165
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
6166
|
+
});
|
|
6167
|
+
// The mesh's own depth, so the wireframe can be occluded by the body it
|
|
6168
|
+
// belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
|
|
6169
|
+
// three's and Babylon's wireframe materials all depth-test, and X-ray is a
|
|
6170
|
+
// toggle beside them. Seeing both walls of a 30k-triangle body at once is
|
|
6171
|
+
// moire, not information.
|
|
6172
|
+
//
|
|
6173
|
+
// It writes depth and nothing else — but it still DECLARES the colour
|
|
6174
|
+
// target, at writeMask 0. A pipeline's attachment state has to match its
|
|
6175
|
+
// pass's, and a pass with a colour attachment will not take a pipeline that
|
|
6176
|
+
// has none. Same trick the scene's own depth prepass uses.
|
|
6177
|
+
//
|
|
6178
|
+
// Its own pass rather than the scene's, because the scene's depth is
|
|
6179
|
+
// multisampled and discarded before the composite.
|
|
6180
|
+
this.wireframeDepthPipeline = this.device.createRenderPipeline({
|
|
6181
|
+
label: "wireframe depth prepass pipeline",
|
|
6182
|
+
layout: this.device.createPipelineLayout({
|
|
6183
|
+
label: "wireframe depth prepass layout",
|
|
6184
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
6185
|
+
}),
|
|
6186
|
+
vertex: {
|
|
6187
|
+
module: wireShader,
|
|
6188
|
+
entryPoint: "vsDepth",
|
|
6189
|
+
buffers: [
|
|
6190
|
+
{ arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }] },
|
|
6191
|
+
{ arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" }] },
|
|
6192
|
+
{ arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" }] },
|
|
6193
|
+
],
|
|
6194
|
+
},
|
|
6195
|
+
fragment: {
|
|
6196
|
+
module: wireShader,
|
|
6197
|
+
entryPoint: "fs",
|
|
6198
|
+
targets: [{ format: this.presentationFormat, writeMask: 0 }],
|
|
6199
|
+
},
|
|
6200
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
6201
|
+
depthStencil: {
|
|
6202
|
+
format: "depth24plus",
|
|
6203
|
+
depthWriteEnabled: true,
|
|
6204
|
+
depthCompare: this.depthAhead,
|
|
6205
|
+
// The wireframe lies exactly ON the surface this writes, so every edge
|
|
6206
|
+
// ties with its own triangles and loses wherever rounding goes the wrong
|
|
6207
|
+
// way — lines that break up and shift as the camera turns. Push the
|
|
6208
|
+
// solid mesh back so the edges win their own ties. The slope term is
|
|
6209
|
+
// what handles a surface seen at a grazing angle, where a pixel spans
|
|
6210
|
+
// far more depth than a constant bias can cover.
|
|
6211
|
+
//
|
|
6212
|
+
// SIGNED BY CONVENTION, as the outline hulls are: bias adds to the depth
|
|
6213
|
+
// VALUE, and reversed-Z inverts what a larger value means.
|
|
6214
|
+
depthBias: this.reversedZ ? -64 : 64,
|
|
6215
|
+
depthBiasSlopeScale: this.reversedZ ? -2 : 2,
|
|
6216
|
+
depthBiasClamp: 0,
|
|
6217
|
+
},
|
|
6218
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
6219
|
+
});
|
|
6220
|
+
this.wireframeBindGroup = this.device.createBindGroup({
|
|
6221
|
+
label: "wireframe bind group",
|
|
6222
|
+
layout: wireBg0,
|
|
6223
|
+
entries: [
|
|
6224
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6225
|
+
{ binding: 1, resource: { buffer: this.wireframeUniformBuffer } },
|
|
6226
|
+
],
|
|
6227
|
+
});
|
|
6228
|
+
this.wireframeSeamBindGroup = this.device.createBindGroup({
|
|
6229
|
+
label: "wireframe bind group (material borders)",
|
|
6230
|
+
layout: wireBg0,
|
|
6231
|
+
entries: [
|
|
6232
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6233
|
+
{ binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
|
|
6234
|
+
],
|
|
6235
|
+
});
|
|
6236
|
+
this.wireframeHoverBindGroup = this.device.createBindGroup({
|
|
6237
|
+
label: "wireframe bind group (hovered material)",
|
|
6238
|
+
layout: wireBg0,
|
|
6239
|
+
entries: [
|
|
6240
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
6241
|
+
{ binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
|
|
6242
|
+
],
|
|
6243
|
+
});
|
|
6244
|
+
this.overlayPassDescriptor = {
|
|
6245
|
+
label: "overlay pass",
|
|
6246
|
+
timestampWrites: this.stamps("overlay"),
|
|
6247
|
+
colorAttachments: [
|
|
6248
|
+
{
|
|
6249
|
+
view: undefined,
|
|
6250
|
+
resolveTarget: undefined,
|
|
6251
|
+
// Transparent, because this layer is composited over the frame rather
|
|
6252
|
+
// than drawn into it. storeOp discard keeps the 4 samples in tile
|
|
6253
|
+
// memory on a TBDR part — only the resolve reaches RAM.
|
|
6254
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
6255
|
+
loadOp: "clear",
|
|
6256
|
+
storeOp: "discard",
|
|
6257
|
+
},
|
|
6258
|
+
],
|
|
6259
|
+
depthStencilAttachment: {
|
|
6260
|
+
view: undefined,
|
|
6261
|
+
depthClearValue: this.depthClear,
|
|
6262
|
+
depthLoadOp: "clear",
|
|
6263
|
+
depthStoreOp: "discard",
|
|
6264
|
+
},
|
|
6265
|
+
};
|
|
6266
|
+
}
|
|
5887
6267
|
// Step 4: Create camera and uniform buffer
|
|
5888
6268
|
setupCamera() {
|
|
5889
6269
|
this.cameraUniformBuffer = this.device.createBuffer({
|
|
@@ -6028,6 +6408,20 @@ export class Engine {
|
|
|
6028
6408
|
* analysis would render silence into the exported video.
|
|
6029
6409
|
*/
|
|
6030
6410
|
setAudioData(data, bandsPerFrame, secondsPerFrame) {
|
|
6411
|
+
// NOT YET, OR NEVER AGAIN. `device` is definite-assignment: it is undefined
|
|
6412
|
+
// until init() resolves and after dispose(), and TypeScript cannot see
|
|
6413
|
+
// either state. This setter is reached from an ASYNC callback — the audio
|
|
6414
|
+
// analysis, the score fetch, the lyric rasteriser all land whenever they
|
|
6415
|
+
// land — so on a hot reload the in-flight promise of the outgoing engine
|
|
6416
|
+
// resolves against the incoming one, which is holding a ref but has not
|
|
6417
|
+
// finished init. It threw "Cannot read properties of undefined (reading
|
|
6418
|
+
// 'createBuffer')" from a line whose only crime was being fast.
|
|
6419
|
+
//
|
|
6420
|
+
// Dropped rather than queued: every caller here re-pushes on the effect
|
|
6421
|
+
// that owns the asset, and that effect re-runs on the very reload that
|
|
6422
|
+
// caused this.
|
|
6423
|
+
if (!this.device)
|
|
6424
|
+
return;
|
|
6031
6425
|
if (this.audioBuffer !== this.audioFallbackBuffer)
|
|
6032
6426
|
this.audioBuffer.destroy();
|
|
6033
6427
|
if (!data || data.length === 0) {
|
|
@@ -6074,6 +6468,20 @@ export class Engine {
|
|
|
6074
6468
|
* atlas are both fixed-size, so nothing re-binds whenever lyrics arrive.
|
|
6075
6469
|
*/
|
|
6076
6470
|
setLyrics(lines, atlas) {
|
|
6471
|
+
// NOT YET, OR NEVER AGAIN. `device` is definite-assignment: it is undefined
|
|
6472
|
+
// until init() resolves and after dispose(), and TypeScript cannot see
|
|
6473
|
+
// either state. This setter is reached from an ASYNC callback — the audio
|
|
6474
|
+
// analysis, the score fetch, the lyric rasteriser all land whenever they
|
|
6475
|
+
// land — so on a hot reload the in-flight promise of the outgoing engine
|
|
6476
|
+
// resolves against the incoming one, which is holding a ref but has not
|
|
6477
|
+
// finished init. It threw "Cannot read properties of undefined (reading
|
|
6478
|
+
// 'createBuffer')" from a line whose only crime was being fast.
|
|
6479
|
+
//
|
|
6480
|
+
// Dropped rather than queued: every caller here re-pushes on the effect
|
|
6481
|
+
// that owns the asset, and that effect re-runs on the very reload that
|
|
6482
|
+
// caused this.
|
|
6483
|
+
if (!this.device)
|
|
6484
|
+
return;
|
|
6077
6485
|
this.device.queue.writeBuffer(this.lyricsBuffer, 0, packLyrics(lines ?? [], atlas?.rects));
|
|
6078
6486
|
if (!atlas)
|
|
6079
6487
|
return;
|
|
@@ -6101,6 +6509,20 @@ export class Engine {
|
|
|
6101
6509
|
this.device.queue.copyExternalImageToTexture({ source: atlas.source }, { texture: this.lyricsTexture, premultipliedAlpha: true }, [w, h]);
|
|
6102
6510
|
}
|
|
6103
6511
|
setMidiNotes(notes, release = 0.35) {
|
|
6512
|
+
// NOT YET, OR NEVER AGAIN. `device` is definite-assignment: it is undefined
|
|
6513
|
+
// until init() resolves and after dispose(), and TypeScript cannot see
|
|
6514
|
+
// either state. This setter is reached from an ASYNC callback — the audio
|
|
6515
|
+
// analysis, the score fetch, the lyric rasteriser all land whenever they
|
|
6516
|
+
// land — so on a hot reload the in-flight promise of the outgoing engine
|
|
6517
|
+
// resolves against the incoming one, which is holding a ref but has not
|
|
6518
|
+
// finished init. It threw "Cannot read properties of undefined (reading
|
|
6519
|
+
// 'createBuffer')" from a line whose only crime was being fast.
|
|
6520
|
+
//
|
|
6521
|
+
// Dropped rather than queued: every caller here re-pushes on the effect
|
|
6522
|
+
// that owns the asset, and that effect re-runs on the very reload that
|
|
6523
|
+
// caused this.
|
|
6524
|
+
if (!this.device)
|
|
6525
|
+
return;
|
|
6104
6526
|
if (this.midiBuffer !== this.midiFallbackBuffer)
|
|
6105
6527
|
this.midiBuffer.destroy();
|
|
6106
6528
|
this.midiRelease = Math.max(0, release);
|
|
@@ -6377,6 +6799,14 @@ export class Engine {
|
|
|
6377
6799
|
return this.sun;
|
|
6378
6800
|
}
|
|
6379
6801
|
addGround(options) {
|
|
6802
|
+
// NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
|
|
6803
|
+
// deferred a frame by useSceneSync's own rAF batching, and a hot reload
|
|
6804
|
+
// that swaps in a new (uninitialized) engine between the schedule and the
|
|
6805
|
+
// callback lands this on a `device` that has not been assigned yet. The
|
|
6806
|
+
// effect that scheduled it re-fires once the new engine is ready, so
|
|
6807
|
+
// dropping this one loses nothing.
|
|
6808
|
+
if (!this.device)
|
|
6809
|
+
return;
|
|
6380
6810
|
const opts = {
|
|
6381
6811
|
width: 160,
|
|
6382
6812
|
height: 160,
|
|
@@ -6467,7 +6897,15 @@ export class Engine {
|
|
|
6467
6897
|
getLightCount() {
|
|
6468
6898
|
return this.lightHeader[0];
|
|
6469
6899
|
}
|
|
6900
|
+
/** Guarded, unlike most private writers here, because its callers are not:
|
|
6901
|
+
* setWorld/setSun are public and can be called before init() finishes
|
|
6902
|
+
* assigning `device` — a scene-settings effect firing on mount races the
|
|
6903
|
+
* engine's own async setup. The state write still lands immediately either
|
|
6904
|
+
* way; only the GPU upload defers, and setupLighting's own writeWorld/
|
|
6905
|
+
* writeSun calls during init pick up whatever was already set. */
|
|
6470
6906
|
updateLightBuffer() {
|
|
6907
|
+
if (!this.device || !this.lightUniformBuffer)
|
|
6908
|
+
return;
|
|
6471
6909
|
this.device.queue.writeBuffer(this.lightUniformBuffer, 0, this.lightData);
|
|
6472
6910
|
}
|
|
6473
6911
|
getStats() {
|
|
@@ -6511,6 +6949,19 @@ export class Engine {
|
|
|
6511
6949
|
this.canvas.removeEventListener("dblclick", this.handleCanvasDoubleClick);
|
|
6512
6950
|
this.canvas.removeEventListener("touchend", this.handleCanvasTouch);
|
|
6513
6951
|
}
|
|
6952
|
+
this.overlayDepthTexture?.destroy();
|
|
6953
|
+
this.overlayDepthTexture = null;
|
|
6954
|
+
this.overlayMsaaTexture?.destroy();
|
|
6955
|
+
this.overlayMsaaTexture = null;
|
|
6956
|
+
this.overlayResolveTexture?.destroy();
|
|
6957
|
+
this.overlayResolveTexture = null;
|
|
6958
|
+
this.overlayInstanceBuffer?.destroy();
|
|
6959
|
+
this.overlayInstanceBuffer = null;
|
|
6960
|
+
for (const inst of this.modelInstances.values()) {
|
|
6961
|
+
for (const edges of inst.wireEdges.values())
|
|
6962
|
+
edges?.buffer.destroy();
|
|
6963
|
+
inst.wireEdges.clear();
|
|
6964
|
+
}
|
|
6514
6965
|
// Remove gizmo drag listeners
|
|
6515
6966
|
this.canvas.removeEventListener("mousedown", this.handleGizmoMouseDown, { capture: true });
|
|
6516
6967
|
window.removeEventListener("mousemove", this.handleGizmoMouseMove);
|
|
@@ -6906,6 +7357,16 @@ export class Engine {
|
|
|
6906
7357
|
setSelectedMaterial(modelName, materialName) {
|
|
6907
7358
|
this.selectedMaterial = modelName && materialName ? { modelName, materialName } : null;
|
|
6908
7359
|
}
|
|
7360
|
+
/** Show the transform gizmo on the selected bone. On by default. */
|
|
7361
|
+
setGizmoEnabled(on) {
|
|
7362
|
+
this.gizmoEnabled = on;
|
|
7363
|
+
}
|
|
7364
|
+
/** A pointer-driven preview of a pick, not a pick itself — see pickMaterial
|
|
7365
|
+
* for the click that actually selects one. Cheap: a field write, nothing
|
|
7366
|
+
* rebuilt, safe to call every frame the pointer is over the canvas. */
|
|
7367
|
+
setHoveredMaterial(modelName, materialName) {
|
|
7368
|
+
this.hoverMaterial = modelName && materialName ? { modelName, materialName } : null;
|
|
7369
|
+
}
|
|
6909
7370
|
setSelectedBone(modelName, boneName) {
|
|
6910
7371
|
if (!modelName || !boneName) {
|
|
6911
7372
|
this.selectedBone = null;
|
|
@@ -6919,6 +7380,235 @@ export class Engine {
|
|
|
6919
7380
|
const boneIndex = inst.model.getSkeleton().bones.findIndex((b) => b.name === boneName);
|
|
6920
7381
|
this.selectedBone = boneIndex >= 0 ? { modelName, boneName, boneIndex } : null;
|
|
6921
7382
|
}
|
|
7383
|
+
// ─── Editor overlays ───────────────────────────────────────────────
|
|
7384
|
+
//
|
|
7385
|
+
// Two ways in. setOverlay takes a list and draws exactly that list, so a host
|
|
7386
|
+
// can paste one in, hand one to a test, or print one back out. The three live
|
|
7387
|
+
// layers name a model instead and are rebuilt from its pose every frame,
|
|
7388
|
+
// which is the only way a skeleton overlay can be right on an animated model.
|
|
7389
|
+
/**
|
|
7390
|
+
* Replace one named layer of overlay primitives. World space, drawn as given
|
|
7391
|
+
* until it is replaced. An empty list removes the layer.
|
|
7392
|
+
*/
|
|
7393
|
+
setOverlay(layer, primitives) {
|
|
7394
|
+
if (primitives.length === 0)
|
|
7395
|
+
this.overlayLayers.delete(layer);
|
|
7396
|
+
else
|
|
7397
|
+
this.overlayLayers.set(layer, primitives);
|
|
7398
|
+
}
|
|
7399
|
+
/** Drop one named layer, or every one. Live layers keep drawing. */
|
|
7400
|
+
clearOverlay(layer) {
|
|
7401
|
+
if (layer === undefined)
|
|
7402
|
+
this.overlayLayers.clear();
|
|
7403
|
+
else
|
|
7404
|
+
this.overlayLayers.delete(layer);
|
|
7405
|
+
}
|
|
7406
|
+
/**
|
|
7407
|
+
* The bone whose marker is nearest a point on the canvas, or null.
|
|
7408
|
+
*
|
|
7409
|
+
* On the CPU, and exact. A few hundred bones with known world positions is a
|
|
7410
|
+
* loop, not a render pass — and having the answer synchronously is what makes
|
|
7411
|
+
* cycling through overlapping bones possible at all. Only VERTICES justify GPU
|
|
7412
|
+
* picking, at tens of thousands.
|
|
7413
|
+
*
|
|
7414
|
+
* `x`/`y` are CSS pixels relative to the canvas, which is what a MouseEvent
|
|
7415
|
+
* gives once getBoundingClientRect is subtracted.
|
|
7416
|
+
*
|
|
7417
|
+
* It projects boneMarkerPositions, the same points the overlay draws markers
|
|
7418
|
+
* at, so the hit box cannot drift away from the circle you are aiming at.
|
|
7419
|
+
*/
|
|
7420
|
+
pickBone(x, y, options = {}) {
|
|
7421
|
+
if (!this.camera)
|
|
7422
|
+
return null;
|
|
7423
|
+
const width = this.canvas.clientWidth;
|
|
7424
|
+
const height = this.canvas.clientHeight;
|
|
7425
|
+
if (width <= 0 || height <= 0)
|
|
7426
|
+
return null;
|
|
7427
|
+
const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
|
|
7428
|
+
let best = null;
|
|
7429
|
+
let bestDist = options.radiusPx ?? 14;
|
|
7430
|
+
let bestDepth = Infinity;
|
|
7431
|
+
for (const inst of this.modelInstances.values()) {
|
|
7432
|
+
if (options.modelName !== undefined && inst.name !== options.modelName)
|
|
7433
|
+
continue;
|
|
7434
|
+
if (inst.isStage || inst.isPlane)
|
|
7435
|
+
continue;
|
|
7436
|
+
const bones = inst.model.getSkeleton().bones;
|
|
7437
|
+
this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch);
|
|
7438
|
+
const pos = this.bonePickScratch;
|
|
7439
|
+
for (let i = 0; i < bones.length; i++) {
|
|
7440
|
+
const px = pos[i * 3];
|
|
7441
|
+
const py = pos[i * 3 + 1];
|
|
7442
|
+
const pz = pos[i * 3 + 2];
|
|
7443
|
+
const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
|
|
7444
|
+
if (cw <= 1e-6)
|
|
7445
|
+
continue; // behind the camera
|
|
7446
|
+
const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
|
|
7447
|
+
const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
|
|
7448
|
+
const sx = ((cx / cw) * 0.5 + 0.5) * width;
|
|
7449
|
+
const sy = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
|
|
7450
|
+
const d = Math.hypot(sx - x, sy - y);
|
|
7451
|
+
if (d > bestDist)
|
|
7452
|
+
continue;
|
|
7453
|
+
// Within a couple of pixels the two are the same click, and MMD stacks
|
|
7454
|
+
// control bones on one point — so the nearer bone takes it.
|
|
7455
|
+
if (d < bestDist - 2 || cw < bestDepth) {
|
|
7456
|
+
best = { modelName: inst.name, boneName: bones[i].name, boneIndex: i };
|
|
7457
|
+
bestDist = d;
|
|
7458
|
+
bestDepth = cw;
|
|
7459
|
+
}
|
|
7460
|
+
}
|
|
7461
|
+
}
|
|
7462
|
+
return best;
|
|
7463
|
+
}
|
|
7464
|
+
/**
|
|
7465
|
+
* The material under a point on the canvas, or null for a miss.
|
|
7466
|
+
*
|
|
7467
|
+
* On the CPU, like pickBone, and for the same reason: a click (or a hover) is
|
|
7468
|
+
* rare and an answer you have synchronously is worth more than one that
|
|
7469
|
+
* arrives a frame later. Tens of thousands of triangles is a loop that costs
|
|
7470
|
+
* a few milliseconds ONCE, against a GPU id pass that costs an attachment and
|
|
7471
|
+
* a readback every frame whether anyone is pointing at the model or not.
|
|
7472
|
+
*
|
|
7473
|
+
* Skinned on the CPU with getSkinMatrices — the same matrices the vertex
|
|
7474
|
+
* shader uses — so the pick lands on the POSED mesh. Bind-pose geometry would
|
|
7475
|
+
* be right on a T-posed model and wrong on every animated one, which is
|
|
7476
|
+
* exactly when someone is clicking around a costume.
|
|
7477
|
+
*
|
|
7478
|
+
* Morph offsets are NOT applied: they move a face, never move it into another
|
|
7479
|
+
* material, and reading them back per click would cost more than the pick.
|
|
7480
|
+
*
|
|
7481
|
+
* `x`/`y` are CSS pixels relative to the canvas, as pickBone takes them.
|
|
7482
|
+
*/
|
|
7483
|
+
pickMaterial(x, y, options = {}) {
|
|
7484
|
+
if (!this.camera)
|
|
7485
|
+
return null;
|
|
7486
|
+
const width = this.canvas.clientWidth;
|
|
7487
|
+
const height = this.canvas.clientHeight;
|
|
7488
|
+
if (width <= 0 || height <= 0)
|
|
7489
|
+
return null;
|
|
7490
|
+
const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
|
|
7491
|
+
let best = null;
|
|
7492
|
+
let bestDepth = Infinity;
|
|
7493
|
+
for (const inst of this.modelInstances.values()) {
|
|
7494
|
+
if (options.modelName !== undefined && inst.name !== options.modelName)
|
|
7495
|
+
continue;
|
|
7496
|
+
if (inst.isStage || inst.isPlane)
|
|
7497
|
+
continue;
|
|
7498
|
+
const model = inst.model;
|
|
7499
|
+
const { positions } = model.getGeometry();
|
|
7500
|
+
const count = positions.length / 3;
|
|
7501
|
+
const { joints, weights } = model.getSkinning();
|
|
7502
|
+
const skin = model.getSkinMatrices();
|
|
7503
|
+
// Project every vertex ONCE into screen x, y and clip w. The triangle
|
|
7504
|
+
// loop then reads three of these rather than re-skinning shared vertices
|
|
7505
|
+
// — a closed mesh uses each vertex about six times.
|
|
7506
|
+
if (!this.materialPickScratch || this.materialPickScratch.length !== count * 3) {
|
|
7507
|
+
this.materialPickScratch = new Float32Array(count * 3);
|
|
7508
|
+
}
|
|
7509
|
+
const proj = this.materialPickScratch;
|
|
7510
|
+
for (let v = 0; v < count; v++) {
|
|
7511
|
+
const bx = positions[v * 3];
|
|
7512
|
+
const by = positions[v * 3 + 1];
|
|
7513
|
+
const bz = positions[v * 3 + 2];
|
|
7514
|
+
let px = 0;
|
|
7515
|
+
let py = 0;
|
|
7516
|
+
let pz = 0;
|
|
7517
|
+
for (let k = 0; k < 4; k++) {
|
|
7518
|
+
const w = weights[v * 4 + k] / 255;
|
|
7519
|
+
if (w === 0)
|
|
7520
|
+
continue;
|
|
7521
|
+
const m = joints[v * 4 + k] * 16;
|
|
7522
|
+
px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12]);
|
|
7523
|
+
py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13]);
|
|
7524
|
+
pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14]);
|
|
7525
|
+
}
|
|
7526
|
+
const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
|
|
7527
|
+
proj[v * 3 + 2] = cw;
|
|
7528
|
+
if (cw <= 1e-6)
|
|
7529
|
+
continue;
|
|
7530
|
+
const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
|
|
7531
|
+
const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
|
|
7532
|
+
proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width;
|
|
7533
|
+
proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
|
|
7534
|
+
}
|
|
7535
|
+
// Point-in-triangle in SCREEN space, nearest w wins. The same projection
|
|
7536
|
+
// pickBone uses, so the two agree about where things are, and it needs no
|
|
7537
|
+
// inverse view-projection to build a ray from.
|
|
7538
|
+
const indices = model.getIndices();
|
|
7539
|
+
const materials = model.getMaterials();
|
|
7540
|
+
let m = 0;
|
|
7541
|
+
let matEnd = materials.length > 0 ? materials[0].vertexCount : indices.length;
|
|
7542
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
7543
|
+
while (i >= matEnd && m + 1 < materials.length) {
|
|
7544
|
+
m++;
|
|
7545
|
+
matEnd += materials[m].vertexCount;
|
|
7546
|
+
}
|
|
7547
|
+
const a = indices[i] * 3;
|
|
7548
|
+
const b = indices[i + 1] * 3;
|
|
7549
|
+
const c = indices[i + 2] * 3;
|
|
7550
|
+
if (proj[a + 2] <= 1e-6 || proj[b + 2] <= 1e-6 || proj[c + 2] <= 1e-6)
|
|
7551
|
+
continue;
|
|
7552
|
+
const ax = proj[a];
|
|
7553
|
+
const ay = proj[a + 1];
|
|
7554
|
+
const bx = proj[b];
|
|
7555
|
+
const by = proj[b + 1];
|
|
7556
|
+
const cx2 = proj[c];
|
|
7557
|
+
const cy2 = proj[c + 1];
|
|
7558
|
+
// Barycentric sign test, both windings: PMX faces are one winding but a
|
|
7559
|
+
// double-sided material is legitimately seen from behind.
|
|
7560
|
+
const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by);
|
|
7561
|
+
const d2 = (x - cx2) * (by - cy2) - (bx - cx2) * (y - cy2);
|
|
7562
|
+
const d3 = (x - ax) * (cy2 - ay) - (cx2 - ax) * (y - ay);
|
|
7563
|
+
const neg = d1 < 0 || d2 < 0 || d3 < 0;
|
|
7564
|
+
const pos = d1 > 0 || d2 > 0 || d3 > 0;
|
|
7565
|
+
if (neg && pos)
|
|
7566
|
+
continue;
|
|
7567
|
+
const depth = (proj[a + 2] + proj[b + 2] + proj[c + 2]) / 3;
|
|
7568
|
+
if (depth >= bestDepth)
|
|
7569
|
+
continue;
|
|
7570
|
+
bestDepth = depth;
|
|
7571
|
+
best = { modelName: inst.name, materialName: materials[m].name, materialIndex: m };
|
|
7572
|
+
}
|
|
7573
|
+
}
|
|
7574
|
+
return best;
|
|
7575
|
+
}
|
|
7576
|
+
/** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
|
|
7577
|
+
setBoneOverlay(modelName, options = {}) {
|
|
7578
|
+
this.overlayBones = modelName ? { modelName, options } : null;
|
|
7579
|
+
}
|
|
7580
|
+
/** Draw every rigidbody of `modelName` where the simulation has it, rebuilt
|
|
7581
|
+
* each frame. Null off. */
|
|
7582
|
+
setRigidbodyOverlay(modelName, options = {}) {
|
|
7583
|
+
this.overlayBodies = modelName ? { modelName, options } : null;
|
|
7584
|
+
}
|
|
7585
|
+
/** Draw a cross per joint of `modelName` plus dashed lines to the bodies it
|
|
7586
|
+
* holds together, rebuilt each frame. Null off. */
|
|
7587
|
+
setJointOverlay(modelName, options = {}) {
|
|
7588
|
+
this.overlayJoints = modelName ? { modelName, options } : null;
|
|
7589
|
+
}
|
|
7590
|
+
/**
|
|
7591
|
+
* Draw `modelName`'s mesh as a wireframe — its vertices and its topology.
|
|
7592
|
+
*
|
|
7593
|
+
* Skinned on the GPU from the model's own vertex buffer and skin matrices, so
|
|
7594
|
+
* it sits on the POSED mesh. The loader's CPU-side positions are bind pose: a
|
|
7595
|
+
* wireframe built from those looks right on a T-posed model and slides off
|
|
7596
|
+
* every animated one, which is exactly the state a user is in while looking at
|
|
7597
|
+
* weights.
|
|
7598
|
+
*
|
|
7599
|
+
* The edge list is deduplicated and built once, on the first frame this is on.
|
|
7600
|
+
*
|
|
7601
|
+
* `material` narrows the wireframe to one material's faces. The mesh still
|
|
7602
|
+
* writes depth in full, so the material reads as part of the body rather than
|
|
7603
|
+
* as a shell floating in front of it — which is the point of scoping it: you
|
|
7604
|
+
* are asking where this material's faces ARE, and an answer that ignores the
|
|
7605
|
+
* torso in front of them is not one.
|
|
7606
|
+
*/
|
|
7607
|
+
setVertexOverlay(modelName, options = {}) {
|
|
7608
|
+
this.overlayVertices = modelName
|
|
7609
|
+
? { modelName, xray: options.xray ?? false, material: options.material ?? null }
|
|
7610
|
+
: null;
|
|
7611
|
+
}
|
|
6922
7612
|
// Build a material's bind group with binding(4) pointing at a given StyleUniforms buffer
|
|
6923
7613
|
// (the group's buffer when grouped, or the shared zero buffer when ungrouped).
|
|
6924
7614
|
/** A group's uniform buffer and its maps have the same lifetime — freeing one
|
|
@@ -7005,8 +7695,26 @@ export class Engine {
|
|
|
7005
7695
|
getIKEnabled() {
|
|
7006
7696
|
return this.ikEnabled;
|
|
7007
7697
|
}
|
|
7698
|
+
/**
|
|
7699
|
+
* Run the solver, or stop it.
|
|
7700
|
+
*
|
|
7701
|
+
* Turning it OFF snaps every body back onto its bone. Merely halting the step
|
|
7702
|
+
* leaves hair and skirts hanging wherever the simulation happened to be — a
|
|
7703
|
+
* pose nothing in the document describes, which is the opposite of what "off"
|
|
7704
|
+
* is asked for: you switch physics off to see what the RIG does, and a frozen
|
|
7705
|
+
* mid-swing is still the solver's answer, just a stale one.
|
|
7706
|
+
*/
|
|
7008
7707
|
setPhysicsEnabled(enabled) {
|
|
7708
|
+
if (this.physicsEnabled === enabled)
|
|
7709
|
+
return;
|
|
7009
7710
|
this.physicsEnabled = enabled;
|
|
7711
|
+
if (enabled)
|
|
7712
|
+
return;
|
|
7713
|
+
for (const inst of this.modelInstances.values()) {
|
|
7714
|
+
if (!inst.physics)
|
|
7715
|
+
continue;
|
|
7716
|
+
inst.physics.reset(inst.model.getWorldMatrices());
|
|
7717
|
+
}
|
|
7010
7718
|
}
|
|
7011
7719
|
getPhysicsEnabled() {
|
|
7012
7720
|
return this.physicsEnabled;
|
|
@@ -8049,13 +8757,15 @@ export class Engine {
|
|
|
8049
8757
|
const jointsBuffer = this.device.createBuffer({
|
|
8050
8758
|
label: `${name}: joints buffer`,
|
|
8051
8759
|
size: skinning.joints.byteLength,
|
|
8052
|
-
|
|
8760
|
+
// STORAGE so the wireframe overlay can skin from it: its quads read two
|
|
8761
|
+
// different model vertices per corner, which no vertex stream can supply.
|
|
8762
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
|
|
8053
8763
|
});
|
|
8054
8764
|
this.device.queue.writeBuffer(jointsBuffer, 0, skinning.joints.buffer, skinning.joints.byteOffset, skinning.joints.byteLength);
|
|
8055
8765
|
const weightsBuffer = this.device.createBuffer({
|
|
8056
8766
|
label: `${name}: weights buffer`,
|
|
8057
8767
|
size: skinning.weights.byteLength,
|
|
8058
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
8768
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE,
|
|
8059
8769
|
});
|
|
8060
8770
|
this.device.queue.writeBuffer(weightsBuffer, 0, skinning.weights.buffer, skinning.weights.byteOffset, skinning.weights.byteLength);
|
|
8061
8771
|
const skinMatrixBuffer = this.device.createBuffer({
|
|
@@ -8142,6 +8852,7 @@ export class Engine {
|
|
|
8142
8852
|
jointsBuffer,
|
|
8143
8853
|
weightsBuffer,
|
|
8144
8854
|
skinMatrixBuffer,
|
|
8855
|
+
wireEdges: new Map(),
|
|
8145
8856
|
drawCalls: [],
|
|
8146
8857
|
shadowDrawCalls: [],
|
|
8147
8858
|
shadowBindGroups,
|
|
@@ -9012,6 +9723,407 @@ export class Engine {
|
|
|
9012
9723
|
epass.draw(3);
|
|
9013
9724
|
epass.end();
|
|
9014
9725
|
}
|
|
9726
|
+
// Unique edges of the mesh, as a line-list index buffer. Each interior edge is
|
|
9727
|
+
// shared by two triangles, so deduplicating halves both the buffer and the
|
|
9728
|
+
// draw. Built once per model, on the first frame its wireframe is asked for.
|
|
9729
|
+
/** The index run `material` owns, or the whole list when it is null. Materials
|
|
9730
|
+
* are consecutive runs in declaration order, so the offset is a prefix sum —
|
|
9731
|
+
* the same walk the draw list does. Returns null for a name the model does
|
|
9732
|
+
* not have, which is what a stale selection looks like after a reload. */
|
|
9733
|
+
materialIndexRange(inst, material) {
|
|
9734
|
+
const indices = inst.model.getIndices();
|
|
9735
|
+
if (!material)
|
|
9736
|
+
return [0, indices.length];
|
|
9737
|
+
let offset = 0;
|
|
9738
|
+
for (const m of inst.model.getMaterials()) {
|
|
9739
|
+
if (m.name === material)
|
|
9740
|
+
return [offset, offset + m.vertexCount];
|
|
9741
|
+
offset += m.vertexCount;
|
|
9742
|
+
}
|
|
9743
|
+
return null;
|
|
9744
|
+
}
|
|
9745
|
+
/**
|
|
9746
|
+
* @param material one material's own edges, or null for the whole mesh
|
|
9747
|
+
* @param seams every material's OUTLINE instead — the borders between them
|
|
9748
|
+
*/
|
|
9749
|
+
ensureEdgeBuffer(inst, material, seams = false) {
|
|
9750
|
+
const key = material ?? (seams ? Engine.SEAM_KEY : "");
|
|
9751
|
+
if (inst.wireEdges.has(key))
|
|
9752
|
+
return inst.wireEdges.get(key) !== null;
|
|
9753
|
+
const indices = inst.model.getIndices();
|
|
9754
|
+
const vertexCount = inst.model.getGeometry().positions.length / 3;
|
|
9755
|
+
const edges = [];
|
|
9756
|
+
if (seams && !material) {
|
|
9757
|
+
// Inside one material's run an interior edge belongs to two triangles and
|
|
9758
|
+
// a border edge to one, so counting uses within the run and keeping the
|
|
9759
|
+
// singles gives exactly that material's outline.
|
|
9760
|
+
//
|
|
9761
|
+
// Per run, never over the whole mesh: an edge two materials share is
|
|
9762
|
+
// interior to the model and a border to both, and only the per-run count
|
|
9763
|
+
// can tell those two cases apart.
|
|
9764
|
+
const used = new Map();
|
|
9765
|
+
const seen = new Set();
|
|
9766
|
+
let offset = 0;
|
|
9767
|
+
for (const m of inst.model.getMaterials()) {
|
|
9768
|
+
const end = offset + m.vertexCount;
|
|
9769
|
+
used.clear();
|
|
9770
|
+
const bump = (a, b) => {
|
|
9771
|
+
const k = (a < b ? a : b) * vertexCount + (a < b ? b : a);
|
|
9772
|
+
used.set(k, (used.get(k) ?? 0) + 1);
|
|
9773
|
+
};
|
|
9774
|
+
for (let i = offset; i + 2 < end; i += 3) {
|
|
9775
|
+
bump(indices[i], indices[i + 1]);
|
|
9776
|
+
bump(indices[i + 1], indices[i + 2]);
|
|
9777
|
+
bump(indices[i + 2], indices[i]);
|
|
9778
|
+
}
|
|
9779
|
+
for (const [k, count] of used) {
|
|
9780
|
+
if (count !== 1 || seen.has(k))
|
|
9781
|
+
continue;
|
|
9782
|
+
seen.add(k);
|
|
9783
|
+
edges.push(Math.floor(k / vertexCount), k % vertexCount);
|
|
9784
|
+
}
|
|
9785
|
+
offset = end;
|
|
9786
|
+
}
|
|
9787
|
+
}
|
|
9788
|
+
else {
|
|
9789
|
+
const range = this.materialIndexRange(inst, material);
|
|
9790
|
+
if (!range)
|
|
9791
|
+
return false;
|
|
9792
|
+
const [start, end] = range;
|
|
9793
|
+
const seen = new Set();
|
|
9794
|
+
const add = (a, b) => {
|
|
9795
|
+
const lo = a < b ? a : b;
|
|
9796
|
+
const hi = a < b ? b : a;
|
|
9797
|
+
const k = lo * vertexCount + hi;
|
|
9798
|
+
if (seen.has(k))
|
|
9799
|
+
return;
|
|
9800
|
+
seen.add(k);
|
|
9801
|
+
edges.push(lo, hi);
|
|
9802
|
+
};
|
|
9803
|
+
for (let i = start; i + 2 < end; i += 3) {
|
|
9804
|
+
add(indices[i], indices[i + 1]);
|
|
9805
|
+
add(indices[i + 1], indices[i + 2]);
|
|
9806
|
+
add(indices[i + 2], indices[i]);
|
|
9807
|
+
}
|
|
9808
|
+
}
|
|
9809
|
+
if (edges.length === 0) {
|
|
9810
|
+
inst.wireEdges.set(key, null);
|
|
9811
|
+
return false;
|
|
9812
|
+
}
|
|
9813
|
+
const data = new Uint32Array(edges);
|
|
9814
|
+
const buffer = this.device.createBuffer({
|
|
9815
|
+
label: `wireframe edges ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
|
|
9816
|
+
size: data.byteLength,
|
|
9817
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
9818
|
+
});
|
|
9819
|
+
this.device.queue.writeBuffer(buffer, 0, data);
|
|
9820
|
+
const bindGroup = this.device.createBindGroup({
|
|
9821
|
+
label: `wireframe mesh ${inst.name}${material ? ` / ${material}` : seams ? " / seams" : ""}`,
|
|
9822
|
+
layout: this.wireframeSkinLayout,
|
|
9823
|
+
entries: [
|
|
9824
|
+
{ binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
|
|
9825
|
+
{ binding: 1, resource: { buffer: inst.vertexBuffer } },
|
|
9826
|
+
{ binding: 2, resource: { buffer: inst.jointsBuffer } },
|
|
9827
|
+
{ binding: 3, resource: { buffer: inst.weightsBuffer } },
|
|
9828
|
+
{ binding: 4, resource: { buffer } },
|
|
9829
|
+
],
|
|
9830
|
+
});
|
|
9831
|
+
inst.wireEdges.set(key, { buffer, count: edges.length, bindGroup });
|
|
9832
|
+
return true;
|
|
9833
|
+
}
|
|
9834
|
+
renderWireframe(pass) {
|
|
9835
|
+
if (!this.overlayVertices)
|
|
9836
|
+
return;
|
|
9837
|
+
const inst = this.overlayModel(this.overlayVertices.modelName);
|
|
9838
|
+
const { material, xray } = this.overlayVertices;
|
|
9839
|
+
if (!inst || !this.ensureEdgeBuffer(inst, material))
|
|
9840
|
+
return;
|
|
9841
|
+
const edges = inst.wireEdges.get(material ?? "");
|
|
9842
|
+
if (!edges)
|
|
9843
|
+
return;
|
|
9844
|
+
// The whole mesh gets its material BORDERS drawn over it — that is what
|
|
9845
|
+
// makes the view read as every material at once rather than as one body of
|
|
9846
|
+
// undifferentiated lines. A single material asked for by name is already
|
|
9847
|
+
// one material, so it needs no borders to separate it from anything.
|
|
9848
|
+
const seams = material === null && this.ensureEdgeBuffer(inst, null, true)
|
|
9849
|
+
? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
|
|
9850
|
+
: null;
|
|
9851
|
+
// A pointer over a material previews EXACTLY what clicking it would pick —
|
|
9852
|
+
// the same self-occluding reveal, layered over the section-wide view rather
|
|
9853
|
+
// than replacing it, so the rest of the mesh stays legible while one
|
|
9854
|
+
// material calls attention to itself. Meaningless once something IS
|
|
9855
|
+
// picked, since only the picked material draws at all then.
|
|
9856
|
+
const hm = this.hoverMaterial;
|
|
9857
|
+
const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null;
|
|
9858
|
+
const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null;
|
|
9859
|
+
const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName) : null;
|
|
9860
|
+
this.wireframeColorData.set(DEFAULT_VERTEX_COLOR);
|
|
9861
|
+
this.wireframeColorData[4] = this.canvas.width;
|
|
9862
|
+
this.wireframeColorData[5] = this.canvas.height;
|
|
9863
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.meshStrokePx;
|
|
9864
|
+
// The triangulation steps back only when there is something drawn over it
|
|
9865
|
+
// to step back FROM.
|
|
9866
|
+
if (seams)
|
|
9867
|
+
this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3] * OVERLAY_STYLE.meshAlpha;
|
|
9868
|
+
this.device.queue.writeBuffer(this.wireframeUniformBuffer, 0, this.wireframeColorData);
|
|
9869
|
+
if (seams) {
|
|
9870
|
+
this.wireframeColorData[3] = DEFAULT_VERTEX_COLOR[3];
|
|
9871
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx;
|
|
9872
|
+
this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData);
|
|
9873
|
+
}
|
|
9874
|
+
if (hover) {
|
|
9875
|
+
this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx;
|
|
9876
|
+
this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData);
|
|
9877
|
+
}
|
|
9878
|
+
// Both bind groups, before the FIRST draw call below, regardless of which
|
|
9879
|
+
// branch runs first — the depth prepass reads the camera from group 0 and
|
|
9880
|
+
// the skin matrices from group 1 same as the edge pass does, and every
|
|
9881
|
+
// draw in this function needs both set to SOMETHING before it runs. Each
|
|
9882
|
+
// block below is free to swap either one out for its own draws.
|
|
9883
|
+
pass.setBindGroup(0, this.wireframeBindGroup);
|
|
9884
|
+
pass.setBindGroup(1, edges.bindGroup);
|
|
9885
|
+
const bindMesh = () => {
|
|
9886
|
+
pass.setPipeline(this.wireframeDepthPipeline);
|
|
9887
|
+
pass.setVertexBuffer(0, inst.vertexBuffer);
|
|
9888
|
+
pass.setVertexBuffer(1, inst.jointsBuffer);
|
|
9889
|
+
pass.setVertexBuffer(2, inst.weightsBuffer);
|
|
9890
|
+
pass.setIndexBuffer(inst.indexBuffer, "uint32");
|
|
9891
|
+
};
|
|
9892
|
+
// The hover preview writes and draws against its OWN depth first, while the
|
|
9893
|
+
// shared depth buffer is still empty — the same trick a pick uses, run
|
|
9894
|
+
// before the base mesh below gets a chance to occlude it. The base mesh's
|
|
9895
|
+
// depth write further down repeats the SAME geometry for these faces
|
|
9896
|
+
// (identical z), so it neither disturbs this nor needs to skip them.
|
|
9897
|
+
if (hover && hoverRange && !xray) {
|
|
9898
|
+
bindMesh();
|
|
9899
|
+
pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0]);
|
|
9900
|
+
pass.setBindGroup(0, this.wireframeHoverBindGroup);
|
|
9901
|
+
pass.setBindGroup(1, hover.bindGroup);
|
|
9902
|
+
pass.setPipeline(this.wireframePipeline);
|
|
9903
|
+
pass.draw(6, hover.count / 2);
|
|
9904
|
+
pass.setBindGroup(0, this.wireframeBindGroup);
|
|
9905
|
+
pass.setBindGroup(1, edges.bindGroup);
|
|
9906
|
+
}
|
|
9907
|
+
// What writes depth is what is allowed to hide the wireframe, and that
|
|
9908
|
+
// differs between the two views.
|
|
9909
|
+
//
|
|
9910
|
+
// The whole mesh, so the far wall of a 30k-triangle body does not draw on
|
|
9911
|
+
// top of the near one — occluded is the default everywhere, Blender's edit
|
|
9912
|
+
// mode and Maya included, and seeing both walls at once is moire rather than
|
|
9913
|
+
// information.
|
|
9914
|
+
//
|
|
9915
|
+
// A PICKED material writes only its OWN faces. The question a pick asks is
|
|
9916
|
+
// where this material is, and half of it is usually under a coat; letting
|
|
9917
|
+
// the coat hide it does not answer that. Its own depth still goes in, so its
|
|
9918
|
+
// back faces stay hidden and it reads as an object instead of a haze —
|
|
9919
|
+
// which is the difference between this and turning x-ray on.
|
|
9920
|
+
const range = material !== null ? this.materialIndexRange(inst, material) : null;
|
|
9921
|
+
if (!xray) {
|
|
9922
|
+
bindMesh();
|
|
9923
|
+
if (range)
|
|
9924
|
+
pass.drawIndexed(range[1] - range[0], 1, range[0]);
|
|
9925
|
+
else
|
|
9926
|
+
pass.drawIndexed(inst.model.getIndices().length);
|
|
9927
|
+
}
|
|
9928
|
+
// Six vertices an edge, instanced.
|
|
9929
|
+
pass.setPipeline(this.wireframePipeline);
|
|
9930
|
+
pass.draw(6, edges.count / 2);
|
|
9931
|
+
if (seams) {
|
|
9932
|
+
pass.setBindGroup(0, this.wireframeSeamBindGroup);
|
|
9933
|
+
pass.setBindGroup(1, seams.bindGroup);
|
|
9934
|
+
pass.draw(6, seams.count / 2);
|
|
9935
|
+
}
|
|
9936
|
+
}
|
|
9937
|
+
/** The bone overlay's options with `selected` filled in from setSelectedBone,
|
|
9938
|
+
* so clicking a bone highlights it without the host mirroring the state. An
|
|
9939
|
+
* explicit `selected` in the options still wins. */
|
|
9940
|
+
boneOverlayOptions(modelName) {
|
|
9941
|
+
const options = this.overlayBones?.options ?? {};
|
|
9942
|
+
if (options.selected !== undefined)
|
|
9943
|
+
return options;
|
|
9944
|
+
const chosen = this.selectedBone?.modelName === modelName ? this.selectedBone.boneName : null;
|
|
9945
|
+
this.boneOptionsScratch.selected = chosen;
|
|
9946
|
+
this.boneOptionsScratch.include = options.include;
|
|
9947
|
+
return this.boneOptionsScratch;
|
|
9948
|
+
}
|
|
9949
|
+
overlayActive() {
|
|
9950
|
+
return (this.overlayLayers.size > 0 ||
|
|
9951
|
+
this.overlayBones !== null ||
|
|
9952
|
+
this.overlayBodies !== null ||
|
|
9953
|
+
this.overlayJoints !== null ||
|
|
9954
|
+
this.overlayVertices !== null);
|
|
9955
|
+
}
|
|
9956
|
+
overlayModel(name) {
|
|
9957
|
+
return this.modelInstances.get(name) ?? null;
|
|
9958
|
+
}
|
|
9959
|
+
/** The primitives a live layer would draw right now. Same list the pass uses,
|
|
9960
|
+
* so a host can show it as data, diff it, or hit-test it on the CPU. */
|
|
9961
|
+
getOverlayPrimitives(layer) {
|
|
9962
|
+
if (layer === "bones") {
|
|
9963
|
+
const inst = this.overlayBones ? this.overlayModel(this.overlayBones.modelName) : null;
|
|
9964
|
+
return inst ? boneOverlay(inst.model, this.boneOverlayOptions(inst.name)) : [];
|
|
9965
|
+
}
|
|
9966
|
+
if (layer === "rigidbodies") {
|
|
9967
|
+
const inst = this.overlayBodies ? this.overlayModel(this.overlayBodies.modelName) : null;
|
|
9968
|
+
return inst ? rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options) : [];
|
|
9969
|
+
}
|
|
9970
|
+
const inst = this.overlayJoints ? this.overlayModel(this.overlayJoints.modelName) : null;
|
|
9971
|
+
return inst ? jointOverlay(inst.model, inst.physics, this.overlayJoints.options) : [];
|
|
9972
|
+
}
|
|
9973
|
+
// The overlay's own layer: a 4x multisampled colour target, its resolve, and a
|
|
9974
|
+
// matching depth. All three are allocated the first frame an overlay is
|
|
9975
|
+
// actually on, so a scene that never shows one never pays for any of it.
|
|
9976
|
+
ensureOverlayTargets(width, height) {
|
|
9977
|
+
if (this.overlayResolveTexture && this.overlayTargetSize[0] === width && this.overlayTargetSize[1] === height) {
|
|
9978
|
+
return;
|
|
9979
|
+
}
|
|
9980
|
+
const samples = Engine.OVERLAY_SAMPLE_COUNT;
|
|
9981
|
+
this.overlayDepthTexture?.destroy();
|
|
9982
|
+
this.overlayMsaaTexture?.destroy();
|
|
9983
|
+
this.overlayResolveTexture?.destroy();
|
|
9984
|
+
this.overlayMsaaTexture = this.device.createTexture({
|
|
9985
|
+
label: "overlay msaa",
|
|
9986
|
+
size: [width, height],
|
|
9987
|
+
sampleCount: samples,
|
|
9988
|
+
format: this.presentationFormat,
|
|
9989
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
9990
|
+
});
|
|
9991
|
+
this.overlayResolveTexture = this.device.createTexture({
|
|
9992
|
+
label: "overlay resolve",
|
|
9993
|
+
size: [width, height],
|
|
9994
|
+
format: this.presentationFormat,
|
|
9995
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
9996
|
+
});
|
|
9997
|
+
this.overlayDepthTexture = this.device.createTexture({
|
|
9998
|
+
label: "overlay depth",
|
|
9999
|
+
size: [width, height],
|
|
10000
|
+
sampleCount: samples,
|
|
10001
|
+
format: "depth24plus",
|
|
10002
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
10003
|
+
});
|
|
10004
|
+
this.overlayTargetSize = [width, height];
|
|
10005
|
+
const colorAtt = this.overlayPassDescriptor.colorAttachments[0];
|
|
10006
|
+
colorAtt.view = this.overlayMsaaTexture.createView();
|
|
10007
|
+
colorAtt.resolveTarget = this.overlayResolveTexture.createView();
|
|
10008
|
+
const depthAtt = this.overlayPassDescriptor.depthStencilAttachment;
|
|
10009
|
+
depthAtt.view = this.overlayDepthTexture.createView();
|
|
10010
|
+
this.overlayCompositeBindGroup = this.device.createBindGroup({
|
|
10011
|
+
label: "overlay composite bind group",
|
|
10012
|
+
layout: this.overlayCompositeLayout,
|
|
10013
|
+
entries: [{ binding: 0, resource: this.overlayResolveTexture.createView() }],
|
|
10014
|
+
});
|
|
10015
|
+
}
|
|
10016
|
+
ensureOverlayInstanceCapacity(count) {
|
|
10017
|
+
if (this.overlayInstanceBuffer && this.overlayInstanceCapacity >= count)
|
|
10018
|
+
return;
|
|
10019
|
+
// Grow in powers of two so a skirt gaining bodies one at a time does not
|
|
10020
|
+
// reallocate once per body.
|
|
10021
|
+
let capacity = Math.max(64, this.overlayInstanceCapacity || 64);
|
|
10022
|
+
while (capacity < count)
|
|
10023
|
+
capacity *= 2;
|
|
10024
|
+
this.overlayInstanceBuffer?.destroy();
|
|
10025
|
+
this.overlayInstanceBuffer = this.device.createBuffer({
|
|
10026
|
+
label: "overlay instance buffer",
|
|
10027
|
+
size: capacity * OVERLAY_INSTANCE_FLOATS * 4,
|
|
10028
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
10029
|
+
});
|
|
10030
|
+
this.overlayInstanceCapacity = capacity;
|
|
10031
|
+
this.overlayInstanceData = new Float32Array(capacity * OVERLAY_INSTANCE_FLOATS);
|
|
10032
|
+
}
|
|
10033
|
+
// Collects every layer, groups it by shape so each shape is one instanced
|
|
10034
|
+
// draw, and runs them into the swapchain over the finished frame.
|
|
10035
|
+
renderOverlayPass(encoder, swapchainView) {
|
|
10036
|
+
if (!this.camera)
|
|
10037
|
+
return;
|
|
10038
|
+
const byShape = this.overlayByShape;
|
|
10039
|
+
for (const shape of OVERLAY_SHAPES) {
|
|
10040
|
+
const list = byShape.get(shape);
|
|
10041
|
+
if (list)
|
|
10042
|
+
list.length = 0;
|
|
10043
|
+
}
|
|
10044
|
+
let total = 0;
|
|
10045
|
+
const collect = (primitives) => {
|
|
10046
|
+
for (const primitive of primitives) {
|
|
10047
|
+
let list = byShape.get(primitive.shape);
|
|
10048
|
+
if (!list) {
|
|
10049
|
+
list = [];
|
|
10050
|
+
byShape.set(primitive.shape, list);
|
|
10051
|
+
}
|
|
10052
|
+
list.push(primitive);
|
|
10053
|
+
total++;
|
|
10054
|
+
}
|
|
10055
|
+
};
|
|
10056
|
+
for (const layer of this.overlayLayers.values())
|
|
10057
|
+
collect(layer);
|
|
10058
|
+
if (this.overlayBones) {
|
|
10059
|
+
const inst = this.overlayModel(this.overlayBones.modelName);
|
|
10060
|
+
if (inst)
|
|
10061
|
+
collect(boneOverlay(inst.model, this.boneOverlayOptions(inst.name)));
|
|
10062
|
+
}
|
|
10063
|
+
if (this.overlayBodies) {
|
|
10064
|
+
const inst = this.overlayModel(this.overlayBodies.modelName);
|
|
10065
|
+
if (inst)
|
|
10066
|
+
collect(rigidbodyOverlay(inst.model, inst.physics, this.overlayBodies.options));
|
|
10067
|
+
}
|
|
10068
|
+
if (this.overlayJoints) {
|
|
10069
|
+
const inst = this.overlayModel(this.overlayJoints.modelName);
|
|
10070
|
+
if (inst)
|
|
10071
|
+
collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options));
|
|
10072
|
+
}
|
|
10073
|
+
if (total === 0 && !this.overlayVertices)
|
|
10074
|
+
return;
|
|
10075
|
+
this.ensureOverlayInstanceCapacity(total);
|
|
10076
|
+
const data = this.overlayInstanceData;
|
|
10077
|
+
const draws = [];
|
|
10078
|
+
let written = 0;
|
|
10079
|
+
for (const shape of OVERLAY_SHAPES) {
|
|
10080
|
+
const list = byShape.get(shape);
|
|
10081
|
+
if (!list || list.length === 0)
|
|
10082
|
+
continue;
|
|
10083
|
+
draws.push({ shape, first: written, count: list.length });
|
|
10084
|
+
for (const primitive of list) {
|
|
10085
|
+
writeOverlayInstance(primitive, data, written * OVERLAY_INSTANCE_FLOATS);
|
|
10086
|
+
written++;
|
|
10087
|
+
}
|
|
10088
|
+
}
|
|
10089
|
+
this.device.queue.writeBuffer(this.overlayInstanceBuffer, 0, data.buffer, data.byteOffset, written * OVERLAY_INSTANCE_FLOATS * 4);
|
|
10090
|
+
const width = this.canvas.width;
|
|
10091
|
+
const height = this.canvas.height;
|
|
10092
|
+
this.ensureOverlayTargets(width, height);
|
|
10093
|
+
this.overlayUniformData[0] = width;
|
|
10094
|
+
this.overlayUniformData[1] = height;
|
|
10095
|
+
this.overlayUniformData[2] = Engine.OVERLAY_DASH_PERIOD_PX;
|
|
10096
|
+
this.device.queue.writeBuffer(this.overlayUniformBuffer, 0, this.overlayUniformData);
|
|
10097
|
+
const pass = encoder.beginRenderPass(this.overlayPassDescriptor);
|
|
10098
|
+
// Under everything: the mesh is the haze the rig is read against.
|
|
10099
|
+
this.renderWireframe(pass);
|
|
10100
|
+
pass.setBindGroup(0, this.overlayBindGroup);
|
|
10101
|
+
pass.setVertexBuffer(0, this.overlayVertexBuffer);
|
|
10102
|
+
pass.setVertexBuffer(1, this.overlayInstanceBuffer);
|
|
10103
|
+
// Volumes first and without depth writes, then the line work over them.
|
|
10104
|
+
for (const solid of [true, false]) {
|
|
10105
|
+
let bound = false;
|
|
10106
|
+
for (const draw of draws) {
|
|
10107
|
+
if (OVERLAY_SOLID_SHAPES.has(draw.shape) !== solid)
|
|
10108
|
+
continue;
|
|
10109
|
+
if (!bound) {
|
|
10110
|
+
pass.setPipeline(solid ? this.overlaySolidPipeline : this.overlayPipeline);
|
|
10111
|
+
bound = true;
|
|
10112
|
+
}
|
|
10113
|
+
const range = this.overlayGeometry.ranges[draw.shape];
|
|
10114
|
+
pass.draw(range.count, draw.count, range.first, draw.first);
|
|
10115
|
+
}
|
|
10116
|
+
}
|
|
10117
|
+
pass.end();
|
|
10118
|
+
// The resolved layer over the finished frame, premultiplied.
|
|
10119
|
+
const compositeAtt = this.overlayCompositePassDescriptor.colorAttachments[0];
|
|
10120
|
+
compositeAtt.view = swapchainView;
|
|
10121
|
+
const composite = encoder.beginRenderPass(this.overlayCompositePassDescriptor);
|
|
10122
|
+
composite.setPipeline(this.overlayCompositePipeline);
|
|
10123
|
+
composite.setBindGroup(0, this.overlayCompositeBindGroup);
|
|
10124
|
+
composite.draw(3);
|
|
10125
|
+
composite.end();
|
|
10126
|
+
}
|
|
9015
10127
|
// Writes gizmo transform = T(bonePos) · R(boneWorldRot) · S(GIZMO_WORLD_SIZE),
|
|
9016
10128
|
// then runs 6 triangle-list draws (3 axes + 3 rings). Local-axes mode: rotation
|
|
9017
10129
|
// aligns rings with the bone's current world orientation, so clicking a ring
|
|
@@ -9659,7 +10771,11 @@ export class Engine {
|
|
|
9659
10771
|
this.renderIdDebugPass(encoder, swapchainView);
|
|
9660
10772
|
if (this.selectedMaterial && hasModels)
|
|
9661
10773
|
this.renderSelectionPasses(encoder, swapchainView);
|
|
9662
|
-
|
|
10774
|
+
// Under the gizmo: the handles you drag stay on top of the rig you are
|
|
10775
|
+
// reading them against.
|
|
10776
|
+
if (this.overlayActive())
|
|
10777
|
+
this.renderOverlayPass(encoder, swapchainView);
|
|
10778
|
+
if (this.gizmoEnabled && this.selectedBone && hasModels)
|
|
9663
10779
|
this.renderGizmoPass(encoder, swapchainView);
|
|
9664
10780
|
const pick = this.pendingPick;
|
|
9665
10781
|
if (pick && hasModels)
|
|
@@ -10945,6 +12061,13 @@ export class Engine {
|
|
|
10945
12061
|
}
|
|
10946
12062
|
}
|
|
10947
12063
|
Engine.instance = null;
|
|
12064
|
+
/** The overlay renders multisampled into its own layer; the scene's own depth
|
|
12065
|
+
* is discarded before the composite (see the depthRead note in render), so it
|
|
12066
|
+
* could not have shared either that or the single-sample swapchain. */
|
|
12067
|
+
Engine.OVERLAY_SAMPLE_COUNT = 4;
|
|
12068
|
+
/** Dash period in device pixels — dashes are geometry, so this is only the
|
|
12069
|
+
* reference the dashedLine shape is cut against. */
|
|
12070
|
+
Engine.OVERLAY_DASH_PERIOD_PX = 8.0;
|
|
10948
12071
|
Engine.GIZMO_RING_SEGMENTS = 96;
|
|
10949
12072
|
Engine.GIZMO_RING_RADIUS = 0.8;
|
|
10950
12073
|
// Axis visible length (relative to gizmo size). Extends past ring radius so
|
|
@@ -11100,6 +12223,7 @@ Engine.TIMED_PASSES = [
|
|
|
11100
12223
|
"field",
|
|
11101
12224
|
"bloom",
|
|
11102
12225
|
"composite",
|
|
12226
|
+
"overlay",
|
|
11103
12227
|
];
|
|
11104
12228
|
// ── The floor mirror (step 7C) ──
|
|
11105
12229
|
// Half-res scene-contract attachments a mirrored draw renders into, plus the
|
|
@@ -11124,3 +12248,6 @@ Engine.CULL_ARG_WORDS = 5;
|
|
|
11124
12248
|
Engine.CULL_DRAW_CASTS_SHADOW = 1;
|
|
11125
12249
|
Engine.CULL_MODEL_VISIBLE = 1;
|
|
11126
12250
|
Engine.CULL_MODEL_RIGID = 2;
|
|
12251
|
+
/** A cache key no material can collide with — a PMX name is never empty and
|
|
12252
|
+
* never contains a NUL. */
|
|
12253
|
+
Engine.SEAM_KEY = "\u0000seams";
|