reze-engine 0.36.1 → 0.38.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/camera.ts CHANGED
@@ -4,6 +4,11 @@ import type { CameraPose } from "./camera-animation"
4
4
  /** Far cap / zoom limit; large enough for wide shots without clipping distant ground */
5
5
  const FAR_CAP = 8000
6
6
  const FAR_MIN = 200
7
+ /** The character-framing value the cloth z-fighting fix settled on — never go below it. */
8
+ const NEAR_MIN = 0.5
9
+ /** ~40cm in MMD units. Past this the near plane would start clipping geometry a
10
+ * user has deliberately pushed the camera up against. */
11
+ const NEAR_MAX = 5
7
12
 
8
13
  export class Camera {
9
14
  alpha: number
@@ -226,8 +231,29 @@ export class Camera {
226
231
  this.far = Math.min(FAR_CAP, Math.max(FAR_MIN, this.radius * 12 + margin))
227
232
  }
228
233
 
234
+ /**
235
+ * Near plane scales with how far out you are framing.
236
+ *
237
+ * A 24-bit non-reversed depth buffer spends most of its precision just past
238
+ * the near plane, so what survives at distance is governed by the far/near
239
+ * ratio. 0.5 was chosen for character framing and fixed coplanar cloth there
240
+ * (see `near` above) — but a stage puts the floor 5–20× further out, where the
241
+ * same ratio leaves neighbouring surfaces sharing a depth value. They then win
242
+ * and lose per pixel as the camera turns, which reads as flickering bands and
243
+ * cracks across the floor.
244
+ *
245
+ * Tying near to the framing keeps character shots byte-identical (radius ~26
246
+ * still lands on the 0.5 floor) and buys back an order of magnitude once you
247
+ * pull out to see a whole stage. Capped so it can never clip something the
248
+ * user is deliberately close to.
249
+ */
250
+ private updateNearFromRadius(): void {
251
+ this.near = Math.min(NEAR_MAX, Math.max(NEAR_MIN, this.radius / 50))
252
+ }
253
+
229
254
  getProjectionMatrix(): Mat4 {
230
255
  this.updateFarFromRadius()
256
+ this.updateNearFromRadius()
231
257
  Mat4.perspectiveInto(this._projMat.values, this.fov, this.aspect, this.near, this.far)
232
258
  return this._projMat
233
259
  }
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
@@ -2871,6 +2909,15 @@ export class Engine {
2871
2909
  setCameraBeta(b: number): void {
2872
2910
  this.camera.beta = b
2873
2911
  }
2912
+ /** Vertical field of view in radians (default π/4). While a camera VMD
2913
+ * drives the view it animates fov itself; the orbit value set here is
2914
+ * restored when the VMD releases the camera. */
2915
+ getCameraFov(): number {
2916
+ return this.camera.fov
2917
+ }
2918
+ setCameraFov(fov: number): void {
2919
+ this.camera.fov = fov
2920
+ }
2874
2921
 
2875
2922
  // Step 5: Create lighting buffers
2876
2923
  private setupLighting() {
@@ -3083,7 +3130,44 @@ export class Engine {
3083
3130
  return model
3084
3131
  }
3085
3132
 
3086
- async addModel(model: Model, pmxPath: string, name?: string, assetReader?: AssetReader): Promise<string> {
3133
+ /** loadModel's folder/zip path for a stage. Shares the whole prelude — only
3134
+ * what the PMX becomes differs. */
3135
+ async loadStage(
3136
+ name: string,
3137
+ options: LoadModelFromFilesOptions & { transform?: Partial<ModelTransform> },
3138
+ ): Promise<Model> {
3139
+ const { model, pmxKey, reader } = await this.openPmxFromFiles(name, options)
3140
+ await this.addStage(model, pmxKey, { name, transform: options.transform, assetReader: reader })
3141
+ return model
3142
+ }
3143
+
3144
+ /** Read a PMX out of a picked folder / expanded zip. Shared by loadModel and
3145
+ * loadStage so the file-map and path handling exist in exactly one place. */
3146
+ private async openPmxFromFiles(
3147
+ name: string,
3148
+ options: LoadModelFromFilesOptions,
3149
+ ): Promise<{ model: Model; pmxKey: string; reader: AssetReader }> {
3150
+ const pmxFile = options.pmxFile ?? findFirstPmxFileInList(options.files)
3151
+ if (!pmxFile) throw new Error("No .pmx file found in the selected folder")
3152
+ const map = fileListToMap(options.files)
3153
+ // `||`, not `??`: flat-picked files carry webkitRelativePath === "" (see
3154
+ // fileListToMap) — `""` must fall through to the filename.
3155
+ const pmxKey = normalizeAssetPath(
3156
+ (pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath || pmxFile.name,
3157
+ )
3158
+ const reader = createFileMapAssetReader(map)
3159
+ const model = await PmxLoader.loadFromReader(reader, pmxKey)
3160
+ model.setName(name)
3161
+ return { model, pmxKey, reader }
3162
+ }
3163
+
3164
+ async addModel(
3165
+ model: Model,
3166
+ pmxPath: string,
3167
+ name?: string,
3168
+ assetReader?: AssetReader,
3169
+ options?: { stage?: boolean },
3170
+ ): Promise<string> {
3087
3171
  const requested = name ?? model.name
3088
3172
  let key = requested
3089
3173
  let n = 1
@@ -3093,10 +3177,46 @@ export class Engine {
3093
3177
  const reader = assetReader ?? createFetchAssetReader()
3094
3178
  const basePath = deriveBasePathFromPmxPath(pmxPath)
3095
3179
  model.setAssetContext(reader, basePath)
3096
- await this.setupModelInstance(key, model, basePath, reader)
3180
+ await this.setupModelInstance(key, model, basePath, reader, options?.stage ?? false)
3097
3181
  return key
3098
3182
  }
3099
3183
 
3184
+ /**
3185
+ * Add a PMX as the scene's environment rather than as a character.
3186
+ *
3187
+ * A stage is the same geometry and the same materials — style groups and
3188
+ * shader graphs work on it unchanged, which is the whole reason pure-PMX
3189
+ * stages are worth supporting — but it is not a performer:
3190
+ *
3191
+ * - no physics. A stage's rigidbodies are set dressing for MMD's solver and
3192
+ * cost a full simulation island for scenery that never moves.
3193
+ * - no IK. Nothing drives a stage's chains, and solving them every frame is
3194
+ * pure waste on what is usually the heaviest mesh in the scene.
3195
+ * - no per-frame pose work while it is idle: with no clip and no morph
3196
+ * change there is nothing to recompute, so update is skipped entirely.
3197
+ * - it owns the floor. See groundIsSuppressed — the built-in ground plane
3198
+ * and a stage's own floor both sit at y=0 and z-fight.
3199
+ *
3200
+ * Bone and material morphs still apply, because that is how a stage's doors,
3201
+ * lifts and colour switches are rigged.
3202
+ */
3203
+ async addStage(
3204
+ model: Model,
3205
+ pmxPath: string,
3206
+ options?: { name?: string; transform?: Partial<ModelTransform>; assetReader?: AssetReader },
3207
+ ): Promise<string> {
3208
+ const key = await this.addModel(model, pmxPath, options?.name, options?.assetReader, { stage: true })
3209
+ if (options?.transform) this.setModelTransform(key, options.transform)
3210
+ return key
3211
+ }
3212
+
3213
+ /** True while a stage is in the scene, which is when the built-in ground plane
3214
+ * must not draw. */
3215
+ groundIsSuppressed(): boolean {
3216
+ for (const inst of this.modelInstances.values()) if (inst.isStage) return true
3217
+ return false
3218
+ }
3219
+
3100
3220
  removeModel(name: string): void {
3101
3221
  const inst = this.modelInstances.get(name)
3102
3222
  if (!inst) return
@@ -3144,12 +3264,18 @@ export class Engine {
3144
3264
  * character — its colliders won't scale; scale stages (which are typically physics-free).
3145
3265
  */
3146
3266
  setModelTransform(name: string, transform: Partial<ModelTransform>): void {
3147
- const model = this.modelInstances.get(name)?.model
3148
- if (!model) return
3267
+ const inst = this.modelInstances.get(name)
3268
+ const model = inst?.model
3269
+ if (!inst || !model) return
3149
3270
  if (transform.position) model.setPosition(transform.position)
3150
3271
  if (transform.rotation) model.setRotation(transform.rotation)
3151
3272
  if (transform.scale !== undefined) model.setScale(transform.scale)
3152
3273
  if (transform.visible !== undefined) model.setVisible(transform.visible)
3274
+ // The root transform is baked into the skin matrices, so moving a model is a
3275
+ // reason to re-upload them even though no pose pass ran. A cast member gets
3276
+ // one every frame anyway; an idle stage would otherwise never see the change
3277
+ // — which is exactly the case this API exists to serve.
3278
+ inst.skinMatricesDirty = true
3153
3279
  }
3154
3280
 
3155
3281
  /** Read a model's scene transform (for serialization into a scene descriptor). */
@@ -3316,8 +3442,22 @@ export class Engine {
3316
3442
  let physicsMs = 0
3317
3443
  this.forEachInstance((inst) => {
3318
3444
  const tAnim = performance.now()
3319
- const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
3445
+ // A stage never solves IK — nothing drives its chains — and skips the pose
3446
+ // pass entirely while it is idle. Morph changes still come through, since
3447
+ // that is the one thing a stage's controls do move.
3448
+ const stageIdle = inst.isStage && inst.model.isIdle()
3449
+ let verticesChanged = false
3450
+ if (!stageIdle) {
3451
+ verticesChanged = inst.model.update(deltaTime, inst.isStage ? false : this.ikEnabled)
3452
+ inst.skinMatricesDirty = true
3453
+ }
3320
3454
  animMs += performance.now() - tAnim
3455
+ // Material morphs ride the same weight change as vertex morphs but land in
3456
+ // uniform buffers, so they consume their own flag — a model whose only
3457
+ // morphs are material morphs never enters the GPU vertex path below.
3458
+ if (inst.materialMorphTargets && inst.model.consumeAuxMorphDirty()) {
3459
+ this.applyMaterialMorphs(inst)
3460
+ }
3321
3461
  if (inst.gpuMorph) {
3322
3462
  // GPU path: on a weight change, upload effective weights (thresholding tiny values
3323
3463
  // to 0 to match the CPU skip) and flag the compute dispatch for this frame.
@@ -3401,6 +3541,7 @@ export class Engine {
3401
3541
  model: Model,
3402
3542
  basePath: string,
3403
3543
  assetReader: AssetReader,
3544
+ isStage = false,
3404
3545
  ): Promise<void> {
3405
3546
  const vertices = model.getVertices()
3406
3547
  const skinning = model.getSkinning()
@@ -3458,7 +3599,10 @@ export class Engine {
3458
3599
  this.device.queue.writeBuffer(indexBuffer, 0, indices)
3459
3600
 
3460
3601
  const rbs = model.getRigidbodies()
3461
- const physics = rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
3602
+ // A stage never simulates, so its bodies are never built — constructing the
3603
+ // solver for the heaviest mesh in the scene and dropping it afterwards was
3604
+ // both wasted work and an invariant maintained in the wrong place.
3605
+ const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
3462
3606
  // Adopt the scene's air, or a model added mid-session would fall under
3463
3607
  // different gravity from the ones already on stage.
3464
3608
  if (physics) {
@@ -3510,7 +3654,13 @@ export class Engine {
3510
3654
  mainPerInstanceBindGroup,
3511
3655
  pickPerInstanceBindGroup,
3512
3656
  pickDrawCalls: [],
3657
+ isStage,
3658
+ // Seeded true: the bind pose has to reach the GPU once before any frame.
3659
+ skinMatricesDirty: true,
3513
3660
  hiddenMaterials: new Set(),
3661
+ morphHiddenMaterials: new Set(),
3662
+ materialMorphTargets: null,
3663
+ materialMorphByIndex: null,
3514
3664
  physics,
3515
3665
  vertexBufferNeedsUpdate: false,
3516
3666
  gpuMorph,
@@ -3803,9 +3953,25 @@ export class Engine {
3803
3953
  // 頭 bone index for the eye shader's rear-view gate (-1 when absent).
3804
3954
  const headBoneIndex = model.getSkeleton().bones.findIndex((b) => b.name === "頭")
3805
3955
 
3956
+ // Materials a type-8 morph can reach. -1 in an offset means "all of them",
3957
+ // so the presence of ANY material morph makes every material a target.
3958
+ const morphedMaterials = new Set<number>()
3959
+ for (const morph of model.getMorphing().morphs) {
3960
+ if (morph.type !== 8 || !morph.materialOffsets) continue
3961
+ for (const off of morph.materialOffsets) {
3962
+ if (off.materialIndex < 0) for (let i = 0; i < materials.length; i++) morphedMaterials.add(i)
3963
+ else morphedMaterials.add(off.materialIndex)
3964
+ }
3965
+ }
3966
+ const morphTargets: MaterialMorphTarget[] = []
3967
+
3806
3968
  let currentIndexOffset = 0
3807
3969
  let materialId = 0
3970
+ // The PMX index, which is what a material morph points at — distinct from
3971
+ // materialId, which only counts materials that produced a draw.
3972
+ let pmxMaterialIndex = -1
3808
3973
  for (const mat of materials) {
3974
+ pmxMaterialIndex++
3809
3975
  const indexCount = mat.vertexCount
3810
3976
  if (indexCount === 0) continue
3811
3977
  materialId++
@@ -3857,6 +4023,19 @@ export class Engine {
3857
4023
 
3858
4024
  const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, mat, sphereMode, headBoneIndex)
3859
4025
  inst.gpuBuffers.push(materialUniformBuffer)
4026
+ if (morphedMaterials.has(pmxMaterialIndex)) {
4027
+ const base = this.materialUniformData(mat, sphereMode, headBoneIndex)
4028
+ morphTargets.push({
4029
+ pmxIndex: pmxMaterialIndex,
4030
+ materialName: mat.name,
4031
+ buffer: materialUniformBuffer,
4032
+ base,
4033
+ work: new Float32Array(base.length),
4034
+ // Seeded from base: that is what createMaterialUniformBuffer already
4035
+ // uploaded, so an unmorphed material never writes a first time.
4036
+ last: Float32Array.from(base),
4037
+ })
4038
+ }
3860
4039
 
3861
4040
  const textureView = diffuseTexture.createView()
3862
4041
  const baseBindGroupEntries: GPUBindGroupEntry[] = [
@@ -3878,8 +4057,13 @@ export class Engine {
3878
4057
  // its own hull where it is see-through instead of us skipping it here.
3879
4058
  // Drawn interleaved right after this material's color draw (babylon-mmd's
3880
4059
  // per-mesh afterRender outline stage) — see drawMaterials.
4060
+ // Stages get no outline hulls. The inverted hull is a SECOND full draw of
4061
+ // the material's geometry, and stage PMX routinely set the edge flag across
4062
+ // every material — on the heaviest mesh in the scene that doubles the
4063
+ // geometry submitted per frame to draw cartoon outlines around
4064
+ // architecture, which is not the look anyone is after.
3881
4065
  let outline: DrawCall["outline"]
3882
- if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
4066
+ if (!inst.isStage && (mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
3883
4067
  const materialUniformData = new Float32Array([
3884
4068
  mat.edgeColor[0],
3885
4069
  mat.edgeColor[1],
@@ -3939,16 +4123,18 @@ export class Engine {
3939
4123
  // by render-class when groups are assigned. Array.sort is stable → PMX order preserved
3940
4124
  // within a bucket.
3941
4125
  this.sortDrawCalls(inst)
4126
+
4127
+ inst.materialMorphTargets = morphTargets.length > 0 ? morphTargets : null
4128
+ inst.materialMorphByIndex = inst.materialMorphTargets
4129
+ ? new Map(morphTargets.map((t) => [t.pmxIndex, t]))
4130
+ : null
4131
+ // Seed from the current weights: a scene can open with a switch already on.
4132
+ if (inst.materialMorphTargets) this.applyMaterialMorphs(inst)
3942
4133
  }
3943
4134
 
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).
4135
+ /** Matches the WGSL MaterialUniforms struct in common.ts — 64 bytes
4136
+ * (diffuse+alpha | ambient+shininess | specular+sphereMode | headIdx+pad). */
4137
+ private materialUniformData(mat: Material, sphereMode: number, headBoneIndex: number): Float32Array {
3952
4138
  const data = new Float32Array(16)
3953
4139
  data[0] = mat.diffuse[0]
3954
4140
  data[1] = mat.diffuse[1]
@@ -3963,7 +4149,107 @@ export class Engine {
3963
4149
  data[10] = mat.specular[2]
3964
4150
  data[11] = sphereMode
3965
4151
  data[12] = headBoneIndex
3966
- return this.createUniformBuffer(`material uniform: ${label}`, data)
4152
+ return data
4153
+ }
4154
+
4155
+ private createMaterialUniformBuffer(
4156
+ label: string,
4157
+ mat: Material,
4158
+ sphereMode: number,
4159
+ headBoneIndex: number,
4160
+ ): GPUBuffer {
4161
+ return this.createUniformBuffer(
4162
+ `material uniform: ${label}`,
4163
+ this.materialUniformData(mat, sphereMode, headBoneIndex),
4164
+ )
4165
+ }
4166
+
4167
+ /**
4168
+ * Re-derive every morph-targeted material's uniform block from base and push
4169
+ * the ones that moved.
4170
+ *
4171
+ * Blend maths follow MMD (and babylon-mmd's _applyMaterialMorph): multiply
4172
+ * lerps from base toward base*morph, add offsets from base. Weight 0 must
4173
+ * therefore land exactly on base, which is why this recomputes rather than
4174
+ * accumulates.
4175
+ *
4176
+ * A material driven to zero alpha is dropped from the draw instead of being
4177
+ * written through: the opaque/transparent bucket is decided at load from the
4178
+ * PMX alpha, so an opaque draw cannot become see-through by uniform alone.
4179
+ * Full-off is the switch stage artists actually ship (帽子消失 and friends);
4180
+ * a partial fade on a material that loaded opaque still will not blend.
4181
+ */
4182
+ private applyMaterialMorphs(inst: ModelInstance): void {
4183
+ const targets = inst.materialMorphTargets
4184
+ if (!targets) return
4185
+ const morphs = inst.model.getMorphing().morphs
4186
+ const weights = inst.model.getEffectiveMorphWeights()
4187
+
4188
+ for (const target of targets) {
4189
+ target.work.set(target.base)
4190
+ }
4191
+
4192
+ for (let i = 0; i < morphs.length; i++) {
4193
+ const w = weights[i]
4194
+ if (w < 0.0001) continue
4195
+ const morph = morphs[i]
4196
+ if (morph.type !== 8 || !morph.materialOffsets) continue
4197
+ for (const off of morph.materialOffsets) {
4198
+ // A named material resolves in one lookup. Only the -1 wildcard walks
4199
+ // every target — and once any offset uses it, every material in the
4200
+ // model is a target, so scanning per offset would be quadratic on the
4201
+ // large stages this is meant to serve.
4202
+ const hit = off.materialIndex >= 0 ? inst.materialMorphByIndex?.get(off.materialIndex) : undefined
4203
+ const affected = off.materialIndex >= 0 ? (hit ? [hit] : []) : targets
4204
+ for (const target of affected) {
4205
+ const d = target.work
4206
+ if (off.offsetType === MATERIAL_MORPH_MULTIPLY) {
4207
+ d[0] += (d[0] * off.diffuse[0] - d[0]) * w
4208
+ d[1] += (d[1] * off.diffuse[1] - d[1]) * w
4209
+ d[2] += (d[2] * off.diffuse[2] - d[2]) * w
4210
+ d[3] += (d[3] * off.diffuse[3] - d[3]) * w
4211
+ d[4] += (d[4] * off.ambient[0] - d[4]) * w
4212
+ d[5] += (d[5] * off.ambient[1] - d[5]) * w
4213
+ d[6] += (d[6] * off.ambient[2] - d[6]) * w
4214
+ d[7] += (d[7] * off.shininess - d[7]) * w
4215
+ d[8] += (d[8] * off.specular[0] - d[8]) * w
4216
+ d[9] += (d[9] * off.specular[1] - d[9]) * w
4217
+ d[10] += (d[10] * off.specular[2] - d[10]) * w
4218
+ } else {
4219
+ d[0] += off.diffuse[0] * w
4220
+ d[1] += off.diffuse[1] * w
4221
+ d[2] += off.diffuse[2] * w
4222
+ d[3] += off.diffuse[3] * w
4223
+ d[4] += off.ambient[0] * w
4224
+ d[5] += off.ambient[1] * w
4225
+ d[6] += off.ambient[2] * w
4226
+ d[7] += off.shininess * w
4227
+ d[8] += off.specular[0] * w
4228
+ d[9] += off.specular[1] * w
4229
+ d[10] += off.specular[2] * w
4230
+ }
4231
+ }
4232
+ }
4233
+ }
4234
+
4235
+ inst.morphHiddenMaterials.clear()
4236
+ for (const target of targets) {
4237
+ const d = target.work
4238
+ // Alpha is the switch; clamp the rest so a stacked multiply cannot send a
4239
+ // colour negative and light the material from the inside.
4240
+ for (let k = 0; k < 11; k++) if (d[k] < 0) d[k] = 0
4241
+ if (d[3] < 0.0001) inst.morphHiddenMaterials.add(target.materialName)
4242
+ let changed = false
4243
+ for (let k = 0; k < 11; k++) {
4244
+ if (d[k] !== target.last[k]) {
4245
+ changed = true
4246
+ break
4247
+ }
4248
+ }
4249
+ if (!changed) continue
4250
+ target.last.set(d)
4251
+ this.device.queue.writeBuffer(target.buffer, 0, d as ArrayBufferView<ArrayBuffer>)
4252
+ }
3967
4253
  }
3968
4254
 
3969
4255
  private createUniformBuffer(label: string, data: Float32Array | Uint32Array): GPUBuffer {
@@ -3977,7 +4263,7 @@ export class Engine {
3977
4263
  }
3978
4264
 
3979
4265
  private shouldRenderDrawCall(inst: ModelInstance, drawCall: DrawCall): boolean {
3980
- return !inst.hiddenMaterials.has(drawCall.materialName)
4266
+ return !inst.hiddenMaterials.has(drawCall.materialName) && !inst.morphHiddenMaterials.has(drawCall.materialName)
3981
4267
  }
3982
4268
 
3983
4269
  private async createTextureFromLogicalPath(inst: ModelInstance, logicalPath: string): Promise<GPUTexture | null> {
@@ -4115,6 +4401,11 @@ export class Engine {
4115
4401
  }
4116
4402
 
4117
4403
  private renderGround(pass: GPURenderPassEncoder) {
4404
+ // A stage brings its own floor. Both sit at y=0, so drawing the built-in
4405
+ // plane underneath produces z-fighting across the whole scene — enforced
4406
+ // here rather than left to callers, who cannot see the conflict coming.
4407
+ // hasGround is left alone: remove the stage and the ground comes back.
4408
+ if (this.groundIsSuppressed()) return
4118
4409
  if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
4119
4410
  pass.setPipeline(this.groundShadowPipeline)
4120
4411
  pass.setVertexBuffer(0, this.groundVertexBuffer)
@@ -5462,6 +5753,10 @@ export class Engine {
5462
5753
 
5463
5754
  private updateSkinMatrices() {
5464
5755
  this.forEachInstance((inst) => {
5756
+ // Only a pose pass can change these, and an idle stage did not run one —
5757
+ // re-uploading bones×64 bytes for scenery that never moves is the one
5758
+ // per-frame cost a stage would otherwise still pay in full.
5759
+ if (!inst.skinMatricesDirty) return
5465
5760
  const skinMatrices = inst.model.getSkinMatrices()
5466
5761
  this.device.queue.writeBuffer(
5467
5762
  inst.skinMatrixBuffer,
@@ -5470,6 +5765,7 @@ export class Engine {
5470
5765
  skinMatrices.byteOffset,
5471
5766
  skinMatrices.byteLength,
5472
5767
  )
5768
+ inst.skinMatricesDirty = false
5473
5769
  })
5474
5770
  }
5475
5771
 
package/src/index.ts CHANGED
@@ -52,7 +52,18 @@ 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 RootMotionProfile,
61
+ type Morph,
62
+ type Morphing,
63
+ type BoneMorphOffset,
64
+ type MaterialMorphOffset,
65
+ type UvMorphOffset,
66
+ } from "./model"
56
67
  export { Vec3, Quat, Mat4, easeInOut, type EulerOrder } from "./math"
57
68
  export type {
58
69
  AnimationClip,
package/src/locomotion.ts CHANGED
@@ -291,6 +291,34 @@ export class LocomotionController {
291
291
  this.yaw = yaw
292
292
  }
293
293
 
294
+ /** Teleport PLUS a hard reset of every transient motion commitment: any
295
+ * in-flight authored stop/turn (whose stored start position would otherwise
296
+ * keep driving the root from where it began), the exit ghost, speed and
297
+ * momentum, the heading-hold gate, and the held inputs. For handing the
298
+ * root back after an externally-driven action (a root-motion clip state):
299
+ * wherever the action ended is simply where she now stands. */
300
+ reset(x: number, y: number, z: number, yaw = 0): void {
301
+ this.position.setXYZ(x, y, z)
302
+ this.yaw = wrapAngle(yaw)
303
+ this.dirX = Math.sin(this.yaw)
304
+ this.dirZ = Math.cos(this.yaw)
305
+ this.speedLevel = 0
306
+ this.recentSpeed = 0
307
+ this.headingHold = 0
308
+ this.headingDirX = 0
309
+ this.headingDirY = 0
310
+ this.gaitPhase = 0
311
+ this.stopping = null
312
+ this.turning = null
313
+ this.runTurning = null
314
+ this.exitGhost = null
315
+ this.inputX = 0
316
+ this.inputY = 0
317
+ this.inputSprint = false
318
+ this.inputForward = 0
319
+ this.inputSteer = 0
320
+ }
321
+
294
322
  /** Strafe mode: hold the body at this world yaw (a camera forward, a lock-on target)
295
323
  * while setMove's vector drives the directional strafe ring — requires
296
324
  * clips.strafeRun. null returns to turn-toward-movement. */