reze-engine 0.36.0 → 0.37.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/src/engine.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Camera } from "./camera"
2
2
  import { Mat4, Quat, Vec3 } from "./math"
3
- import { Model, type Material } from "./model"
3
+ import { Model, MATERIAL_MORPH_MULTIPLY, type Material } from "./model"
4
4
  import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
5
5
  import { decodeTga } from "./tga-loader"
6
6
  import { VMDLoader } from "./vmd-loader"
@@ -429,7 +429,21 @@ interface ModelInstance {
429
429
  mainPerInstanceBindGroup: GPUBindGroup
430
430
  pickPerInstanceBindGroup: GPUBindGroup
431
431
  pickDrawCalls: PickDrawCall[]
432
+ /** Environment geometry added via addStage — no physics, no IK, and it
433
+ * suppresses the built-in ground. See addStage for why each of those. */
434
+ isStage: boolean
435
+ /** A pose pass ran since the last skin-matrix upload. Always true for cast
436
+ * members; false for an idle stage, which is the point. */
437
+ skinMatricesDirty: boolean
432
438
  hiddenMaterials: Set<string>
439
+ /** Materials a material morph has driven to zero alpha. Kept apart from
440
+ * hiddenMaterials so a morph switching a part off never clobbers the user's
441
+ * own visibility toggle, and vice versa. */
442
+ morphHiddenMaterials: Set<string>
443
+ /** Material-morph targets, or null when the model has no type-8 morphs. */
444
+ materialMorphTargets: MaterialMorphTarget[] | null
445
+ /** The same targets by PMX material index, so a named offset is one lookup. */
446
+ materialMorphByIndex: Map<number, MaterialMorphTarget> | null
433
447
  physics: RezePhysics | null
434
448
  vertexBufferNeedsUpdate: boolean
435
449
  gpuMorph: GpuMorph | null
@@ -442,6 +456,30 @@ interface ModelInstance {
442
456
  styleGroupGen: Map<string, number>
443
457
  }
444
458
 
459
+ /**
460
+ * One material a type-8 morph can reach, with the uniform block as it loaded.
461
+ *
462
+ * Material morphs are re-derived from base every time a weight changes rather
463
+ * than accumulated, because weights go down as well as up and a running total
464
+ * drifts. The buffer is already COPY_DST, so this is a writeBuffer, not a
465
+ * rebuild.
466
+ */
467
+ interface MaterialMorphTarget {
468
+ /** Index into the PMX material array — what MaterialMorphOffset points at. */
469
+ pmxIndex: number
470
+ materialName: string
471
+ buffer: GPUBuffer
472
+ /** The 16-float MaterialUniforms block as createMaterialUniformBuffer wrote it. */
473
+ base: Float32Array
474
+ /** Scratch for the morphed block, so the per-change pass allocates nothing. */
475
+ work: Float32Array
476
+ /** What was last uploaded. `applyMorphs` marks weights dirty on every frame of
477
+ * any clip carrying morph tracks — i.e. every character with a face VMD — so
478
+ * without this the pass would re-upload byte-identical material blocks
479
+ * forever on behalf of a switch that never moves. */
480
+ last: Float32Array
481
+ }
482
+
445
483
  // Per-model GPU vertex-morph compute state. Present only for models with vertex morphs.
446
484
  interface GpuMorph {
447
485
  bindGroup: GPUBindGroup
@@ -3083,7 +3121,44 @@ export class Engine {
3083
3121
  return model
3084
3122
  }
3085
3123
 
3086
- async addModel(model: Model, pmxPath: string, name?: string, assetReader?: AssetReader): Promise<string> {
3124
+ /** loadModel's folder/zip path for a stage. Shares the whole prelude — only
3125
+ * what the PMX becomes differs. */
3126
+ async loadStage(
3127
+ name: string,
3128
+ options: LoadModelFromFilesOptions & { transform?: Partial<ModelTransform> },
3129
+ ): Promise<Model> {
3130
+ const { model, pmxKey, reader } = await this.openPmxFromFiles(name, options)
3131
+ await this.addStage(model, pmxKey, { name, transform: options.transform, assetReader: reader })
3132
+ return model
3133
+ }
3134
+
3135
+ /** Read a PMX out of a picked folder / expanded zip. Shared by loadModel and
3136
+ * loadStage so the file-map and path handling exist in exactly one place. */
3137
+ private async openPmxFromFiles(
3138
+ name: string,
3139
+ options: LoadModelFromFilesOptions,
3140
+ ): Promise<{ model: Model; pmxKey: string; reader: AssetReader }> {
3141
+ const pmxFile = options.pmxFile ?? findFirstPmxFileInList(options.files)
3142
+ if (!pmxFile) throw new Error("No .pmx file found in the selected folder")
3143
+ const map = fileListToMap(options.files)
3144
+ // `||`, not `??`: flat-picked files carry webkitRelativePath === "" (see
3145
+ // fileListToMap) — `""` must fall through to the filename.
3146
+ const pmxKey = normalizeAssetPath(
3147
+ (pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath || pmxFile.name,
3148
+ )
3149
+ const reader = createFileMapAssetReader(map)
3150
+ const model = await PmxLoader.loadFromReader(reader, pmxKey)
3151
+ model.setName(name)
3152
+ return { model, pmxKey, reader }
3153
+ }
3154
+
3155
+ async addModel(
3156
+ model: Model,
3157
+ pmxPath: string,
3158
+ name?: string,
3159
+ assetReader?: AssetReader,
3160
+ options?: { stage?: boolean },
3161
+ ): Promise<string> {
3087
3162
  const requested = name ?? model.name
3088
3163
  let key = requested
3089
3164
  let n = 1
@@ -3093,10 +3168,46 @@ export class Engine {
3093
3168
  const reader = assetReader ?? createFetchAssetReader()
3094
3169
  const basePath = deriveBasePathFromPmxPath(pmxPath)
3095
3170
  model.setAssetContext(reader, basePath)
3096
- await this.setupModelInstance(key, model, basePath, reader)
3171
+ await this.setupModelInstance(key, model, basePath, reader, options?.stage ?? false)
3097
3172
  return key
3098
3173
  }
3099
3174
 
3175
+ /**
3176
+ * Add a PMX as the scene's environment rather than as a character.
3177
+ *
3178
+ * A stage is the same geometry and the same materials — style groups and
3179
+ * shader graphs work on it unchanged, which is the whole reason pure-PMX
3180
+ * stages are worth supporting — but it is not a performer:
3181
+ *
3182
+ * - no physics. A stage's rigidbodies are set dressing for MMD's solver and
3183
+ * cost a full simulation island for scenery that never moves.
3184
+ * - no IK. Nothing drives a stage's chains, and solving them every frame is
3185
+ * pure waste on what is usually the heaviest mesh in the scene.
3186
+ * - no per-frame pose work while it is idle: with no clip and no morph
3187
+ * change there is nothing to recompute, so update is skipped entirely.
3188
+ * - it owns the floor. See groundIsSuppressed — the built-in ground plane
3189
+ * and a stage's own floor both sit at y=0 and z-fight.
3190
+ *
3191
+ * Bone and material morphs still apply, because that is how a stage's doors,
3192
+ * lifts and colour switches are rigged.
3193
+ */
3194
+ async addStage(
3195
+ model: Model,
3196
+ pmxPath: string,
3197
+ options?: { name?: string; transform?: Partial<ModelTransform>; assetReader?: AssetReader },
3198
+ ): Promise<string> {
3199
+ const key = await this.addModel(model, pmxPath, options?.name, options?.assetReader, { stage: true })
3200
+ if (options?.transform) this.setModelTransform(key, options.transform)
3201
+ return key
3202
+ }
3203
+
3204
+ /** True while a stage is in the scene, which is when the built-in ground plane
3205
+ * must not draw. */
3206
+ groundIsSuppressed(): boolean {
3207
+ for (const inst of this.modelInstances.values()) if (inst.isStage) return true
3208
+ return false
3209
+ }
3210
+
3100
3211
  removeModel(name: string): void {
3101
3212
  const inst = this.modelInstances.get(name)
3102
3213
  if (!inst) return
@@ -3144,12 +3255,18 @@ export class Engine {
3144
3255
  * character — its colliders won't scale; scale stages (which are typically physics-free).
3145
3256
  */
3146
3257
  setModelTransform(name: string, transform: Partial<ModelTransform>): void {
3147
- const model = this.modelInstances.get(name)?.model
3148
- if (!model) return
3258
+ const inst = this.modelInstances.get(name)
3259
+ const model = inst?.model
3260
+ if (!inst || !model) return
3149
3261
  if (transform.position) model.setPosition(transform.position)
3150
3262
  if (transform.rotation) model.setRotation(transform.rotation)
3151
3263
  if (transform.scale !== undefined) model.setScale(transform.scale)
3152
3264
  if (transform.visible !== undefined) model.setVisible(transform.visible)
3265
+ // The root transform is baked into the skin matrices, so moving a model is a
3266
+ // reason to re-upload them even though no pose pass ran. A cast member gets
3267
+ // one every frame anyway; an idle stage would otherwise never see the change
3268
+ // — which is exactly the case this API exists to serve.
3269
+ inst.skinMatricesDirty = true
3153
3270
  }
3154
3271
 
3155
3272
  /** Read a model's scene transform (for serialization into a scene descriptor). */
@@ -3316,8 +3433,22 @@ export class Engine {
3316
3433
  let physicsMs = 0
3317
3434
  this.forEachInstance((inst) => {
3318
3435
  const tAnim = performance.now()
3319
- const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
3436
+ // A stage never solves IK — nothing drives its chains — and skips the pose
3437
+ // pass entirely while it is idle. Morph changes still come through, since
3438
+ // that is the one thing a stage's controls do move.
3439
+ const stageIdle = inst.isStage && inst.model.isIdle()
3440
+ let verticesChanged = false
3441
+ if (!stageIdle) {
3442
+ verticesChanged = inst.model.update(deltaTime, inst.isStage ? false : this.ikEnabled)
3443
+ inst.skinMatricesDirty = true
3444
+ }
3320
3445
  animMs += performance.now() - tAnim
3446
+ // Material morphs ride the same weight change as vertex morphs but land in
3447
+ // uniform buffers, so they consume their own flag — a model whose only
3448
+ // morphs are material morphs never enters the GPU vertex path below.
3449
+ if (inst.materialMorphTargets && inst.model.consumeAuxMorphDirty()) {
3450
+ this.applyMaterialMorphs(inst)
3451
+ }
3321
3452
  if (inst.gpuMorph) {
3322
3453
  // GPU path: on a weight change, upload effective weights (thresholding tiny values
3323
3454
  // to 0 to match the CPU skip) and flag the compute dispatch for this frame.
@@ -3401,6 +3532,7 @@ export class Engine {
3401
3532
  model: Model,
3402
3533
  basePath: string,
3403
3534
  assetReader: AssetReader,
3535
+ isStage = false,
3404
3536
  ): Promise<void> {
3405
3537
  const vertices = model.getVertices()
3406
3538
  const skinning = model.getSkinning()
@@ -3458,7 +3590,10 @@ export class Engine {
3458
3590
  this.device.queue.writeBuffer(indexBuffer, 0, indices)
3459
3591
 
3460
3592
  const rbs = model.getRigidbodies()
3461
- const physics = rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
3593
+ // A stage never simulates, so its bodies are never built — constructing the
3594
+ // solver for the heaviest mesh in the scene and dropping it afterwards was
3595
+ // both wasted work and an invariant maintained in the wrong place.
3596
+ const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
3462
3597
  // Adopt the scene's air, or a model added mid-session would fall under
3463
3598
  // different gravity from the ones already on stage.
3464
3599
  if (physics) {
@@ -3510,7 +3645,13 @@ export class Engine {
3510
3645
  mainPerInstanceBindGroup,
3511
3646
  pickPerInstanceBindGroup,
3512
3647
  pickDrawCalls: [],
3648
+ isStage,
3649
+ // Seeded true: the bind pose has to reach the GPU once before any frame.
3650
+ skinMatricesDirty: true,
3513
3651
  hiddenMaterials: new Set(),
3652
+ morphHiddenMaterials: new Set(),
3653
+ materialMorphTargets: null,
3654
+ materialMorphByIndex: null,
3514
3655
  physics,
3515
3656
  vertexBufferNeedsUpdate: false,
3516
3657
  gpuMorph,
@@ -3803,9 +3944,25 @@ export class Engine {
3803
3944
  // 頭 bone index for the eye shader's rear-view gate (-1 when absent).
3804
3945
  const headBoneIndex = model.getSkeleton().bones.findIndex((b) => b.name === "頭")
3805
3946
 
3947
+ // Materials a type-8 morph can reach. -1 in an offset means "all of them",
3948
+ // so the presence of ANY material morph makes every material a target.
3949
+ const morphedMaterials = new Set<number>()
3950
+ for (const morph of model.getMorphing().morphs) {
3951
+ if (morph.type !== 8 || !morph.materialOffsets) continue
3952
+ for (const off of morph.materialOffsets) {
3953
+ if (off.materialIndex < 0) for (let i = 0; i < materials.length; i++) morphedMaterials.add(i)
3954
+ else morphedMaterials.add(off.materialIndex)
3955
+ }
3956
+ }
3957
+ const morphTargets: MaterialMorphTarget[] = []
3958
+
3806
3959
  let currentIndexOffset = 0
3807
3960
  let materialId = 0
3961
+ // The PMX index, which is what a material morph points at — distinct from
3962
+ // materialId, which only counts materials that produced a draw.
3963
+ let pmxMaterialIndex = -1
3808
3964
  for (const mat of materials) {
3965
+ pmxMaterialIndex++
3809
3966
  const indexCount = mat.vertexCount
3810
3967
  if (indexCount === 0) continue
3811
3968
  materialId++
@@ -3857,6 +4014,19 @@ export class Engine {
3857
4014
 
3858
4015
  const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, mat, sphereMode, headBoneIndex)
3859
4016
  inst.gpuBuffers.push(materialUniformBuffer)
4017
+ if (morphedMaterials.has(pmxMaterialIndex)) {
4018
+ const base = this.materialUniformData(mat, sphereMode, headBoneIndex)
4019
+ morphTargets.push({
4020
+ pmxIndex: pmxMaterialIndex,
4021
+ materialName: mat.name,
4022
+ buffer: materialUniformBuffer,
4023
+ base,
4024
+ work: new Float32Array(base.length),
4025
+ // Seeded from base: that is what createMaterialUniformBuffer already
4026
+ // uploaded, so an unmorphed material never writes a first time.
4027
+ last: Float32Array.from(base),
4028
+ })
4029
+ }
3860
4030
 
3861
4031
  const textureView = diffuseTexture.createView()
3862
4032
  const baseBindGroupEntries: GPUBindGroupEntry[] = [
@@ -3878,8 +4048,13 @@ export class Engine {
3878
4048
  // its own hull where it is see-through instead of us skipping it here.
3879
4049
  // Drawn interleaved right after this material's color draw (babylon-mmd's
3880
4050
  // per-mesh afterRender outline stage) — see drawMaterials.
4051
+ // Stages get no outline hulls. The inverted hull is a SECOND full draw of
4052
+ // the material's geometry, and stage PMX routinely set the edge flag across
4053
+ // every material — on the heaviest mesh in the scene that doubles the
4054
+ // geometry submitted per frame to draw cartoon outlines around
4055
+ // architecture, which is not the look anyone is after.
3881
4056
  let outline: DrawCall["outline"]
3882
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
4057
+ if (!inst.isStage && (mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3883
4058
  const materialUniformData = new Float32Array([
3884
4059
  mat.edgeColor[0],
3885
4060
  mat.edgeColor[1],
@@ -3939,16 +4114,18 @@ export class Engine {
3939
4114
  // by render-class when groups are assigned. Array.sort is stable → PMX order preserved
3940
4115
  // within a bucket.
3941
4116
  this.sortDrawCalls(inst)
4117
+
4118
+ inst.materialMorphTargets = morphTargets.length > 0 ? morphTargets : null
4119
+ inst.materialMorphByIndex = inst.materialMorphTargets
4120
+ ? new Map(morphTargets.map((t) => [t.pmxIndex, t]))
4121
+ : null
4122
+ // Seed from the current weights: a scene can open with a switch already on.
4123
+ if (inst.materialMorphTargets) this.applyMaterialMorphs(inst)
3942
4124
  }
3943
4125
 
3944
- private createMaterialUniformBuffer(
3945
- label: string,
3946
- mat: Material,
3947
- sphereMode: number,
3948
- headBoneIndex: number,
3949
- ): GPUBuffer {
3950
- // Matches the WGSL MaterialUniforms struct in common.ts — 64 bytes
3951
- // (diffuse+alpha | ambient+shininess | specular+sphereMode | headIdx+pad).
4126
+ /** Matches the WGSL MaterialUniforms struct in common.ts — 64 bytes
4127
+ * (diffuse+alpha | ambient+shininess | specular+sphereMode | headIdx+pad). */
4128
+ private materialUniformData(mat: Material, sphereMode: number, headBoneIndex: number): Float32Array {
3952
4129
  const data = new Float32Array(16)
3953
4130
  data[0] = mat.diffuse[0]
3954
4131
  data[1] = mat.diffuse[1]
@@ -3963,7 +4140,107 @@ export class Engine {
3963
4140
  data[10] = mat.specular[2]
3964
4141
  data[11] = sphereMode
3965
4142
  data[12] = headBoneIndex
3966
- return this.createUniformBuffer(`material uniform: ${label}`, data)
4143
+ return data
4144
+ }
4145
+
4146
+ private createMaterialUniformBuffer(
4147
+ label: string,
4148
+ mat: Material,
4149
+ sphereMode: number,
4150
+ headBoneIndex: number,
4151
+ ): GPUBuffer {
4152
+ return this.createUniformBuffer(
4153
+ `material uniform: ${label}`,
4154
+ this.materialUniformData(mat, sphereMode, headBoneIndex),
4155
+ )
4156
+ }
4157
+
4158
+ /**
4159
+ * Re-derive every morph-targeted material's uniform block from base and push
4160
+ * the ones that moved.
4161
+ *
4162
+ * Blend maths follow MMD (and babylon-mmd's _applyMaterialMorph): multiply
4163
+ * lerps from base toward base*morph, add offsets from base. Weight 0 must
4164
+ * therefore land exactly on base, which is why this recomputes rather than
4165
+ * accumulates.
4166
+ *
4167
+ * A material driven to zero alpha is dropped from the draw instead of being
4168
+ * written through: the opaque/transparent bucket is decided at load from the
4169
+ * PMX alpha, so an opaque draw cannot become see-through by uniform alone.
4170
+ * Full-off is the switch stage artists actually ship (帽子消失 and friends);
4171
+ * a partial fade on a material that loaded opaque still will not blend.
4172
+ */
4173
+ private applyMaterialMorphs(inst: ModelInstance): void {
4174
+ const targets = inst.materialMorphTargets
4175
+ if (!targets) return
4176
+ const morphs = inst.model.getMorphing().morphs
4177
+ const weights = inst.model.getEffectiveMorphWeights()
4178
+
4179
+ for (const target of targets) {
4180
+ target.work.set(target.base)
4181
+ }
4182
+
4183
+ for (let i = 0; i < morphs.length; i++) {
4184
+ const w = weights[i]
4185
+ if (w < 0.0001) continue
4186
+ const morph = morphs[i]
4187
+ if (morph.type !== 8 || !morph.materialOffsets) continue
4188
+ for (const off of morph.materialOffsets) {
4189
+ // A named material resolves in one lookup. Only the -1 wildcard walks
4190
+ // every target — and once any offset uses it, every material in the
4191
+ // model is a target, so scanning per offset would be quadratic on the
4192
+ // large stages this is meant to serve.
4193
+ const hit = off.materialIndex >= 0 ? inst.materialMorphByIndex?.get(off.materialIndex) : undefined
4194
+ const affected = off.materialIndex >= 0 ? (hit ? [hit] : []) : targets
4195
+ for (const target of affected) {
4196
+ const d = target.work
4197
+ if (off.offsetType === MATERIAL_MORPH_MULTIPLY) {
4198
+ d[0] += (d[0] * off.diffuse[0] - d[0]) * w
4199
+ d[1] += (d[1] * off.diffuse[1] - d[1]) * w
4200
+ d[2] += (d[2] * off.diffuse[2] - d[2]) * w
4201
+ d[3] += (d[3] * off.diffuse[3] - d[3]) * w
4202
+ d[4] += (d[4] * off.ambient[0] - d[4]) * w
4203
+ d[5] += (d[5] * off.ambient[1] - d[5]) * w
4204
+ d[6] += (d[6] * off.ambient[2] - d[6]) * w
4205
+ d[7] += (d[7] * off.shininess - d[7]) * w
4206
+ d[8] += (d[8] * off.specular[0] - d[8]) * w
4207
+ d[9] += (d[9] * off.specular[1] - d[9]) * w
4208
+ d[10] += (d[10] * off.specular[2] - d[10]) * w
4209
+ } else {
4210
+ d[0] += off.diffuse[0] * w
4211
+ d[1] += off.diffuse[1] * w
4212
+ d[2] += off.diffuse[2] * w
4213
+ d[3] += off.diffuse[3] * w
4214
+ d[4] += off.ambient[0] * w
4215
+ d[5] += off.ambient[1] * w
4216
+ d[6] += off.ambient[2] * w
4217
+ d[7] += off.shininess * w
4218
+ d[8] += off.specular[0] * w
4219
+ d[9] += off.specular[1] * w
4220
+ d[10] += off.specular[2] * w
4221
+ }
4222
+ }
4223
+ }
4224
+ }
4225
+
4226
+ inst.morphHiddenMaterials.clear()
4227
+ for (const target of targets) {
4228
+ const d = target.work
4229
+ // Alpha is the switch; clamp the rest so a stacked multiply cannot send a
4230
+ // colour negative and light the material from the inside.
4231
+ for (let k = 0; k < 11; k++) if (d[k] < 0) d[k] = 0
4232
+ if (d[3] < 0.0001) inst.morphHiddenMaterials.add(target.materialName)
4233
+ let changed = false
4234
+ for (let k = 0; k < 11; k++) {
4235
+ if (d[k] !== target.last[k]) {
4236
+ changed = true
4237
+ break
4238
+ }
4239
+ }
4240
+ if (!changed) continue
4241
+ target.last.set(d)
4242
+ this.device.queue.writeBuffer(target.buffer, 0, d as ArrayBufferView<ArrayBuffer>)
4243
+ }
3967
4244
  }
3968
4245
 
3969
4246
  private createUniformBuffer(label: string, data: Float32Array | Uint32Array): GPUBuffer {
@@ -3977,7 +4254,7 @@ export class Engine {
3977
4254
  }
3978
4255
 
3979
4256
  private shouldRenderDrawCall(inst: ModelInstance, drawCall: DrawCall): boolean {
3980
- return !inst.hiddenMaterials.has(drawCall.materialName)
4257
+ return !inst.hiddenMaterials.has(drawCall.materialName) && !inst.morphHiddenMaterials.has(drawCall.materialName)
3981
4258
  }
3982
4259
 
3983
4260
  private async createTextureFromLogicalPath(inst: ModelInstance, logicalPath: string): Promise<GPUTexture | null> {
@@ -4115,6 +4392,11 @@ export class Engine {
4115
4392
  }
4116
4393
 
4117
4394
  private renderGround(pass: GPURenderPassEncoder) {
4395
+ // A stage brings its own floor. Both sit at y=0, so drawing the built-in
4396
+ // plane underneath produces z-fighting across the whole scene — enforced
4397
+ // here rather than left to callers, who cannot see the conflict coming.
4398
+ // hasGround is left alone: remove the stage and the ground comes back.
4399
+ if (this.groundIsSuppressed()) return
4118
4400
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
4119
4401
  pass.setPipeline(this.groundShadowPipeline)
4120
4402
  pass.setVertexBuffer(0, this.groundVertexBuffer)
@@ -5462,6 +5744,10 @@ export class Engine {
5462
5744
 
5463
5745
  private updateSkinMatrices() {
5464
5746
  this.forEachInstance((inst) => {
5747
+ // Only a pose pass can change these, and an idle stage did not run one —
5748
+ // re-uploading bones×64 bytes for scenery that never moves is the one
5749
+ // per-frame cost a stage would otherwise still pay in full.
5750
+ if (!inst.skinMatricesDirty) return
5465
5751
  const skinMatrices = inst.model.getSkinMatrices()
5466
5752
  this.device.queue.writeBuffer(
5467
5753
  inst.skinMatrixBuffer,
@@ -5470,6 +5756,7 @@ export class Engine {
5470
5756
  skinMatrices.byteOffset,
5471
5757
  skinMatrices.byteLength,
5472
5758
  )
5759
+ inst.skinMatricesDirty = false
5473
5760
  })
5474
5761
  }
5475
5762
 
package/src/index.ts CHANGED
@@ -52,7 +52,17 @@ export { BODY_GRAPH } from "./graph/presets/body"
52
52
  export { STOCKINGS_GRAPH } from "./graph/presets/stockings"
53
53
  export { EYE_GRAPH } from "./graph/presets/eye"
54
54
  export { FACE_GRAPH } from "./graph/presets/face"
55
- export { Model, type ClipEventInfo } from "./model"
55
+ export {
56
+ Model,
57
+ MATERIAL_MORPH_MULTIPLY,
58
+ MATERIAL_MORPH_ADD,
59
+ type ClipEventInfo,
60
+ type Morph,
61
+ type Morphing,
62
+ type BoneMorphOffset,
63
+ type MaterialMorphOffset,
64
+ type UvMorphOffset,
65
+ } from "./model"
56
66
  export { Vec3, Quat, Mat4, easeInOut, type EulerOrder } from "./math"
57
67
  export type {
58
68
  AnimationClip,