From cea7ae690c06ec99c311efdc5dde2ac9e14f826c Mon Sep 17 00:00:00 2001 From: Domingos Martins Date: Sun, 8 Nov 2026 11:39:05 -0300 Subject: [PATCH] Divine Staff of Archangel: put the glints along the staff, not across Without a skeleton, bone 0 now uses the mesh's own frame (a node created only when that frame is not the identity, and disposed with the sprites). A model with a skeleton, or with its mesh on the root, is unchanged. The test builds a turned one-bone model and checks the glints lie along the shaft; it fails without the change. --- src/controllers/actors/weaponSprites.test.ts | 33 +++++++++++++++++++ src/controllers/actors/weaponSprites.ts | 34 +++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/controllers/actors/weaponSprites.test.ts b/src/controllers/actors/weaponSprites.test.ts index 93f63b26f..e1eaf5c63 100644 --- a/src/controllers/actors/weaponSprites.test.ts +++ b/src/controllers/actors/weaponSprites.test.ts @@ -93,6 +93,39 @@ describe('a held weapon\'s sprites (RenderLinkObject, ZzzCharacter.cpp:7027-8386 const sprites = new WeaponSprites(scene, object, [{ texture: 'impact03', bone: 0, scale: 0.75, light: [1, 1, 1] }]); expect(sprites.nodes[0].parent).toBe(root); }); + + it('ride bone 0 where a one-bone model keeps it, on its mesh node (the Divine Staff\'s row of glints)', () => { + const root = new Mesh('staff', scene); + const model = new TransformNode('model_staff', scene); + model.parent = root; + const shaft = new Mesh('shaft', scene); + shaft.parent = model; + shaft.position.set(0, -0.525, 0.409); + shaft.rotation.set(0, -Math.PI / 2, -Math.PI / 2); + const object = new ModelObject(scene); + object.load({ mesh: root, skeleton: null as unknown as Skeleton, animationGroups: [] as AnimationGroup[] }); + const sprites = new WeaponSprites(scene, object, [ + { texture: 'flare01', bone: 0, offset: [-180, -40, 0], scale: 2, light: [1, 0.3, 0.1] }, + { texture: 'flare01', bone: 0, offset: [90, -40, 0], scale: 2, light: [1, 0.3, 0.1] }, + ]); + root.computeWorldMatrix(true); + model.computeWorldMatrix(true); + shaft.computeWorldMatrix(true); + const ends = sprites.nodes.map(node => { + node.computeWorldMatrix(true); + if (!node.parent) throw new Error('a sprite node has no parent'); + return Vector3.TransformCoordinates(node.position, node.parent.getWorldMatrix()); + }); + // The two glints sit on the shaft's own x axis, 40 cm off it, as BoneTransform[0] puts them. + const along = Vector3.TransformNormal(new Vector3(2.7, 0, 0), shaft.getWorldMatrix()); + const step = ends[1].subtract(ends[0]); + expect(step.x).toBeCloseTo(along.x); + expect(step.y).toBeCloseTo(along.y); + expect(step.z).toBeCloseTo(along.z); + expect(sprites.nodes[0].parent).not.toBe(root); + sprites.dispose(); + expect(scene.transformNodes.some(n => n.name === 'weaponBone0')).toBe(false); + }); }); describe('a held sprite gated on its wearer', () => { diff --git a/src/controllers/actors/weaponSprites.ts b/src/controllers/actors/weaponSprites.ts index a2f6a1fa8..e4aa282e4 100644 --- a/src/controllers/actors/weaponSprites.ts +++ b/src/controllers/actors/weaponSprites.ts @@ -39,9 +39,13 @@ export class WeaponSprites { const gltf = model.gltf; this.#scene = scene; this.#meshes = gltf ? gltf.mesh.getChildMeshes(false) : []; + const boneZero = gltf && !gltf.skeleton ? this.#bakedBoneZero(scene, this.#meshes[0]) : null; const at = (bone: number, name = 'weaponSprite') => { const node = new TransformNode(name, scene); - node.parent = (bone >= 0 ? gltf?.skeleton?.bones[bone + 1]?.getTransformNode() : null) ?? gltf?.mesh ?? null; + node.parent = + (bone >= 0 ? (gltf?.skeleton?.bones[bone + 1]?.getTransformNode() ?? (bone === 0 ? boneZero : null)) : null) ?? + gltf?.mesh ?? + null; return node; }; this.nodes = specs.map(spec => { @@ -110,9 +114,37 @@ export class WeaponSprites { return false; } + /** The bone-0 frame of a model with no skeleton, when one was needed (disposed with the sprites). */ + #boneZero: TransformNode | null = null; + + /** + * A one-bone model comes without a skeleton: its bone's rest transform sits on the mesh nodes instead + * (the Divine Staff of Archangel's is turned -90/-90 and moved off the root). `BoneTransform[0]` is that + * frame, not the root, or the staff's row of glints lies across the shaft and through the wearer. + * Null when the mesh sits on the root as it is. + */ + #bakedBoneZero(scene: Scene, mesh: AbstractMesh | undefined): TransformNode | null { + if (!mesh) return null; + const turned = mesh.rotationQuaternion + ? Math.abs(mesh.rotationQuaternion.w) < 1 - 1e-6 + : mesh.rotation.lengthSquared() > 1e-12; + const moved = mesh.position.lengthSquared() > 1e-12; + const scaled = Math.abs(mesh.scaling.x - 1) + Math.abs(mesh.scaling.y - 1) + Math.abs(mesh.scaling.z - 1) > 1e-6; + if (!turned && !moved && !scaled) return null; + const frame = new TransformNode('weaponBone0', scene); + frame.parent = mesh.parent; + frame.position.copyFrom(mesh.position); + if (mesh.rotationQuaternion) frame.rotationQuaternion = mesh.rotationQuaternion.clone(); + else frame.rotation.copyFrom(mesh.rotation); + frame.scaling.copyFrom(mesh.scaling); + this.#boneZero = frame; + return frame; + } + dispose(): void { this.#sprites.dispose(); for (const node of this.nodes) node.dispose(); for (const node of this.#emitAt) node.dispose(); + this.#boneZero?.dispose(); } }