阶段11开发
This commit is contained in:
1 parent
8e9b83f95b
commit
8802bbe25e
21 files changed
+5285
-81
No files matched your search
@@ -0,0 +1,44 @@
|
||||
export type SpineExportVersion = '4.2'
|
||||
export type SpineExportKeyframeCurve = 'linear' | 'bezier'
|
||||
|
||||
/** Spine JSON 导出的全局参数。实际 JSON 生成逻辑将在后续阶段接入。 */
|
||||
export interface SpineExportSettings {
|
||||
imagesPath: string
|
||||
spineVersion: SpineExportVersion
|
||||
keyframeCurve: SpineExportKeyframeCurve
|
||||
fps: number
|
||||
omitFps: boolean
|
||||
integerFrameAlignment: boolean
|
||||
bonePool: boolean
|
||||
excludeEmptyAnimations: boolean
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export function defaultSpineExportSettings(): SpineExportSettings {
|
||||
return {
|
||||
imagesPath: './images/',
|
||||
spineVersion: '4.2',
|
||||
keyframeCurve: 'linear',
|
||||
fps: 30,
|
||||
omitFps: false,
|
||||
integerFrameAlignment: true,
|
||||
bonePool: true,
|
||||
excludeEmptyAnimations: false,
|
||||
fileName: 'SpineParticle.json',
|
||||
}
|
||||
}
|
||||
|
||||
/** 补齐旧配置或热更新前状态中缺少的导出参数。 */
|
||||
export function ensureSpineExportSettings(value?: Partial<SpineExportSettings> | null): SpineExportSettings {
|
||||
const settings = Object.assign(defaultSpineExportSettings(), value || {})
|
||||
settings.imagesPath = String(settings.imagesPath || './images/')
|
||||
settings.spineVersion = '4.2'
|
||||
settings.keyframeCurve = settings.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
|
||||
settings.fps = Math.min(240, Math.max(1, Math.round(Number(settings.fps) || 30)))
|
||||
settings.omitFps = settings.omitFps === true
|
||||
settings.integerFrameAlignment = settings.integerFrameAlignment !== false
|
||||
settings.bonePool = settings.bonePool !== false
|
||||
settings.excludeEmptyAnimations = settings.excludeEmptyAnimations === true
|
||||
settings.fileName = String(settings.fileName || 'SpineParticle.json')
|
||||
return settings
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { EmitterConfig, ParticleImageResource, ParticleState, TrailImageResource } from '../core/particleEmitter'
|
||||
import type { ParticleSystem, TimelineState } from '../store/particleStore'
|
||||
import type { SpineExportSettings } from './spineExportSettings'
|
||||
import type { SpineExportResult, SpineExportWarning, SpineJsonDocument, SpineJsonMap } from './spineJsonTypes'
|
||||
import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, type NumericSample } from './spineKeyframeReducer'
|
||||
import { buildWeightedTrailMesh } from './spineMeshBuilder'
|
||||
|
||||
type Track = { key: string; name: string; states: Array<ParticleState | null> }
|
||||
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
|
||||
|
||||
const BLENDS = { normal: 'normal', add: 'additive', multiply: 'multiply', screen: 'screen' } as const
|
||||
const PARTICLE_POSITION_TOLERANCE = 0.1
|
||||
const TRAIL_POSITION_TOLERANCE = 0.1
|
||||
const ROTATION_TOLERANCE = 0.01
|
||||
const SCALE_TOLERANCE = 0.01
|
||||
|
||||
function safeName(value: string, fallback: string) {
|
||||
const name = String(value || '').trim().replace(/[\\/:*?"<>|\s]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
return name || fallback
|
||||
}
|
||||
|
||||
function uniqueName(base: string, used: Set<string>) {
|
||||
let value = base
|
||||
let index = 2
|
||||
while (used.has(value)) value = `${base}_${index++}`
|
||||
used.add(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function stripExtension(value: string) { return value.replace(/\.[^./\\]+$/, '') }
|
||||
function normalizePath(folder: string, file: string) {
|
||||
return stripExtension([folder, file].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\.\//, ''))
|
||||
}
|
||||
function textureSize(texture: any) {
|
||||
const width = Number(texture?.orig?.width ?? texture?.width) || 0
|
||||
const height = Number(texture?.orig?.height ?? texture?.height) || 0
|
||||
return { width, height }
|
||||
}
|
||||
function byte(value: number) { return Math.round(Math.max(0, Math.min(1, value)) * 255).toString(16).padStart(2, '0') }
|
||||
function rgbaValues(value: number, alpha: number) {
|
||||
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255, Math.max(0, Math.min(1, alpha))]
|
||||
}
|
||||
function time(frame: number, fps: number) { return roundSpine(frame / fps) }
|
||||
|
||||
function particleDurationFrames(system: ParticleSystem, fps: number) {
|
||||
const c = system.config
|
||||
const life = c.lifeMode === 'fixed' ? Math.max(0, Number(c.lifeMin) || 0) : Math.max(0, Number(c.lifeMin) || 0, Number(c.lifeMax) || 0)
|
||||
const delay = Math.max(0, Number(c.delay) || 0)
|
||||
let end: number
|
||||
if (c.mode !== 'stream') end = delay + life
|
||||
else if (c.streamBehavior !== 'loop') end = delay + Math.max(1 / fps, Number(c.duration) || 0) + life
|
||||
else {
|
||||
const loop = Math.max(1, Math.ceil(Number(c.loopDurationFrames) || 1)) / fps
|
||||
if (c.generateLoopEndAnimation) end = delay + life + 1 / fps
|
||||
else if (c.generateLoopStartAnimation) end = delay + loop * Math.max(1, Math.ceil(life / loop))
|
||||
else end = delay + loop
|
||||
}
|
||||
return Math.max(1, Math.ceil(end * fps) + 1)
|
||||
}
|
||||
|
||||
function stateIdentity(state: ParticleState, bonePool: boolean) {
|
||||
return bonePool ? state.boneName : `${state.spawnId ?? 'legacy'}_${state.boneName}`
|
||||
}
|
||||
|
||||
function collectTracks(frames: ParticleState[][], bonePool: boolean, usedNames: Set<string>) {
|
||||
const keys = new Set<string>()
|
||||
for (const frame of frames) for (const state of frame) keys.add(stateIdentity(state, bonePool))
|
||||
return [...keys].sort().map((key, index): Track => ({
|
||||
key,
|
||||
name: uniqueName(`_particle_${String(index).padStart(3, '0')}`, usedNames),
|
||||
states: frames.map((frame) => frame.find((state) => stateIdentity(state, bonePool) === key) || null),
|
||||
}))
|
||||
}
|
||||
|
||||
function sourceStateAt(track: Track, targetFrame: number, sourceFps: number, targetFps: number) {
|
||||
const source = targetFrame * sourceFps / targetFps
|
||||
const aIndex = Math.min(track.states.length - 1, Math.max(0, Math.floor(source)))
|
||||
const bIndex = Math.min(track.states.length - 1, aIndex + 1)
|
||||
const a = track.states[aIndex]
|
||||
const b = track.states[bIndex]
|
||||
if (!a || !b || stateIdentity(a, true) !== stateIdentity(b, true) || a.spawnId !== b.spawnId) return a
|
||||
const amount = source - aIndex
|
||||
if (amount <= 1e-6) return a
|
||||
const lerp = (x: number | undefined, y: number | undefined) => (x ?? 0) + ((y ?? x ?? 0) - (x ?? 0)) * amount
|
||||
const trailState = a.trailState && b.trailState && a.trailState.resourceId === b.trailState.resourceId
|
||||
? {
|
||||
...a.trailState,
|
||||
width: lerp(a.trailState.width, b.trailState.width),
|
||||
alpha: lerp(a.trailState.alpha, b.trailState.alpha),
|
||||
bones: a.trailState.bones.map((bone, index) => {
|
||||
const next = b.trailState!.bones[index] || bone
|
||||
return { ...bone, worldX: lerp(bone.worldX ?? bone.x, next.worldX ?? next.x), worldY: lerp(bone.worldY ?? bone.y, next.worldY ?? next.y), worldRotation: lerp(bone.worldRotation ?? -bone.rotation, next.worldRotation ?? -next.rotation) }
|
||||
}),
|
||||
}
|
||||
: a.trailState
|
||||
return {
|
||||
...a,
|
||||
worldX: lerp(a.worldX, b.worldX), worldY: lerp(a.worldY, b.worldY), worldRotation: lerp(a.worldRotation, b.worldRotation),
|
||||
worldScaleX: lerp(a.worldScaleX, b.worldScaleX), worldScaleY: lerp(a.worldScaleY, b.worldScaleY),
|
||||
alpha: lerp(a.alpha, b.alpha),
|
||||
trailState,
|
||||
}
|
||||
}
|
||||
|
||||
type TimelineCurveMode = 'linear' | 'bezier'
|
||||
|
||||
function resolvedTimelineCurve(mode: '跟随导出' | '线性' | '贝塞尔' | undefined, settings: SpineExportSettings): TimelineCurveMode {
|
||||
if (!mode || mode === '跟随导出') return settings.keyframeCurve
|
||||
return mode === '贝塞尔' ? 'bezier' : 'linear'
|
||||
}
|
||||
|
||||
function numericTimeline(samples: NumericSample[], fps: number, names: string[], tolerances: number[], settings: SpineExportSettings, forcedFrames: Set<number> = new Set(), curveMode: TimelineCurveMode = settings.keyframeCurve) {
|
||||
if (!samples.length) return []
|
||||
const fitted = curveMode === 'bezier'
|
||||
? reduceBezierSamples(samples, tolerances, forcedFrames)
|
||||
: { samples: reduceNumericSamples(samples, tolerances, forcedFrames), controls: [] }
|
||||
const keys = fitted.samples.map((sample) => {
|
||||
const key: SpineJsonMap = { time: time(sample.frame, fps) }
|
||||
names.forEach((name, index) => { key[name] = roundSpine(sample.values[index]) })
|
||||
return key
|
||||
})
|
||||
for (let index = 0; index < fitted.controls.length; index++) {
|
||||
const startTime = Number(keys[index].time) || 0
|
||||
const endTime = Number(keys[index + 1].time) || 0
|
||||
const curve: number[] = []
|
||||
for (let dimension = 0; dimension < names.length; dimension++) {
|
||||
const controls = fitted.controls[index].values[dimension]
|
||||
curve.push(roundSpine(startTime + (endTime - startTime) / 3), roundSpine(controls[0]), roundSpine(startTime + (endTime - startTime) * 2 / 3), roundSpine(controls[1]))
|
||||
}
|
||||
keys[index].curve = curve
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function stateBoundaryFrames(states: Array<ParticleState | null>) {
|
||||
const forced = new Set<number>()
|
||||
for (let frame = 0; frame < states.length; frame++) {
|
||||
const current = states[frame]
|
||||
const previous = frame ? states[frame - 1] : null
|
||||
const changedParticle = !!current && !!previous && current.spawnId !== previous.spawnId
|
||||
if ((!!current !== !!previous) || changedParticle) {
|
||||
if (frame > 0 && previous) forced.add(frame - 1)
|
||||
if (current) forced.add(frame)
|
||||
}
|
||||
}
|
||||
return forced
|
||||
}
|
||||
|
||||
function optionalStateBoundaryFrames<T>(states: Array<T | undefined>) {
|
||||
const forced = new Set<number>()
|
||||
for (let frame = 0; frame < states.length; frame++) {
|
||||
const current = states[frame], previous = frame ? states[frame - 1] : undefined
|
||||
if (!!current !== !!previous) {
|
||||
if (frame > 0 && previous) forced.add(frame - 1)
|
||||
if (current) forced.add(frame)
|
||||
}
|
||||
}
|
||||
return forced
|
||||
}
|
||||
|
||||
function trailShapeAt(resource: TrailImageResource, t: number) {
|
||||
if (!resource.shapeEnabled || !resource.shapeCurve?.length) return 1
|
||||
const points = [...resource.shapeCurve].sort((a, b) => a.x - b.x)
|
||||
if (t <= points[0].x) return Math.max(0, points[0].y)
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const a = points[index - 1], b = points[index]
|
||||
if (t <= b.x) {
|
||||
const amount = (t - a.x) / Math.max(0.000001, b.x - a.x)
|
||||
return Math.max(0, a.y + (b.y - a.y) * amount)
|
||||
}
|
||||
}
|
||||
return Math.max(0, points[points.length - 1].y)
|
||||
}
|
||||
|
||||
function discreteTimeline<T>(values: T[], fps: number, make: (value: T, frame: number) => SpineJsonMap) {
|
||||
const keys: SpineJsonMap[] = []
|
||||
let previous: T | symbol = Symbol()
|
||||
values.forEach((value, frame) => { if (value !== previous) { keys.push(make(value, frame)); previous = value } })
|
||||
return keys
|
||||
}
|
||||
|
||||
function resourceAttachment(resource: ParticleImageResource, frameIndex: number, warnings: SpineExportWarning[]) {
|
||||
const frame = resource.imageMode === 'sequence' ? resource.sequenceFrames[frameIndex] : undefined
|
||||
const file = frame?.name || resource.textureName || `image_${resource.id}`
|
||||
const texture = frame?.texture || resource.texture
|
||||
const size = textureSize(texture)
|
||||
if (!size.width || !size.height) warnings.push({ code: 'missing-image', message: `图片“${file}”缺失或尺寸不可读,已保留路径。` })
|
||||
const width = size.width || 1, height = size.height || 1
|
||||
return {
|
||||
path: normalizePath(resource.textureFolder, file),
|
||||
x: roundSpine((0.5 - resource.anchorX) * width),
|
||||
y: roundSpine((resource.anchorY - 0.5) * height),
|
||||
width: roundSpine(width), height: roundSpine(height),
|
||||
}
|
||||
}
|
||||
|
||||
function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
|
||||
if (!colors.length) return []
|
||||
const forcedFrames = new Set<number>()
|
||||
for (let frame = 1; frame < attachments.length; frame++) {
|
||||
const current = attachments[frame]
|
||||
const previous = attachments[frame - 1]
|
||||
// 序列帧图片名变化不影响同一插槽上的 RGBA 插值;仅在显隐或资源卡片切换时锁定边界。
|
||||
const currentFamily = current?.replace(/_\d+$/, '') ?? null
|
||||
const previousFamily = previous?.replace(/_\d+$/, '') ?? null
|
||||
if (currentFamily !== previousFamily) {
|
||||
forcedFrames.add(frame)
|
||||
forcedFrames.add(frame - 1)
|
||||
}
|
||||
}
|
||||
// 透明度使用未量化的浮点值参与精简,线性渐变能直接收敛到首尾关键帧;
|
||||
// RGB 保持单通道 1/255,透明度使用 0.01 的视觉容差。
|
||||
const samples: NumericSample[] = colors.map((values, frame) => ({ frame, values }))
|
||||
const rgbTolerance = 1 / 255
|
||||
const fitted = curveMode === 'bezier'
|
||||
? reduceBezierSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames)
|
||||
: { samples: reduceNumericSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames), controls: [] }
|
||||
const keys = fitted.samples.map((sample) => ({ time: time(sample.frame, fps), color: `${byte(sample.values[0])}${byte(sample.values[1])}${byte(sample.values[2])}${byte(sample.values[3])}` } as SpineJsonMap))
|
||||
if (fitted.controls.length) {
|
||||
for (let index = 0; index < fitted.controls.length; index++) {
|
||||
const t0 = time(fitted.samples[index].frame, fps), t1 = time(fitted.samples[index + 1].frame, fps)
|
||||
const curve: number[] = []
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
const controls = fitted.controls[index].values[channel]
|
||||
curve.push(roundSpine(t0 + (t1 - t0) / 3), roundSpine(controls[0]), roundSpine(t0 + (t1 - t0) * 2 / 3), roundSpine(controls[1]))
|
||||
}
|
||||
keys[index].curve = curve
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function addSlotAnimation(animation: SpineJsonMap, slotName: string, attachments: Array<string | null>, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
|
||||
const slot = animation.slots[slotName] ||= {}
|
||||
const attachment = discreteTimeline(attachments, fps, (name, frame) => name == null ? { time: time(frame, fps) } : { time: time(frame, fps), name })
|
||||
if (attachment.length) slot.attachment = attachment
|
||||
const rgba = rgbaTimeline(colors, attachments, fps, settings, curveMode)
|
||||
if (rgba.length) slot.rgba = rgba
|
||||
}
|
||||
|
||||
export function buildSpineJson(input: ExportInput): SpineExportResult {
|
||||
const settings = input.settings
|
||||
const sourceFps = Math.max(1, Number(input.timeline.fps) || 30)
|
||||
const targetFps = Math.max(1, Math.round(settings.fps || 30))
|
||||
// 关闭整数帧对齐时保留编辑器原始采样时刻,仅修改 Spine 的 FPS 元数据。
|
||||
const fps = settings.integerFrameAlignment ? targetFps : sourceFps
|
||||
const warnings: SpineExportWarning[] = []
|
||||
const visibleSystems = input.systems.filter((system) => system.visible !== false)
|
||||
const document: SpineJsonDocument = {
|
||||
skeleton: { hash: '', spine: '4.2', x: 0, y: 0, width: 0, height: 0, images: settings.imagesPath || './images/' },
|
||||
bones: [{ name: 'root' }], slots: [], skins: [{ name: 'default', attachments: {} }], animations: {},
|
||||
}
|
||||
if (!settings.omitFps) document.skeleton.fps = targetFps
|
||||
const skin = document.skins[0].attachments as SpineJsonMap
|
||||
const usedNames = new Set<string>(['root'])
|
||||
const animationNames = [...new Set([...(input.timeline.animations.length ? input.timeline.animations : ['animation']), ...visibleSystems.map((system) => system.animation || 'animation')])]
|
||||
const animationSystems = new Map<string, Array<{ system: ParticleSystem; tracks: Track[]; targetFrames: number }>>()
|
||||
|
||||
for (const system of visibleSystems) {
|
||||
const sourceCount = Math.min(system.frames?.length || 0, particleDurationFrames(system, sourceFps))
|
||||
const frames = (system.frames || []).slice(0, sourceCount)
|
||||
if (!frames.length) warnings.push({ code: 'empty-cache', message: `“${system.config.name}”没有可用帧缓存。` })
|
||||
const prefix = uniqueName(safeName(system.config.name, `system_${system.id}`), usedNames)
|
||||
document.bones.push({ name: prefix, parent: 'root' })
|
||||
const tracks = collectTracks(frames, settings.bonePool, usedNames)
|
||||
const duration = Math.max(0, (sourceCount - 1) / sourceFps)
|
||||
const targetFrames = Math.max(1, Math.ceil(duration * fps) + 1)
|
||||
const group = animationSystems.get(system.animation) || []
|
||||
group.push({ system, tracks, targetFrames })
|
||||
animationSystems.set(system.animation, group)
|
||||
|
||||
for (const track of tracks) {
|
||||
document.bones.push({ name: track.name, parent: prefix })
|
||||
const usedImageBlends = new Set(system.config.imageResources.map((item) => item.blend))
|
||||
for (const blend of usedImageBlends) {
|
||||
const slotName = `${track.name}_image_${blend}`
|
||||
document.slots.push({ name: slotName, bone: track.name, color: 'ffffff00', blend: BLENDS[blend] })
|
||||
const slotSkin = skin[slotName] ||= {}
|
||||
for (const resource of system.config.imageResources.filter((item) => item.blend === blend)) {
|
||||
const count = resource.imageMode === 'sequence' ? Math.max(1, resource.sequenceFrames.length) : 1
|
||||
for (let frame = 0; frame < count; frame++) slotSkin[`image_${resource.id}_${frame}`] = resourceAttachment(resource, frame, warnings)
|
||||
}
|
||||
}
|
||||
if (system.config.trail) {
|
||||
for (const resource of system.config.trailResources) {
|
||||
if (!track.states.some((state) => state?.trailState?.resourceId === resource.id)) continue
|
||||
const count = Math.max(2, Math.min(16, Math.round(resource.boneCount || 3)))
|
||||
const trailNames: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = `${track.name}_trail_${resource.id}_${i}`
|
||||
trailNames.push(name)
|
||||
document.bones.push({
|
||||
name,
|
||||
parent: prefix,
|
||||
})
|
||||
}
|
||||
const slotName = `${track.name}_trail_${resource.id}`
|
||||
document.slots.push({ name: slotName, bone: trailNames[0], color: 'ffffff00', blend: BLENDS[resource.blend] })
|
||||
const indices = trailNames.map((name) => document.bones.findIndex((bone) => bone.name === name))
|
||||
const path = normalizePath('', resource.textureName || `trail_${resource.id}`)
|
||||
;(skin[slotName] ||= {})[`trail_${resource.id}`] = buildWeightedTrailMesh(system.config, resource, indices, path)
|
||||
if (!resource.texture) warnings.push({ code: 'missing-image', message: `拖尾图片“${resource.textureName || resource.id}”缺失,已保留路径。` })
|
||||
}
|
||||
}
|
||||
// Spine 按 slots 数组顺序绘制。保持每个粒子的拖尾在图片下方。
|
||||
const imagePrefix = `${track.name}_image_`
|
||||
const trailPrefix = `${track.name}_trail_`
|
||||
const ownSlots = document.slots.filter((slot) => String(slot.name).startsWith(imagePrefix) || String(slot.name).startsWith(trailPrefix))
|
||||
if (ownSlots.length) {
|
||||
document.slots = document.slots.filter((slot) => !ownSlots.includes(slot))
|
||||
document.slots.push(...ownSlots.filter((slot) => String(slot.name).startsWith(trailPrefix)), ...ownSlots.filter((slot) => String(slot.name).startsWith(imagePrefix)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const animationName of animationNames) {
|
||||
const entries = animationSystems.get(animationName) || []
|
||||
const animation: SpineJsonMap = { bones: {}, slots: {} }
|
||||
let hasContent = false
|
||||
for (const { system, tracks, targetFrames } of entries) {
|
||||
for (const track of tracks) {
|
||||
const states = Array.from({ length: targetFrames }, (_, frame) => sourceStateAt(track, frame, sourceFps, fps))
|
||||
const active = states.map((state) => !!state?.active)
|
||||
const positions: NumericSample[] = [], rotations: number[] = [], scales: NumericSample[] = []
|
||||
states.forEach((state, frame) => {
|
||||
if (!state?.active) return
|
||||
positions.push({ frame, values: [state.worldX ?? state.x, state.worldY ?? state.y] })
|
||||
rotations.push(state.worldRotation ?? state.rotation)
|
||||
scales.push({ frame, values: [state.worldScaleX ?? state.scaleX, state.worldScaleY ?? state.scaleY] })
|
||||
})
|
||||
const rotationValues = unwrapDegrees(rotations)
|
||||
const rotationSamples: NumericSample[] = positions.map((sample, index) => ({ frame: sample.frame, values: [rotationValues[index] ?? 0] }))
|
||||
const motionBoundaries = stateBoundaryFrames(states)
|
||||
const boneTimeline: SpineJsonMap = {}
|
||||
const translate = numericTimeline(positions, fps, ['x', 'y'], [PARTICLE_POSITION_TOLERANCE, PARTICLE_POSITION_TOLERANCE], settings, motionBoundaries)
|
||||
const rotate = numericTimeline(rotationSamples, fps, ['value'], [ROTATION_TOLERANCE], settings, motionBoundaries)
|
||||
const scale = numericTimeline(scales, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, motionBoundaries)
|
||||
if (translate.length) boneTimeline.translate = translate
|
||||
if (rotate.length) boneTimeline.rotate = rotate
|
||||
if (scale.length) boneTimeline.scale = scale
|
||||
if (Object.keys(boneTimeline).length) { animation.bones[track.name] = boneTimeline; hasContent = true }
|
||||
|
||||
const usedBlends = new Set(system.config.imageResources.map((item) => item.blend))
|
||||
for (const blend of usedBlends) {
|
||||
const attachments: Array<string | null> = [], colors: number[][] = []
|
||||
let lastColor = 0xffffff
|
||||
for (let frame = 0; frame < targetFrames; frame++) {
|
||||
const state = states[frame]
|
||||
const resource = state ? system.config.imageResources.find((item) => item.id === state.resourceId) : undefined
|
||||
const visible = !!state && active[frame] && state.imageVisible !== false && resource?.blend === blend
|
||||
if (state && resource?.blend === blend) lastColor = state.colorHex
|
||||
attachments.push(visible ? `image_${resource!.id}_${state!.imageFrameIndex ?? 0}` : null)
|
||||
colors.push(rgbaValues(lastColor, visible ? state!.alpha : 0))
|
||||
}
|
||||
const blendResources = system.config.imageResources.filter((item) => item.blend === blend)
|
||||
const requestedModes = blendResources
|
||||
.filter((item) => item.independentAlpha === 'curve')
|
||||
.map((item) => resolvedTimelineCurve(item.alphaCurveMode, settings))
|
||||
const rgbaCurveMode = requestedModes.length
|
||||
? (requestedModes.includes('bezier') ? 'bezier' : 'linear')
|
||||
: resolvedTimelineCurve(system.config.alphaMode === 'curve' ? system.config.curveMode : undefined, settings)
|
||||
addSlotAnimation(animation, `${track.name}_image_${blend}`, attachments, colors, fps, settings, rgbaCurveMode)
|
||||
if (attachments.some(Boolean)) hasContent = true
|
||||
}
|
||||
|
||||
if (system.config.trail) for (const resource of system.config.trailResources) {
|
||||
const count = Math.max(2, Math.min(16, Math.round(resource.boneCount || 3)))
|
||||
const trailStates = states.map((state) => state?.trailState?.resourceId === resource.id ? state.trailState : undefined)
|
||||
const firstTrailState = trailStates.find((trail) => !!trail)
|
||||
if (!firstTrailState) continue
|
||||
const trailBoundaries = optionalStateBoundaryFrames(trailStates)
|
||||
const referenceWidth = resource.widthSizeMode === 'random'
|
||||
? Math.max(1, Number(resource.widthMin) || 0, Number(resource.widthMax) || 0)
|
||||
: Math.max(0.001, Number(resource.width) || 100)
|
||||
// 加权 Mesh 的骨骼和附件属于永久对象池,不能随粒子死亡而增删。
|
||||
// 出生前使用首次有效姿态,死亡后保持最近姿态;整组骨骼同帧缩放控制显隐。
|
||||
let heldTrailState = firstTrailState
|
||||
const rigStates = trailStates.map((trail) => {
|
||||
if (trail) heldTrailState = trail
|
||||
return heldTrailState
|
||||
})
|
||||
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
||||
const samplesTranslate: NumericSample[] = [], samplesRotate: NumericSample[] = [], samplesScale: NumericSample[] = []
|
||||
const boneBoundaries = new Set(trailBoundaries)
|
||||
const shapeScale = trailShapeAt(resource, boneIndex / Math.max(1, count - 1))
|
||||
rigStates.forEach((trail, frame) => {
|
||||
const bone = trail?.bones[Math.min(trail.bones.length - 1, boneIndex)]
|
||||
if (!bone) return
|
||||
const world = { x: bone.worldX ?? bone.x, y: bone.worldY ?? bone.y, rotation: bone.worldRotation ?? -bone.rotation }
|
||||
// 所有拖尾骨骼均直接挂在粒子系统骨骼下,动画写入独立世界姿态,
|
||||
// 避免骨骼链带来的旋转、缩放和位置累计误差。
|
||||
samplesTranslate.push({ frame, values: [world.x, world.y] })
|
||||
samplesRotate.push({ frame, values: [world.rotation] })
|
||||
samplesScale.push({
|
||||
frame,
|
||||
// 假死只修改插槽透明度;骨骼缩放始终保留,用于宽度和形状曲线。
|
||||
values: [1, Math.max(0, trail.width / referenceWidth) * shapeScale],
|
||||
})
|
||||
})
|
||||
const name = `${track.name}_trail_${resource.id}_${boneIndex}`
|
||||
const timeline: SpineJsonMap = {}
|
||||
const translate = numericTimeline(samplesTranslate, fps, ['x', 'y'], [TRAIL_POSITION_TOLERANCE, TRAIL_POSITION_TOLERANCE], settings, boneBoundaries)
|
||||
const rotate = numericTimeline(samplesRotate, fps, ['value'], [ROTATION_TOLERANCE], settings, boneBoundaries)
|
||||
const scale = numericTimeline(samplesScale, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, boneBoundaries)
|
||||
if (translate.length) timeline.translate = translate
|
||||
if (rotate.length) timeline.rotate = rotate
|
||||
if (scale.length) timeline.scale = scale
|
||||
if (Object.keys(timeline).length) animation.bones[name] = timeline
|
||||
}
|
||||
// Mesh 始终绑定在插槽上,不再用 null 附件模拟死亡,避免拓扑在时间轴上消失/重建。
|
||||
const attachments = trailStates.map(() => `trail_${resource.id}`)
|
||||
let lastTrailColor = firstTrailState.colorHex
|
||||
const colors = trailStates.map((trail) => {
|
||||
if (trail) {
|
||||
lastTrailColor = trail.colorHex
|
||||
}
|
||||
// 整组拖尾骨骼同生同死,因此可由唯一插槽统一透明假死。
|
||||
return rgbaValues(lastTrailColor, trail ? trail.alpha : 0)
|
||||
})
|
||||
addSlotAnimation(animation, `${track.name}_trail_${resource.id}`, attachments, colors, fps, settings)
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasContent || !settings.excludeEmptyAnimations) document.animations[animationName] = animation
|
||||
}
|
||||
|
||||
if (!Object.keys(document.animations).length && !settings.excludeEmptyAnimations) document.animations.animation = { bones: {}, slots: {} }
|
||||
const text = JSON.stringify(document, null, 2)
|
||||
return { json: document, text, warnings, particleSystemCount: visibleSystems.length, animationCount: Object.keys(document.animations).length, boneCount: document.bones.length }
|
||||
}
|
||||
|
||||
export function downloadSpineJson(result: SpineExportResult, fileName: string) {
|
||||
const blob = new Blob([result.text], { type: 'application/json;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = /\.json$/i.test(fileName) ? fileName : `${fileName || 'SpineParticle'}.json`
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
// 给浏览器足够时间接管 Blob,避免立即释放导致下载被静默取消。
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SpineJsonMap = Record<string, any>
|
||||
|
||||
export interface SpineJsonDocument {
|
||||
skeleton: { hash: string; spine: string; x: number; y: number; width: number; height: number; images: string; fps?: number }
|
||||
bones: SpineJsonMap[]
|
||||
slots: SpineJsonMap[]
|
||||
skins: SpineJsonMap[]
|
||||
animations: Record<string, SpineJsonMap>
|
||||
}
|
||||
|
||||
export interface SpineExportWarning {
|
||||
code: 'missing-image' | 'missing-frame' | 'empty-cache' | 'invalid-value'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SpineExportResult {
|
||||
json: SpineJsonDocument
|
||||
text: string
|
||||
warnings: SpineExportWarning[]
|
||||
particleSystemCount: number
|
||||
animationCount: number
|
||||
boneCount: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
BoundingBoxAttachment,
|
||||
ClippingAttachment,
|
||||
MeshAttachment,
|
||||
PathAttachment,
|
||||
PointAttachment,
|
||||
RegionAttachment,
|
||||
SkeletonJson,
|
||||
type AttachmentLoader,
|
||||
type Skin,
|
||||
} from '@esotericsoftware/spine-core'
|
||||
|
||||
class ExportValidationAttachmentLoader implements AttachmentLoader {
|
||||
newRegionAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new RegionAttachment(name, path) }
|
||||
newMeshAttachment(_skin: Skin, name: string, path: string, _sequence: any) { return new MeshAttachment(name, path) }
|
||||
newBoundingBoxAttachment(_skin: Skin, name: string) { return new BoundingBoxAttachment(name) }
|
||||
newPathAttachment(_skin: Skin, name: string) { return new PathAttachment(name) }
|
||||
newPointAttachment(_skin: Skin, name: string) { return new PointAttachment(name) }
|
||||
newClippingAttachment(_skin: Skin, name: string) { return new ClippingAttachment(name) }
|
||||
}
|
||||
|
||||
const VALID_INHERIT = new Set(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'])
|
||||
|
||||
function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCount: number) {
|
||||
const uvs = mesh.uvs
|
||||
const vertices = mesh.vertices
|
||||
const triangles = mesh.triangles
|
||||
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(`拖尾网格“${attachmentName}”的 UV 数据无效`)
|
||||
if (!Array.isArray(vertices) || !vertices.length) throw new Error(`拖尾网格“${attachmentName}”缺少顶点数据`)
|
||||
const vertexCount = uvs.length / 2
|
||||
if (!Array.isArray(triangles) || triangles.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的三角形索引越界`)
|
||||
}
|
||||
const hull = Number(mesh.hull) || 0
|
||||
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(`拖尾网格“${attachmentName}”的外轮廓数量无效`)
|
||||
if (Array.isArray(mesh.edges) && mesh.edges.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount * 2 || index % 2)) {
|
||||
throw new Error(`拖尾网格“${attachmentName}”的外轮廓边索引越界`)
|
||||
}
|
||||
// 未加权 Mesh 每个顶点只有 x/y;拖尾必须是逐顶点的骨骼权重编码。
|
||||
if (vertices.length === uvs.length) return
|
||||
let offset = 0
|
||||
let decodedVertices = 0
|
||||
while (offset < vertices.length) {
|
||||
const influences = vertices[offset++]
|
||||
if (!Number.isInteger(influences) || influences < 1) throw new Error(`拖尾网格“${attachmentName}”包含无效骨骼权重数量`)
|
||||
let weightSum = 0
|
||||
for (let influence = 0; influence < influences; influence++) {
|
||||
const boneIndex = vertices[offset++]
|
||||
const x = vertices[offset++]
|
||||
const y = vertices[offset++]
|
||||
const weight = vertices[offset++]
|
||||
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(`拖尾网格“${attachmentName}”引用了不存在的骨骼`)
|
||||
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(`拖尾网格“${attachmentName}”包含无效权重数据`)
|
||||
weightSum += weight
|
||||
}
|
||||
if (Math.abs(weightSum - 1) > 0.001) throw new Error(`拖尾网格“${attachmentName}”的顶点权重之和不为 1`)
|
||||
decodedVertices++
|
||||
}
|
||||
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(`拖尾网格“${attachmentName}”的顶点数量与 UV 不一致`)
|
||||
}
|
||||
|
||||
function validateEditorImportStructure(root: Record<string, any>) {
|
||||
const bones = Array.isArray(root.bones) ? root.bones : []
|
||||
const boneByName = new Map(bones.map((bone) => [String(bone.name || ''), bone]))
|
||||
const weightedMeshSlots = new Set<string>()
|
||||
for (const bone of bones) {
|
||||
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(`骨骼“${bone.name || ''}”的 inherit 值“${bone.inherit}”不符合 Spine 4.2 JSON 格式`)
|
||||
if (/_particle_\d+_trail_/.test(String(bone.name || ''))) {
|
||||
const parent = boneByName.get(String(bone.parent || ''))
|
||||
if (!parent || /_particle_\d+_trail_/.test(String(parent.name || ''))) {
|
||||
throw new Error(`拖尾骨骼“${bone.name || ''}”必须直接挂在粒子系统骨骼下,不能组成骨骼链`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const skin of Array.isArray(root.skins) ? root.skins : []) {
|
||||
const attachments = skin?.attachments || {}
|
||||
for (const slotName of Object.keys(attachments)) {
|
||||
for (const attachmentName of Object.keys(attachments[slotName] || {})) {
|
||||
const attachment = attachments[slotName][attachmentName]
|
||||
if (attachment?.type === 'mesh') {
|
||||
validateMesh(attachment, attachmentName, bones.length)
|
||||
if (attachment.vertices?.length !== attachment.uvs?.length) weightedMeshSlots.add(slotName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const animation of Object.values(root.animations || {}) as Array<Record<string, any>>) {
|
||||
for (const slotName of weightedMeshSlots) {
|
||||
const attachmentKeys = animation?.slots?.[slotName]?.attachment
|
||||
if (Array.isArray(attachmentKeys) && attachmentKeys.some((key) => !key?.name)) {
|
||||
throw new Error(`加权拖尾插槽“${slotName}”不能使用空附件,未出现或死亡阶段应将对应骨骼缩放设为 0`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载前使用项目内置 Spine 4.2 Runtime 做一次结构解析,提前拦截无效 JSON。 */
|
||||
export function validateSpineJson(text: string) {
|
||||
const root = JSON.parse(text)
|
||||
validateEditorImportStructure(root)
|
||||
const reader = new SkeletonJson(new ExportValidationAttachmentLoader())
|
||||
const data = reader.readSkeletonData(root)
|
||||
if (!data.bones.length) throw new Error('导出结果缺少骨骼')
|
||||
return { bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
export interface NumericSample {
|
||||
frame: number
|
||||
values: number[]
|
||||
}
|
||||
|
||||
export interface BezierSegmentControls {
|
||||
/** 每个维度的第一个、第二个控制值。控制时间固定在区间 1/3 与 2/3。 */
|
||||
values: Array<[number, number]>
|
||||
}
|
||||
|
||||
export interface ReducedBezierSamples {
|
||||
samples: NumericSample[]
|
||||
controls: BezierSegmentControls[]
|
||||
}
|
||||
|
||||
function interpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, tolerances: number[]) {
|
||||
const span = end.frame - start.frame
|
||||
const amount = span <= 0 ? 0 : (sample.frame - start.frame) / span
|
||||
let error = 0
|
||||
for (let i = 0; i < sample.values.length; i++) {
|
||||
const expected = start.values[i] + (end.values[i] - start.values[i]) * amount
|
||||
error = Math.max(error, Math.abs(sample.values[i] - expected) / Math.max(1e-9, tolerances[i] ?? tolerances[0] ?? 1e-3))
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
/** 保留误差最大的点并递归拆分,确保删除关键帧后仍在给定误差内。 */
|
||||
export function reduceNumericSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set()) {
|
||||
if (samples.length <= 2) return samples.slice()
|
||||
const retained = new Set<number>([0, samples.length - 1])
|
||||
for (let i = 1; i < samples.length - 1; i++) if (forcedFrames.has(samples[i].frame)) retained.add(i)
|
||||
const anchors = () => [...retained].sort((a, b) => a - b)
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
const ordered = anchors()
|
||||
for (let segment = 0; segment < ordered.length - 1; segment++) {
|
||||
const startIndex = ordered[segment]
|
||||
const endIndex = ordered[segment + 1]
|
||||
let worstIndex = -1
|
||||
let worstError = 1
|
||||
for (let i = startIndex + 1; i < endIndex; i++) {
|
||||
const error = interpolationError(samples[i], samples[startIndex], samples[endIndex], tolerances)
|
||||
if (error > worstError) { worstError = error; worstIndex = i }
|
||||
}
|
||||
if (worstIndex >= 0) { retained.add(worstIndex); changed = true }
|
||||
}
|
||||
}
|
||||
return anchors().map((index) => samples[index])
|
||||
}
|
||||
|
||||
function endpointDerivative(samples: NumericSample[], start: number, end: number, dimension: number, atStart: boolean) {
|
||||
const count = end - start + 1
|
||||
if (count < 2) return 0
|
||||
const first = atStart ? start : end
|
||||
const second = atStart ? start + 1 : end - 1
|
||||
const third = atStart ? start + 2 : end - 2
|
||||
const h = Math.abs(samples[second].frame - samples[first].frame)
|
||||
if (count >= 3 && h > 0 && Math.abs(Math.abs(samples[third].frame - samples[second].frame) - h) < 1e-6) {
|
||||
const v0 = samples[first].values[dimension]
|
||||
const v1 = samples[second].values[dimension]
|
||||
const v2 = samples[third].values[dimension]
|
||||
return atStart ? (-3 * v0 + 4 * v1 - v2) / (2 * h) : (3 * v0 - 4 * v1 + v2) / (2 * h)
|
||||
}
|
||||
const frameSpan = samples[second].frame - samples[first].frame
|
||||
return frameSpan ? (samples[second].values[dimension] - samples[first].values[dimension]) / frameSpan : 0
|
||||
}
|
||||
|
||||
function bezierControls(samples: NumericSample[], start: number, end: number): BezierSegmentControls {
|
||||
const span = samples[end].frame - samples[start].frame
|
||||
const values: Array<[number, number]> = []
|
||||
for (let dimension = 0; dimension < samples[start].values.length; dimension++) {
|
||||
const startValue = samples[start].values[dimension]
|
||||
const endValue = samples[end].values[dimension]
|
||||
const min = Math.min(...samples.slice(start, end + 1).map((sample) => sample.values[dimension]))
|
||||
const max = Math.max(...samples.slice(start, end + 1).map((sample) => sample.values[dimension]))
|
||||
const first = startValue + endpointDerivative(samples, start, end, dimension, true) * span / 3
|
||||
const second = endValue - endpointDerivative(samples, start, end, dimension, false) * span / 3
|
||||
// 控制值限制在该段实际采样范围内,避免帧间过冲。
|
||||
values.push([Math.max(min, Math.min(max, first)), Math.max(min, Math.min(max, second))])
|
||||
}
|
||||
return { values }
|
||||
}
|
||||
|
||||
function cubicAt(start: number, control1: number, control2: number, end: number, amount: number) {
|
||||
const inverse = 1 - amount
|
||||
return inverse * inverse * inverse * start
|
||||
+ 3 * inverse * inverse * amount * control1
|
||||
+ 3 * inverse * amount * amount * control2
|
||||
+ amount * amount * amount * end
|
||||
}
|
||||
|
||||
function bezierInterpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, controls: BezierSegmentControls, tolerances: number[]) {
|
||||
const span = end.frame - start.frame
|
||||
const amount = span <= 0 ? 0 : (sample.frame - start.frame) / span
|
||||
let error = 0
|
||||
for (let dimension = 0; dimension < sample.values.length; dimension++) {
|
||||
const control = controls.values[dimension]
|
||||
const expected = cubicAt(start.values[dimension], control[0], control[1], end.values[dimension], amount)
|
||||
error = Math.max(error, Math.abs(sample.values[dimension] - expected) / Math.max(1e-9, tolerances[dimension] ?? tolerances[0] ?? 1e-3))
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
/** 使用三次贝塞尔拟合连续采样,并以原始逐帧数据验证每一段误差。 */
|
||||
export function reduceBezierSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set()): ReducedBezierSamples {
|
||||
if (samples.length <= 1) return { samples: samples.slice(), controls: [] }
|
||||
const retained = new Set<number>([0, samples.length - 1])
|
||||
for (let index = 1; index < samples.length - 1; index++) if (forcedFrames.has(samples[index].frame)) retained.add(index)
|
||||
const anchors = () => [...retained].sort((a, b) => a - b)
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
const ordered = anchors()
|
||||
for (let segment = 0; segment < ordered.length - 1; segment++) {
|
||||
const start = ordered[segment], end = ordered[segment + 1]
|
||||
if (end - start <= 1) continue
|
||||
const controls = bezierControls(samples, start, end)
|
||||
let worstIndex = -1, worstError = 1
|
||||
for (let index = start + 1; index < end; index++) {
|
||||
const error = bezierInterpolationError(samples[index], samples[start], samples[end], controls, tolerances)
|
||||
if (error > worstError) { worstError = error; worstIndex = index }
|
||||
}
|
||||
if (worstIndex >= 0) { retained.add(worstIndex); changed = true }
|
||||
}
|
||||
}
|
||||
const ordered = anchors()
|
||||
return {
|
||||
samples: ordered.map((index) => samples[index]),
|
||||
controls: ordered.slice(0, -1).map((start, index) => bezierControls(samples, start, ordered[index + 1])),
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapDegrees(values: number[]) {
|
||||
if (!values.length) return []
|
||||
const result = [values[0]]
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
let value = values[i]
|
||||
while (value - result[i - 1] > 180) value -= 360
|
||||
while (value - result[i - 1] < -180) value += 360
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function roundSpine(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
const rounded = Math.round(value * 100000) / 100000
|
||||
return Object.is(rounded, -0) ? 0 : rounded
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { EmitterConfig, TrailImageResource } from '../core/particleEmitter'
|
||||
import { roundSpine } from './spineKeyframeReducer'
|
||||
|
||||
export interface WeightedTrailMesh {
|
||||
type: 'mesh'
|
||||
path: string
|
||||
uvs: number[]
|
||||
triangles: number[]
|
||||
vertices: number[]
|
||||
hull: number
|
||||
edges: number[]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 生成由同级拖尾骨骼加权驱动的网格。每列至多受相邻两根骨骼影响。 */
|
||||
export function buildWeightedTrailMesh(config: EmitterConfig, resource: TrailImageResource, boneIndices: number[], path: string): WeightedTrailMesh {
|
||||
const rows = Math.max(2, Math.min(16, Math.round(Number(config.trailGridRows) || 2)))
|
||||
const cols = Math.max(2, Math.min(32, Math.round(Number(config.trailGridCols) || 2)))
|
||||
const count = Math.max(2, boneIndices.length)
|
||||
const length = resource.lengthMode === 'random'
|
||||
? Math.max(1, Number(resource.lengthMin) || 0, Number(resource.lengthMax) || 0)
|
||||
: Math.max(0.001, Number(resource.length) || 100)
|
||||
const width = resource.widthSizeMode === 'random'
|
||||
? Math.max(1, Number(resource.widthMin) || 0, Number(resource.widthMax) || 0)
|
||||
: Math.max(0.001, Number(resource.width) || 100)
|
||||
const angle = (Number(resource.rotation) || 0) * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const uvs: number[] = []
|
||||
const triangles: number[] = []
|
||||
const vertices: number[] = []
|
||||
const edges: number[] = []
|
||||
|
||||
// Spine 的 hull 表示 vertices/uvs 开头连续存放的外轮廓顶点数量。
|
||||
// 先按顺时针写入四条边,再追加内部网格点,保证重新导入编辑器时网格拓扑有效。
|
||||
const orderedGrid: Array<{ col: number; row: number }> = []
|
||||
for (let col = 0; col < cols; col++) orderedGrid.push({ col, row: 0 })
|
||||
for (let row = 1; row < rows; row++) orderedGrid.push({ col: cols - 1, row })
|
||||
for (let col = cols - 2; col >= 0; col--) orderedGrid.push({ col, row: rows - 1 })
|
||||
for (let row = rows - 2; row >= 1; row--) orderedGrid.push({ col: 0, row })
|
||||
const hull = orderedGrid.length
|
||||
for (let col = 1; col < cols - 1; col++) {
|
||||
for (let row = 1; row < rows - 1; row++) orderedGrid.push({ col, row })
|
||||
}
|
||||
const vertexIndices = new Map<string, number>()
|
||||
|
||||
for (let vertexIndex = 0; vertexIndex < orderedGrid.length; vertexIndex++) {
|
||||
const { col, row } = orderedGrid[vertexIndex]
|
||||
vertexIndices.set(`${col}:${row}`, vertexIndex)
|
||||
const columnT = col / (cols - 1)
|
||||
const position = columnT * (count - 1)
|
||||
const boneA = Math.min(count - 1, Math.floor(position))
|
||||
const boneB = Math.min(count - 1, boneA + 1)
|
||||
const blend = position - boneA
|
||||
const rowT = row / (rows - 1)
|
||||
const u = 0.5 + cos * (columnT - 0.5) - sin * (rowT - 0.5)
|
||||
const v = 0.5 + sin * (columnT - 0.5) + cos * (rowT - 0.5)
|
||||
uvs.push(roundSpine(u), roundSpine(v))
|
||||
const setupY = (0.5 - rowT) * width
|
||||
if (boneA === boneB || blend < 1e-6) {
|
||||
vertices.push(1, boneIndices[boneA], 0, roundSpine(setupY), 1)
|
||||
} else {
|
||||
vertices.push(
|
||||
2,
|
||||
boneIndices[boneA], 0, roundSpine(setupY), roundSpine(1 - blend),
|
||||
boneIndices[boneB], 0, roundSpine(setupY), roundSpine(blend),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let col = 0; col < cols - 1; col++) {
|
||||
for (let row = 0; row < rows - 1; row++) {
|
||||
const a = vertexIndices.get(`${col}:${row}`)!
|
||||
const b = vertexIndices.get(`${col + 1}:${row}`)!
|
||||
const c = vertexIndices.get(`${col}:${row + 1}`)!
|
||||
const d = vertexIndices.get(`${col + 1}:${row + 1}`)!
|
||||
triangles.push(a, b, c, b, d, c)
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < hull; index++) edges.push(index * 2, ((index + 1) % hull) * 2)
|
||||
return { type: 'mesh', path, uvs, triangles, vertices, hull, edges, width: length, height: width }
|
||||
}
|
||||
Reference in new issue
Block a user