reze-engine 0.55.1 → 0.55.3

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/src/engine.ts CHANGED
@@ -380,6 +380,24 @@ export type ModelTransform = {
380
380
  visible: boolean
381
381
  }
382
382
 
383
+ /** How a model rides another — MMD's 外部親 (outside parent). See setModelParent. */
384
+ export type ModelAttachment = {
385
+ /** Model key of the parent. */
386
+ model: string
387
+ /** Bone on the parent. A name the parent's rig lacks rides the parent's root. */
388
+ bone: string
389
+ }
390
+
391
+ /** The attachment as the engine keeps it: the record plus the two matrices the
392
+ * per-frame placement needs, allocated once per attach rather than per frame. */
393
+ type Attachment = ModelAttachment & {
394
+ /** Where the child's origin sits in the bone's space (position · rotation). */
395
+ offsetMatrix: Float32Array
396
+ /** The root the child is posed under this frame. Handed to Model.setRootParent
397
+ * BY REFERENCE and refilled every frame; see placeAttached. */
398
+ rootMatrix: Float32Array
399
+ }
400
+
383
401
  type SunOptions = {
384
402
  /** Linear color of the sun lamp (Blender: Light > Color). */
385
403
  color?: Vec3
@@ -778,6 +796,18 @@ interface ModelInstance {
778
796
  * plane into isStage would have made adding a title graphic delete the ground.
779
797
  */
780
798
  isPlane: boolean
799
+ /**
800
+ * A PROP: a PMX object a character holds or wears — a microphone, a fan, a
801
+ * sword. The third answer beside stage and plane. It keeps what a cast member
802
+ * has that scenery does not (physics, outlines, its own clip) and drops what
803
+ * makes one a performer: no effect subject id, no seeding of the scene clock,
804
+ * no bone picking. Like a card it leaves the floor alone. See addProp.
805
+ */
806
+ isProp: boolean
807
+ /** Who this model hangs from, or null. Any model can: a prop by design, a
808
+ * card for a sign in her hand, a second character for a mascot on her
809
+ * shoulder. See setModelParent. */
810
+ parent: Attachment | null
781
811
  /** This card's texture is rewritten every frame, so it is allocated with no
782
812
  * mip chain — rebuilding one per frame is a pass per level per card, and is
783
813
  * what a moving card was mostly costing. See setPlaneFrame. */
@@ -1510,6 +1540,13 @@ export class Engine {
1510
1540
  // tone (bottom). Stand-in for MMD's toon01–10.bmp, which we can't ship.
1511
1541
  private defaultToonRampTexture!: GPUTexture
1512
1542
  private groundShadowPipeline!: GPURenderPipeline
1543
+ /** The soft-edge variant, built the first time a scene asks for one. Null while
1544
+ * no scene has, which is most of them — a pipeline nobody draws with is still
1545
+ * a shader compile at load. */
1546
+ private groundShadowSoftPipeline: GPURenderPipeline | null = null
1547
+ /** How the ground's own pipeline is chosen, kept beside the uniform that sets
1548
+ * it so the draw does not have to read the buffer back. */
1549
+ private groundSoft = false
1513
1550
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
1514
1551
  private outlinePipeline!: GPURenderPipeline
1515
1552
  private selectedMaterial: { modelName: string; materialName: string } | null = null
@@ -4963,7 +5000,7 @@ export class Engine {
4963
5000
  for (const inst of this.modelInstances.values()) {
4964
5001
  // Neither a stage nor a plane is a performer, so neither is a subject an
4965
5002
  // effect can follow.
4966
- if (!inst.model.visible || inst.isStage || inst.isPlane) continue
5003
+ if (!inst.model.visible || inst.isStage || inst.isPlane || inst.isProp) continue
4967
5004
  const model = inst.model
4968
5005
  const matrices = model.getWorldMatrices()
4969
5006
  if (matrices.length === 0) continue
@@ -6068,14 +6105,9 @@ export class Engine {
6068
6105
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6069
6106
  ],
6070
6107
  })
6071
- const groundShadowShader = this.device.createShaderModule({
6072
- label: "ground shadow",
6073
- code: groundShaderWgsl(),
6074
- })
6075
- this.groundShadowPipeline = this.createRenderPipeline({
6108
+ this.groundShadowPipelineDesc = {
6076
6109
  label: "ground shadow pipeline",
6077
6110
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.groundShadowBindGroupLayout] }),
6078
- shaderModule: groundShadowShader,
6079
6111
  // Slot 0 only — the ground has no skinning, and declaring the full
6080
6112
  // 3-slot layout while renderGround binds one buffer is a WebGPU
6081
6113
  // validation error that invalidates the whole command buffer.
@@ -6083,7 +6115,8 @@ export class Engine {
6083
6115
  fragmentTargets: sceneTargetsFor("ground", this.sceneFormats),
6084
6116
  cullMode: "back",
6085
6117
  depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
6086
- })
6118
+ }
6119
+ this.groundShadowPipeline = this.buildGroundPipeline(false)
6087
6120
 
6088
6121
  // Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
6089
6122
  this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
@@ -8005,7 +8038,7 @@ export class Engine {
8005
8038
  // is first in insertion order and was seeding this clock with its own
8006
8039
  // permanent zero. In a scene with a stage, a camera VMD therefore sampled
8007
8040
  // frame 0 forever and the shot never moved.
8008
- if (inst.isStage || inst.isPlane) continue
8041
+ if (inst.isStage || inst.isPlane || inst.isProp) continue
8009
8042
  const p = inst.model.getAnimationProgress()
8010
8043
  if (p.playing || p.paused) return p.current
8011
8044
  // Otherwise the first cast member that actually HAS a clip: one still at
@@ -8457,6 +8490,16 @@ export class Engine {
8457
8490
  return model
8458
8491
  }
8459
8492
 
8493
+ /** loadModel's folder/zip path for a prop. See addProp. */
8494
+ async loadProp(
8495
+ name: string,
8496
+ options: LoadModelFromFilesOptions & { transform?: Partial<ModelTransform> },
8497
+ ): Promise<Model> {
8498
+ const { model, pmxKey, reader } = await this.openPmxFromFiles(name, options)
8499
+ await this.addProp(model, pmxKey, { name, transform: options.transform, assetReader: reader })
8500
+ return model
8501
+ }
8502
+
8460
8503
  /** Read a PMX out of a picked folder / expanded zip. Shared by loadModel and
8461
8504
  * loadStage so the file-map and path handling exist in exactly one place. */
8462
8505
  private async openPmxFromFiles(
@@ -8482,7 +8525,7 @@ export class Engine {
8482
8525
  pmxPath: string,
8483
8526
  name?: string,
8484
8527
  assetReader?: AssetReader,
8485
- options?: { stage?: boolean; plane?: boolean; dynamic?: boolean },
8528
+ options?: { stage?: boolean; plane?: boolean; dynamic?: boolean; prop?: boolean },
8486
8529
  ): Promise<string> {
8487
8530
  const requested = name ?? model.name
8488
8531
  let key = requested
@@ -8501,6 +8544,7 @@ export class Engine {
8501
8544
  options?.stage ?? false,
8502
8545
  options?.plane ?? false,
8503
8546
  options?.dynamic ?? false,
8547
+ options?.prop ?? false,
8504
8548
  )
8505
8549
  return key
8506
8550
  }
@@ -8534,6 +8578,28 @@ export class Engine {
8534
8578
  return key
8535
8579
  }
8536
8580
 
8581
+ /**
8582
+ * Add a PMX as a PROP: an object a character holds or wears rather than a
8583
+ * performer or the environment. A microphone, a fan, a sword, an umbrella.
8584
+ *
8585
+ * It keeps what makes a held thing look right — physics (the charm on a phone
8586
+ * strap swings), toon outlines, its own clip if it has one — and drops what
8587
+ * makes a model a cast member: no effect subject id, so a silhouette effect
8588
+ * still outlines HER and not the mic; no seeding of the scene clock; no bone
8589
+ * picking in the pose editor. Like a card it leaves the built-in ground
8590
+ * alone, which is the one thing a stage does that a prop must not. Usually
8591
+ * hung from a bone with setModelParent, though it can stand on its own.
8592
+ */
8593
+ async addProp(
8594
+ model: Model,
8595
+ pmxPath: string,
8596
+ options?: { name?: string; transform?: Partial<ModelTransform>; assetReader?: AssetReader },
8597
+ ): Promise<string> {
8598
+ const key = await this.addModel(model, pmxPath, options?.name, options?.assetReader, { prop: true })
8599
+ if (options?.transform) this.setModelTransform(key, options.transform)
8600
+ return key
8601
+ }
8602
+
8537
8603
  /**
8538
8604
  * Put a picture in the scene as a flat card.
8539
8605
  *
@@ -8789,8 +8855,14 @@ export class Engine {
8789
8855
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
8790
8856
  for (const install of inst.styleGroups.values()) this.destroyInstall(install)
8791
8857
  this.modelInstances.delete(name)
8858
+ // Whatever hung from it stands on its own now, at identity — the same
8859
+ // place a detach leaves a model.
8860
+ for (const other of this.modelInstances.values()) {
8861
+ if (other.parent?.model === name) this.setModelParent(other.name, null)
8862
+ }
8792
8863
  this.cullListDirty = true
8793
8864
  this.bundlesDirty = true
8865
+ this.updateOrderDirty = true
8794
8866
  }
8795
8867
 
8796
8868
  getModelNames(): string[] {
@@ -8801,6 +8873,136 @@ export class Engine {
8801
8873
  return this.modelInstances.get(name)?.model ?? null
8802
8874
  }
8803
8875
 
8876
+ /**
8877
+ * Hang a model from a bone of another — MMD's 外部親 (outside parent).
8878
+ *
8879
+ * Every frame, after the parent has been posed and simulated, the child's
8880
+ * root bones are placed at that bone with `offset` composed on top, and only
8881
+ * then is the child posed itself. The placement enters through the child's
8882
+ * BONES rather than its model transform (Model.setRootParent): physics runs
8883
+ * in model space, so a root moved by the transform would have a charm on a
8884
+ * phone strap feel gravity swing with the hand, while a root moved by the
8885
+ * skeleton keeps down down. It also puts the child's own clip on top of the
8886
+ * ride, as MMD does — an umbrella that spins keeps spinning in the hand.
8887
+ *
8888
+ * While attached the child's position and rotation are held at identity and
8889
+ * setModelTransform ignores them; scale still applies, and is folded into
8890
+ * the placement so the offset stays in the parent's units. Detaching leaves
8891
+ * the model at identity until the host places it again.
8892
+ *
8893
+ * A bone the parent lacks rides the parent's root, which is what camera
8894
+ * follow does with an unknown name. Returns false for an unknown model, a
8895
+ * missing parent, or a model asked to ride itself.
8896
+ */
8897
+ setModelParent(
8898
+ name: string,
8899
+ parent: string | null,
8900
+ bone = "全ての親",
8901
+ offset?: { position?: Vec3; rotation?: Quat },
8902
+ ): boolean {
8903
+ const inst = this.modelInstances.get(name)
8904
+ if (!inst) return false
8905
+ if (parent === null) {
8906
+ if (inst.parent) {
8907
+ inst.parent = null
8908
+ inst.model.setRootParent(null)
8909
+ inst.skinMatricesDirty = true
8910
+ this.updateOrderDirty = true
8911
+ }
8912
+ return true
8913
+ }
8914
+ if (parent === name || !this.modelInstances.has(parent)) return false
8915
+ const p = offset?.position ?? new Vec3(0, 0, 0)
8916
+ const r = offset?.rotation ?? Quat.identity()
8917
+ const offsetMatrix = inst.parent?.offsetMatrix ?? new Float32Array(16)
8918
+ Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, 1, offsetMatrix)
8919
+ // Identity until the first frame fills it: a physics reset between now and
8920
+ // then re-poses the model, and a zero matrix would fold it to a point.
8921
+ const rootMatrix = inst.parent?.rootMatrix ?? new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
8922
+ inst.parent = { model: parent, bone, offsetMatrix, rootMatrix }
8923
+ inst.model.setPosition(new Vec3(0, 0, 0))
8924
+ inst.model.setRotation(Quat.identity())
8925
+ inst.model.setRootParent(rootMatrix)
8926
+ inst.skinMatricesDirty = true
8927
+ this.updateOrderDirty = true
8928
+ return true
8929
+ }
8930
+
8931
+ /** What a model hangs from, or null. */
8932
+ getModelParent(name: string): ModelAttachment | null {
8933
+ const att = this.modelInstances.get(name)?.parent
8934
+ return att ? { model: att.model, bone: att.bone } : null
8935
+ }
8936
+
8937
+ /**
8938
+ * The root an attached model is posed under this frame: the parent's
8939
+ * placement, its bone as posed and simulated, then the offset.
8940
+ *
8941
+ * The translation is divided by the child's own scale. The skin bake
8942
+ * multiplies the child's scale back on outside the skeleton, and a uniform
8943
+ * scale commutes with the rotation, so this is exactly what lands the child
8944
+ * at the bone in world units while its mesh still comes out scaled.
8945
+ */
8946
+ private placeAttached(inst: ModelInstance): void {
8947
+ const att = inst.parent!
8948
+ const parent = this.modelInstances.get(att.model)
8949
+ if (!parent) {
8950
+ this.setModelParent(inst.name, null)
8951
+ return
8952
+ }
8953
+ const out = att.rootMatrix
8954
+ const tmp = this.attachScratch
8955
+ const root = parent.model.getRootMatrix()
8956
+ const bone = parent.model.getBoneWorldMatrix(att.bone)
8957
+ if (bone) {
8958
+ Mat4.multiplyArrays(root, 0, bone, 0, tmp, 0)
8959
+ Mat4.multiplyArrays(tmp, 0, att.offsetMatrix, 0, out, 0)
8960
+ } else {
8961
+ Mat4.multiplyArrays(root, 0, att.offsetMatrix, 0, out, 0)
8962
+ }
8963
+ const s = inst.model.scale
8964
+ if (s > 0 && s !== 1) {
8965
+ const k = 1 / s
8966
+ out[12] *= k
8967
+ out[13] *= k
8968
+ out[14] *= k
8969
+ }
8970
+ }
8971
+ private readonly attachScratch = new Float32Array(16)
8972
+
8973
+ /** Instances in pose order: a parent before every model hanging from it, so
8974
+ * a child reads the bone as posed and simulated THIS frame. Insertion order
8975
+ * otherwise. Rebuilt when a model is added, removed or re-parented. */
8976
+ private updateOrder: ModelInstance[] = []
8977
+ private updateOrderDirty = true
8978
+ private instancesInUpdateOrder(): ModelInstance[] {
8979
+ if (!this.updateOrderDirty) return this.updateOrder
8980
+ const placed = new Set<string>()
8981
+ const order: ModelInstance[] = []
8982
+ let pending = Array.from(this.modelInstances.values())
8983
+ while (pending.length > 0) {
8984
+ const rest: ModelInstance[] = []
8985
+ for (const inst of pending) {
8986
+ const p = inst.parent?.model
8987
+ if (p === undefined || placed.has(p) || !this.modelInstances.has(p)) {
8988
+ order.push(inst)
8989
+ placed.add(inst.name)
8990
+ } else rest.push(inst)
8991
+ }
8992
+ if (rest.length === pending.length) {
8993
+ // A cycle: nothing left can go first. They pose in insertion order and
8994
+ // each reads the other's previous frame, which is the best a cycle gets.
8995
+ console.warn(`[reze] attachment cycle: ${rest.map((r) => r.name).join(" → ")}`)
8996
+ order.push(...rest)
8997
+ break
8998
+ }
8999
+ pending = rest
9000
+ }
9001
+ this.updateOrder = order
9002
+ this.updateOrderDirty = false
9003
+ return order
9004
+ }
9005
+
8804
9006
  /**
8805
9007
  * Place a model in the scene — position, rotation, uniform scale, visibility. The
8806
9008
  * transform is a root offset baked into skinning (moves the whole rig), so it composes
@@ -8812,8 +9014,11 @@ export class Engine {
8812
9014
  const inst = this.modelInstances.get(name)
8813
9015
  const model = inst?.model
8814
9016
  if (!inst || !model) return
8815
- if (transform.position) model.setPosition(transform.position)
8816
- if (transform.rotation) model.setRotation(transform.rotation)
9017
+ // An attached model is placed by its parent's bone; its own position and
9018
+ // rotation are held at identity so the ride is the whole placement (see
9019
+ // setModelParent). Scale and visibility are still its own.
9020
+ if (transform.position && !inst.parent) model.setPosition(transform.position)
9021
+ if (transform.rotation && !inst.parent) model.setRotation(transform.rotation)
8817
9022
  if (transform.scale !== undefined) model.setScale(transform.scale)
8818
9023
  if (transform.visible !== undefined) model.setVisible(transform.visible)
8819
9024
  // The root transform is baked into the skin matrices, so moving a model is a
@@ -8934,7 +9139,7 @@ export class Engine {
8934
9139
 
8935
9140
  for (const inst of this.modelInstances.values()) {
8936
9141
  if (options.modelName !== undefined && inst.name !== options.modelName) continue
8937
- if (inst.isStage || inst.isPlane) continue
9142
+ if (inst.isStage || inst.isPlane || inst.isProp) continue
8938
9143
  const bones = inst.model.getSkeleton().bones
8939
9144
  this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch)
8940
9145
  const pos = this.bonePickScratch
@@ -9001,7 +9206,7 @@ export class Engine {
9001
9206
 
9002
9207
  for (const inst of this.modelInstances.values()) {
9003
9208
  if (options.modelName !== undefined && inst.name !== options.modelName) continue
9004
- if (inst.isStage || inst.isPlane) continue
9209
+ if (inst.isStage || inst.isPlane || inst.isProp) continue
9005
9210
  const model = inst.model
9006
9211
  const { positions } = model.getGeometry()
9007
9212
  const count = positions.length / 3
@@ -9192,6 +9397,59 @@ export class Engine {
9192
9397
  else inst.hiddenMaterials.add(materialName)
9193
9398
  }
9194
9399
 
9400
+ /**
9401
+ * Push a colour/shading edit straight into a material's own uniform buffer —
9402
+ * the same block createMaterialUniformBuffer wrote at load, offset for
9403
+ * offset. A single write per call, on the fields that actually moved, so
9404
+ * dragging one slider does not touch the other eleven.
9405
+ *
9406
+ * UNGROUPED materials only. A grouped material renders through its style
9407
+ * group's own compiled graph (setupPipelines' neutral/DEFAULT_GRAPH path is
9408
+ * what reads this buffer, and a grouped material never runs it) — the call
9409
+ * still writes the bytes, they are simply never sampled, which would look
9410
+ * like the edit silently failing. Callers check groupsByModel first and this
9411
+ * quietly no-ops rather than assume that check was made, since a document
9412
+ * edit landing to the WRONG channel (the group's own colour input) would be
9413
+ * a worse failure than one that does nothing.
9414
+ *
9415
+ * Structural fields — anything that changes which draw bucket a material is
9416
+ * in, or which texture it binds — are NOT here: alpha crossing the 1.0
9417
+ * opaque/transparent line, edge on/off, a texture swap, all need the draw
9418
+ * list or the bind group rebuilt, not a uniform write. Those get their own
9419
+ * call when something needs them.
9420
+ */
9421
+ setMaterialUniforms(
9422
+ modelName: string,
9423
+ materialName: string,
9424
+ patch: {
9425
+ diffuse?: readonly [number, number, number, number]
9426
+ specular?: readonly [number, number, number]
9427
+ specularPower?: number
9428
+ ambient?: readonly [number, number, number]
9429
+ },
9430
+ ): boolean {
9431
+ const inst = this.modelInstances.get(modelName)
9432
+ if (!inst) return false
9433
+ const materials = inst.model.getMaterials()
9434
+ const index = materials.findIndex((m) => m.name === materialName)
9435
+ if (index < 0) return false
9436
+ const buffer = inst.materialUniformBuffers[index]
9437
+ if (!buffer) return false
9438
+ if (patch.diffuse) {
9439
+ this.device.queue.writeBuffer(buffer, 0, new Float32Array(patch.diffuse))
9440
+ }
9441
+ if (patch.ambient) {
9442
+ this.device.queue.writeBuffer(buffer, 16, new Float32Array(patch.ambient))
9443
+ }
9444
+ if (patch.specularPower !== undefined) {
9445
+ this.device.queue.writeBuffer(buffer, 28, new Float32Array([patch.specularPower]))
9446
+ }
9447
+ if (patch.specular) {
9448
+ this.device.queue.writeBuffer(buffer, 32, new Float32Array(patch.specular))
9449
+ }
9450
+ return true
9451
+ }
9452
+
9195
9453
  isMaterialVisible(modelName: string, materialName: string): boolean {
9196
9454
  const inst = this.modelInstances.get(modelName)
9197
9455
  return inst ? !inst.hiddenMaterials.has(materialName) : false
@@ -9318,12 +9576,18 @@ export class Engine {
9318
9576
  private updateInstances(deltaTime: number): void {
9319
9577
  let animMs = 0
9320
9578
  let physicsMs = 0
9321
- this.forEachInstance((inst) => {
9579
+ for (const inst of this.instancesInUpdateOrder()) {
9322
9580
  const tAnim = performance.now()
9581
+ // An attached model is placed from its parent's bone as posed and
9582
+ // simulated THIS frame — the order guarantees the parent came first —
9583
+ // and only then posed itself, so its clip and physics ride the placement.
9584
+ const attached = inst.parent !== null
9585
+ if (attached) this.placeAttached(inst)
9323
9586
  // A stage never solves IK — nothing drives its chains — and skips the pose
9324
9587
  // pass entirely while it is idle. Morph changes still come through, since
9325
- // that is the one thing a stage's controls do move.
9326
- const stageIdle = (inst.isStage || inst.isPlane) && inst.model.isIdle()
9588
+ // that is the one thing a stage's controls do move. A prop idles the same
9589
+ // way while it stands on its own; hung from a hand it moves every frame.
9590
+ const stageIdle = (inst.isStage || inst.isPlane || inst.isProp) && !attached && inst.model.isIdle()
9327
9591
  let verticesChanged = false
9328
9592
  if (!stageIdle) {
9329
9593
  verticesChanged = inst.model.update(deltaTime, inst.isStage || inst.isPlane ? false : this.ikEnabled)
@@ -9367,7 +9631,7 @@ export class Engine {
9367
9631
  physicsMs += performance.now() - tPhys
9368
9632
  }
9369
9633
  if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
9370
- })
9634
+ }
9371
9635
  this.frameAnimMsRaw = animMs
9372
9636
  this.framePhysicsMsRaw = physicsMs
9373
9637
  const EMA = 0.1
@@ -10324,6 +10588,7 @@ export class Engine {
10324
10588
  isStage = false,
10325
10589
  isPlane = false,
10326
10590
  dynamicTexture = false,
10591
+ isProp = false,
10327
10592
  ): Promise<void> {
10328
10593
  const vertices = model.getVertices()
10329
10594
  const skinning = model.getSkinning()
@@ -10469,6 +10734,8 @@ export class Engine {
10469
10734
  pickDrawCalls: [],
10470
10735
  isStage,
10471
10736
  isPlane,
10737
+ isProp,
10738
+ parent: null,
10472
10739
  dynamicTexture,
10473
10740
  // Seeded true: the bind pose has to reach the GPU once before any frame.
10474
10741
  skinMatricesDirty: true,
@@ -10497,6 +10764,7 @@ export class Engine {
10497
10764
  this.modelInstances.set(name, inst)
10498
10765
  this.cullListDirty = true
10499
10766
  this.bundlesDirty = true
10767
+ this.updateOrderDirty = true
10500
10768
  }
10501
10769
 
10502
10770
  // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
@@ -10642,6 +10910,28 @@ export class Engine {
10642
10910
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices)
10643
10911
  }
10644
10912
 
10913
+ /** Everything about the ground's pipeline except which shadow variant it
10914
+ * compiles, so the two are built from one description and cannot drift. */
10915
+ private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
10916
+
10917
+ private buildGroundPipeline(soft: boolean): GPURenderPipeline {
10918
+ return this.createRenderPipeline({
10919
+ ...this.groundShadowPipelineDesc,
10920
+ label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
10921
+ shaderModule: this.device.createShaderModule({
10922
+ label: soft ? "ground shadow (soft)" : "ground shadow",
10923
+ code: groundShaderWgsl(soft),
10924
+ }),
10925
+ })
10926
+ }
10927
+
10928
+ /** Built on the first frame that actually needs it. A shader compile costs
10929
+ * load time, and the overwhelming majority of scenes never soften a shadow. */
10930
+ private ensureGroundSoftPipeline(): GPURenderPipeline {
10931
+ if (!this.groundShadowSoftPipeline) this.groundShadowSoftPipeline = this.buildGroundPipeline(true)
10932
+ return this.groundShadowSoftPipeline
10933
+ }
10934
+
10645
10935
  private createShadowGroundResources(opts: {
10646
10936
  diffuseColor: Vec3
10647
10937
  fadeStart: number
@@ -10699,6 +10989,9 @@ export class Engine {
10699
10989
  // gb[18] — shadow edge softness. Was padding; the shader reads it as the
10700
10990
  // Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
10701
10991
  gb[18] = Math.min(Math.max(shadowSoftness, 0), 1)
10992
+ // Which variant the draw picks. Zero is the sharp shader, which is the one
10993
+ // that existed before softness did.
10994
+ this.groundSoft = gb[18] > 0
10702
10995
  // gb[17] — does the FAR cascade hold anything?
10703
10996
  //
10704
10997
  // It holds something only when a stage is loaded; that is what it exists for
@@ -11398,7 +11691,7 @@ export class Engine {
11398
11691
  // hasGround is left alone: remove the stage and the ground comes back.
11399
11692
  if (this.groundIsSuppressed()) return
11400
11693
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
11401
- pass.setPipeline(this.groundShadowPipeline)
11694
+ pass.setPipeline(this.groundSoft ? this.ensureGroundSoftPipeline() : this.groundShadowPipeline)
11402
11695
  pass.setVertexBuffer(0, this.groundVertexBuffer)
11403
11696
  pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
11404
11697
  pass.setBindGroup(0, this.groundDrawCall.bindGroup)
@@ -13854,7 +14147,7 @@ export class Engine {
13854
14147
  // serves.
13855
14148
  let n = 0
13856
14149
  this.forEachInstance((inst) => {
13857
- if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane) return
14150
+ if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp) return
13858
14151
  const m = inst.model
13859
14152
  // The model transform is only where the model was PLACED. A motion moves
13860
14153
  // the character by animating bones, so an effect anchored to the
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type MaterialPreset,
15
15
  type MaterialPresetMap,
16
16
  type ModelTransform,
17
+ type ModelAttachment,
17
18
  type GizmoDragEvent,
18
19
  type GizmoDragCallback,
19
20
  type GizmoDragKind,
package/src/model.ts CHANGED
@@ -315,6 +315,38 @@ export class Model {
315
315
  this._visible = visible
316
316
  }
317
317
 
318
+ /** Hang the rig's root bones from `matrix` (model space, column-major 16
319
+ * floats), or from nothing. The engine drives this every frame for an
320
+ * attached model; the matrix is read at the next world pass, not copied. */
321
+ setRootParent(matrix: Float32Array | null): void {
322
+ this.rootParent = matrix
323
+ if (matrix) {
324
+ const root = this.skeleton.bones.find((b) => b.parentIndex < 0)
325
+ this.primaryRootBind = root ? [root.bindTranslation[0], root.bindTranslation[1], root.bindTranslation[2]] : [0, 0, 0]
326
+ }
327
+ }
328
+
329
+ getRootParent(): Float32Array | null {
330
+ return this.rootParent
331
+ }
332
+
333
+ /** The placement matrix (position · rotation · scale) the skin bake composes
334
+ * onto every bone. Rebuilt lazily, the way getSkinMatrices does it. */
335
+ getRootMatrix(): Float32Array {
336
+ this.refreshRootMatrix()
337
+ return this.rootMatrixValues
338
+ }
339
+
340
+ private refreshRootMatrix(): void {
341
+ if (!this.rootMatrixDirty) return
342
+ const p = this._position, r = this._rotation, s = this._scale
343
+ Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, s, this.rootMatrixValues)
344
+ this.rootIsIdentity =
345
+ p.x === 0 && p.y === 0 && p.z === 0 &&
346
+ r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1 && s === 1
347
+ this.rootMatrixDirty = false
348
+ }
349
+
318
350
  private vertexData: Float32Array<ArrayBuffer>
319
351
  private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
320
352
  private vertexCount: number
@@ -383,6 +415,21 @@ export class Model {
383
415
  private rootMatrixValues: Float32Array = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])
384
416
  private rootMatrixDirty: boolean = false
385
417
  private rootIsIdentity: boolean = true
418
+ /** What every parentless bone hangs from — MMD's 外部親 (outside parent).
419
+ * Model space, so the pose pipeline, IK and physics all see it: a prop bound
420
+ * to a hand is moved by its BONES, and gravity keeps pointing down while the
421
+ * hand tilts. Null is the ordinary rig, rooted at the model's own origin.
422
+ * Written per frame by the engine from the parent's posed bone; see
423
+ * Engine.setModelParent. */
424
+ private rootParent: Float32Array | null = null
425
+ /** The bind position of the PRIMARY root — the first parentless bone, 全ての親
426
+ * by convention. Under a root parent that bone sits exactly ON the parent
427
+ * bone, as MMD's 外部親 does, so its bind position is taken off every root's
428
+ * local matrix: the primary lands at the parent, the other roots keep their
429
+ * layout relative to it. Without this the MODEL ORIGIN went to the parent
430
+ * bone, and a prop rigged with its one bone at the mesh's centre hung that
431
+ * far away from the hand. */
432
+ private primaryRootBind: [number, number, number] = [0, 0, 0]
386
433
 
387
434
  // Cached skin matrices array to avoid allocations in getSkinMatrices
388
435
  private skinMatricesArray?: Float32Array
@@ -984,6 +1031,14 @@ export class Model {
984
1031
  return this.clipApplySuspended
985
1032
  }
986
1033
 
1034
+ /** A bone's posed matrix — model space, column-major, the live array rather
1035
+ * than a copy. Null for a name this rig does not have. */
1036
+ getBoneWorldMatrix(boneName: string): Float32Array | null {
1037
+ const idx = this.runtimeSkeleton.nameIndex[boneName]
1038
+ if (idx === undefined || idx < 0) return null
1039
+ return this.runtimeSkeleton.worldMatrices[idx].values
1040
+ }
1041
+
987
1042
  // World bone origin (world matrix col3); unknown name → null
988
1043
  getBoneWorldPosition(boneName: string): Vec3 | null {
989
1044
  const idx = this.runtimeSkeleton.nameIndex[boneName]
@@ -1238,14 +1293,7 @@ export class Model {
1238
1293
  const skinMatrices = this.skinMatricesArray
1239
1294
 
1240
1295
  // Rebuild root matrix + cache identity-shortcut flag only when pos/rot changed.
1241
- if (this.rootMatrixDirty) {
1242
- const p = this._position, r = this._rotation, s = this._scale
1243
- Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, s, this.rootMatrixValues)
1244
- this.rootIsIdentity =
1245
- p.x === 0 && p.y === 0 && p.z === 0 &&
1246
- r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1 && s === 1
1247
- this.rootMatrixDirty = false
1248
- }
1296
+ this.refreshRootMatrix()
1249
1297
 
1250
1298
  if (this.rootIsIdentity) {
1251
1299
  // skinMatrix = worldMatrix × inverseBindMatrix
@@ -2644,6 +2692,12 @@ export class Model {
2644
2692
  if (b.parentIndex >= 0) {
2645
2693
  const parentMat = worldMats[b.parentIndex]
2646
2694
  Mat4.multiplyArrays(parentMat.values, 0, localMVals, 0, worldMat.values, 0)
2695
+ } else if (this.rootParent) {
2696
+ const pr = this.primaryRootBind
2697
+ localMVals[12] -= pr[0]
2698
+ localMVals[13] -= pr[1]
2699
+ localMVals[14] -= pr[2]
2700
+ Mat4.multiplyArrays(this.rootParent, 0, localMVals, 0, worldMat.values, 0)
2647
2701
  } else {
2648
2702
  worldMat.values.set(localMVals)
2649
2703
  }
@@ -2801,6 +2855,8 @@ export class Model {
2801
2855
  // leaving every other bone — the simulated ones above all — untouched.
2802
2856
  const order = subset ?? this.deformOrder
2803
2857
  const count = subset ? subset.length : boneCount
2858
+ const rootParent = this.rootParent
2859
+ const primaryRootBind = this.primaryRootBind
2804
2860
  const override = this.appendRotOverride
2805
2861
  const overrideSet = this.appendRotOverrideSet
2806
2862
  for (let k = 0; k < count; k++) {
@@ -2875,6 +2931,13 @@ export class Model {
2875
2931
  if (b.parentIndex >= 0) {
2876
2932
  const parentMat = worldMats[b.parentIndex]
2877
2933
  Mat4.multiplyArrays(parentMat.values, 0, localMVals, 0, worldMat.values, 0)
2934
+ } else if (rootParent) {
2935
+ // The primary root's bind position comes off every root, so the primary
2936
+ // sits ON the parent bone. See primaryRootBind.
2937
+ localMVals[12] -= primaryRootBind[0]
2938
+ localMVals[13] -= primaryRootBind[1]
2939
+ localMVals[14] -= primaryRootBind[2]
2940
+ Mat4.multiplyArrays(rootParent, 0, localMVals, 0, worldMat.values, 0)
2878
2941
  } else {
2879
2942
  worldMat.values.set(localMVals)
2880
2943
  }