From c29de7a26bf94ed5299eaf2c98af7970cb1056b3 Mon Sep 17 00:00:00 2001 From: tianmo Date: Tue, 22 Sep 2026 15:59:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=89=A9=E7=90=86=E7=BB=93?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpineThumbnailGenerateServer/js/renderer.js | 28 ++++++++------ .../UNIVERSAL_SpineViewer/viewer/spine.html | 35 +++++++++++------- tests/physics-constraints.test.js | 37 +++++++++++++++++++ viewer/spine.html | 35 +++++++++++------- 4 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 tests/physics-constraints.test.js diff --git a/SpineThumbnailGenerateServer/js/renderer.js b/SpineThumbnailGenerateServer/js/renderer.js index 65864ce..1eeb4f8 100644 --- a/SpineThumbnailGenerateServer/js/renderer.js +++ b/SpineThumbnailGenerateServer/js/renderer.js @@ -136,8 +136,12 @@ function readSkeletonData(spine, skeletonPath, atlas, isBinary) { return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8')); } -function updateWorld(spine, skeleton) { - if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update); +function updateWorld(spine, skeleton, delta = 0, resetPhysics = false) { + if (spine.Physics && spine.Physics.update !== undefined) { + if (delta > 0 && typeof skeleton.update === 'function') skeleton.update(delta); + const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update; + skeleton.updateWorldTransform(mode); + } else skeleton.updateWorldTransform(); } @@ -191,7 +195,7 @@ async function renderThumbnail(options) { utils.applySkin(skeleton, utils.preferredSkin(data)); if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose(); else if (typeof skeleton.setupPose === 'function') skeleton.setupPose(); - updateWorld(spine, skeleton); + updateWorld(spine, skeleton, 0, true); const bounds = boundsOf(spine, skeleton); @@ -275,7 +279,7 @@ function firstAnimation(data) { return null; } -function applyAnimationPose(spine, targetSkeleton, targetState, animation, time) { +function applyAnimationPose(spine, targetSkeleton, targetState, animation, time, previousTime) { if (typeof targetState.clearTracks === 'function') targetState.clearTracks(); else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0); if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose(); @@ -283,7 +287,7 @@ function applyAnimationPose(spine, targetSkeleton, targetState, animation, time) if (entry) entry.loop = false; targetState.update(Math.max(0, time)); targetState.apply(targetSkeleton); - updateWorld(spine, targetSkeleton); + updateWorld(spine, targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined); } function mergeBounds(total, next) { @@ -319,8 +323,10 @@ async function measureVisibleBounds(loaded, targetSkeleton, fallbackBounds, pose try { probe = window.SpineCanvasClipping.createRenderer(loaded.spine, context, loaded.runtimeDir); probe.premultipliedAlpha = loaded.premultipliedAlpha; + let previousIndex = -1; for (let index = 0; index < poseCount; index += step) { - applyPose(index); + applyPose(index, previousIndex); + previousIndex = index; context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, size, size); context.save(); @@ -397,13 +403,13 @@ async function renderAnimatedSourceFrames(loaded, animation, options) { const state = new spine.AnimationState(stateData); let bounds = null; - for (const time of sampleTimes) { - applyAnimationPose(spine, skeleton, state, animation, time); + for (let index = 0; index < sampleTimes.length; index++) { + applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined); bounds = mergeBounds(bounds, boundsOf(spine, skeleton)); } if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error('骨骼数据没有可绘制的范围'); - bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index) => { - applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index]); + bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index, previousIndex) => { + applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined); }, options.probe) || bounds; const canvas = document.createElement('canvas'); @@ -421,7 +427,7 @@ async function renderAnimatedSourceFrames(loaded, animation, options) { renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, loaded.runtimeDir); renderer.premultipliedAlpha = loaded.premultipliedAlpha; for (let index = 0; index < sampleTimes.length; index++) { - applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index]); + applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, size, size); diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index a1fea37..70196e4 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -320,6 +320,7 @@ let duration = 0; // 当前动画时长 let isStaticAnimation = false; const FPS = 30; + const MAX_FRAME_DELTA = 0.064; const STATIC_EPSILON = 1e-6; let statusHoldUntil = 0; @@ -572,9 +573,13 @@ } // ---------- 更新动画状态 ---------- - function updateWorld(sk) { + function updateWorld(sk, delta = 0, resetPhysics = false) { if (sk.updateWorldTransform) { - if (spine.Physics && spine.Physics.update !== undefined) sk.updateWorldTransform(spine.Physics.update); + if (spine.Physics && spine.Physics.update !== undefined) { + if (delta > 0 && typeof sk.update === 'function') sk.update(delta); + const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update; + sk.updateWorldTransform(mode); + } else sk.updateWorldTransform(); } } @@ -880,7 +885,7 @@ }); } - function applyAnimationPose(targetSkeleton, targetState, animation, time) { + function applyAnimationPose(targetSkeleton, targetState, animation, time, previousTime) { if (typeof targetState.clearTracks === 'function') targetState.clearTracks(); else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0); if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose(); @@ -888,7 +893,7 @@ if (entry) entry.loop = false; targetState.update(Math.max(0, time)); targetState.apply(targetSkeleton); - updateWorld(targetSkeleton); + updateWorld(targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined); } function mergeBounds(total, next) { @@ -916,7 +921,7 @@ probeRenderer = clippingRenderer.createRenderer(spine, context, activeRuntimeDir); probeRenderer.premultipliedAlpha = premultipliedAlpha; for (let index = 0; index < poseCount; index++) { - applyPose(index); + applyPose(index, index - 1); context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, size, size); context.save(); @@ -972,14 +977,14 @@ previewStateData.defaultMix = 0; const previewState = new spine.AnimationState(previewStateData); let bounds = null; - for (const time of sampleTimes) { - applyAnimationPose(previewSkeleton, previewState, animation, time); + for (let index = 0; index < sampleTimes.length; index++) { + applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined); bounds = mergeBounds(bounds, getBounds(previewSkeleton)); } bounds = bounds || stableFitBounds; if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg')); - bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index) => { - applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]); + bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index, previousIndex) => { + applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined); }) || bounds; const off = document.createElement('canvas'); @@ -994,7 +999,7 @@ offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir); offRenderer.premultipliedAlpha = premultipliedAlpha; for (let index = 0; index < sampleTimes.length; index++) { - applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]); + applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined); octx.setTransform(1, 0, 0, 1, 0, 0); octx.clearRect(0, 0, size, size); octx.fillStyle = '#1e1e1e'; @@ -1371,10 +1376,12 @@ if (skeleton && state) { if (playing) { // Spine 运行时自身处理循环;持续 update 即可。 - state.update(dt * speed); + const frameDelta = Math.min(Math.max(dt, 0), MAX_FRAME_DELTA) * speed; + state.update(frameDelta); state.apply(skeleton); + updateWorld(skeleton, frameDelta); } - updateWorld(skeleton); + else updateWorld(skeleton); draw(); const entry = currentTrack(); if (entry) { @@ -1611,7 +1618,7 @@ if (typeof skeleton.setBonesToSetupPose === 'function') skeleton.setBonesToSetupPose(); if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose(); } - updateWorld(skeleton); + updateWorld(skeleton, 0, true); updateTimelineDuration(null, true); setStatus(t('viewer.setupPose')); draw(); @@ -1693,7 +1700,7 @@ } state.update(Math.max(0, time)); state.apply(skeleton); - updateWorld(skeleton); + updateWorld(skeleton, 0, true); draw(); updateTimeReadout(time); } catch (e) { /* ignore */ } diff --git a/tests/physics-constraints.test.js b/tests/physics-constraints.test.js new file mode 100644 index 0000000..6abe6ad --- /dev/null +++ b/tests/physics-constraints.test.js @@ -0,0 +1,37 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const viewer = fs.readFileSync('viewer/spine.html', 'utf8'); +const releaseViewer = fs.readFileSync('release/UNIVERSAL_SpineViewer/viewer/spine.html', 'utf8'); +const server = fs.readFileSync('SpineThumbnailGenerateServer/js/renderer.js', 'utf8'); + +function functionSource(source, name) { + const start = source.indexOf(`function ${name}(`); + assert.ok(start >= 0, `missing ${name}`); + const open = source.indexOf('{', start); + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === '{') depth++; + else if (source[index] === '}' && --depth === 0) return source.slice(start, index + 1); + } + throw new Error(`unterminated ${name}`); +} + +const calls = []; +const skeleton = { + update(delta) { calls.push(['time', delta]); }, + updateWorldTransform(mode) { calls.push(['world', mode]); }, +}; +vm.runInNewContext(`const spine = { Physics: { update: 'update', reset: 'reset' } }; ${functionSource(viewer, 'updateWorld')}; updateWorld(skeleton, 0.05); updateWorld(skeleton, 0, true);`, { skeleton, calls }); +assert.deepEqual(calls, [['time', 0.05], ['world', 'update'], ['world', 'reset']]); + +assert.equal(viewer, releaseViewer, 'release viewer must mirror the source viewer'); +assert.match(viewer, /MAX_FRAME_DELTA = 0\.064[\s\S]*updateWorld\(skeleton, frameDelta\)/); +assert.match(viewer, /time - previousTime[\s\S]*previousTime === undefined/); +assert.match(server, /skeleton\.update\(delta\)[\s\S]*spine\.Physics\.reset/); +assert.match(server, /time - previousTime[\s\S]*previousTime === undefined/); + +console.log('PASS: Spine 4.2+ physics advances time and resets between sampling passes'); diff --git a/viewer/spine.html b/viewer/spine.html index a1fea37..70196e4 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -320,6 +320,7 @@ let duration = 0; // 当前动画时长 let isStaticAnimation = false; const FPS = 30; + const MAX_FRAME_DELTA = 0.064; const STATIC_EPSILON = 1e-6; let statusHoldUntil = 0; @@ -572,9 +573,13 @@ } // ---------- 更新动画状态 ---------- - function updateWorld(sk) { + function updateWorld(sk, delta = 0, resetPhysics = false) { if (sk.updateWorldTransform) { - if (spine.Physics && spine.Physics.update !== undefined) sk.updateWorldTransform(spine.Physics.update); + if (spine.Physics && spine.Physics.update !== undefined) { + if (delta > 0 && typeof sk.update === 'function') sk.update(delta); + const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update; + sk.updateWorldTransform(mode); + } else sk.updateWorldTransform(); } } @@ -880,7 +885,7 @@ }); } - function applyAnimationPose(targetSkeleton, targetState, animation, time) { + function applyAnimationPose(targetSkeleton, targetState, animation, time, previousTime) { if (typeof targetState.clearTracks === 'function') targetState.clearTracks(); else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0); if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose(); @@ -888,7 +893,7 @@ if (entry) entry.loop = false; targetState.update(Math.max(0, time)); targetState.apply(targetSkeleton); - updateWorld(targetSkeleton); + updateWorld(targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined); } function mergeBounds(total, next) { @@ -916,7 +921,7 @@ probeRenderer = clippingRenderer.createRenderer(spine, context, activeRuntimeDir); probeRenderer.premultipliedAlpha = premultipliedAlpha; for (let index = 0; index < poseCount; index++) { - applyPose(index); + applyPose(index, index - 1); context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, size, size); context.save(); @@ -972,14 +977,14 @@ previewStateData.defaultMix = 0; const previewState = new spine.AnimationState(previewStateData); let bounds = null; - for (const time of sampleTimes) { - applyAnimationPose(previewSkeleton, previewState, animation, time); + for (let index = 0; index < sampleTimes.length; index++) { + applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined); bounds = mergeBounds(bounds, getBounds(previewSkeleton)); } bounds = bounds || stableFitBounds; if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg')); - bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index) => { - applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]); + bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index, previousIndex) => { + applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined); }) || bounds; const off = document.createElement('canvas'); @@ -994,7 +999,7 @@ offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir); offRenderer.premultipliedAlpha = premultipliedAlpha; for (let index = 0; index < sampleTimes.length; index++) { - applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]); + applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined); octx.setTransform(1, 0, 0, 1, 0, 0); octx.clearRect(0, 0, size, size); octx.fillStyle = '#1e1e1e'; @@ -1371,10 +1376,12 @@ if (skeleton && state) { if (playing) { // Spine 运行时自身处理循环;持续 update 即可。 - state.update(dt * speed); + const frameDelta = Math.min(Math.max(dt, 0), MAX_FRAME_DELTA) * speed; + state.update(frameDelta); state.apply(skeleton); + updateWorld(skeleton, frameDelta); } - updateWorld(skeleton); + else updateWorld(skeleton); draw(); const entry = currentTrack(); if (entry) { @@ -1611,7 +1618,7 @@ if (typeof skeleton.setBonesToSetupPose === 'function') skeleton.setBonesToSetupPose(); if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose(); } - updateWorld(skeleton); + updateWorld(skeleton, 0, true); updateTimelineDuration(null, true); setStatus(t('viewer.setupPose')); draw(); @@ -1693,7 +1700,7 @@ } state.update(Math.max(0, time)); state.apply(skeleton); - updateWorld(skeleton); + updateWorld(skeleton, 0, true); draw(); updateTimeReadout(time); } catch (e) { /* ignore */ }