From 58476a6468159f9bff64e827c6a86a51a0efde16 Mon Sep 17 00:00:00 2001 From: STREGA Date: Sat, 14 Oct 2023 19:29:49 -0400 Subject: [PATCH] Make Skeleton & SkeletalAnimation a cached resource --- .../ECS/3D Specific/Rig/Rig3DComponent.swift | 23 +- .../ECS/3D Specific/Rig/Rig3DSystem.swift | 4 +- .../ECS/StandardRenderingSystem.swift | 16 +- .../Importers/GLTransmissionFormat.swift | 16 +- Sources/GateEngine/Resources/Resource.swift | 6 + .../Resources/ResourceManager.swift | 10 +- .../Skinning/SkeletalAnimation.swift | 277 ++++++++++-- .../Resources/Skinning/Skeleton.swift | 412 ++++++++++++++---- .../GateEngine/Resources/Skinning/Skin.swift | 2 +- .../GateEngine/Resources/Tiles/TileMap.swift | 2 +- .../GateEngine/Resources/Tiles/TileSet.swift | 2 +- .../System/Rendering/Drawables/Scene.swift | 1 + 12 files changed, 626 insertions(+), 145 deletions(-) diff --git a/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DComponent.swift b/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DComponent.swift index 8829ad79..e38768df 100755 --- a/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DComponent.swift +++ b/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DComponent.swift @@ -5,7 +5,7 @@ * http://stregasgate.com */ -public class Rig3DComponent: Component { +@MainActor public final class Rig3DComponent: Component { public var disabled: Bool = false internal var deltaAccumulator: Float = 0 public var slowAnimationsPastDistance: Float = 20 @@ -37,15 +37,26 @@ public class Rig3DComponent: Component { public var updateColliderFromBoneNamed: String? = nil func update(deltaTime: Float, objectScale: Size3) { - activeAnimation?.update(deltaTime: deltaTime, objectScale: objectScale) - deltaAccumulator += deltaTime - blendingAccumulator += deltaTime + if let activeAnimation, activeAnimation.isReady { + activeAnimation.update(deltaTime: deltaTime, objectScale: objectScale) + deltaAccumulator += deltaTime + blendingAccumulator += deltaTime + } } - public class Animation { + @MainActor public final class Animation { public var subAnimations: [SubAnimation] var primaryIndex: Int = 0 + public var isReady: Bool { + for animation in subAnimations { + if animation.skeletalAnimation.state != .ready { + return false + } + } + return true + } + @inline(__always) public var progress: Float { get { @@ -146,7 +157,7 @@ public class Rig3DComponent: Component { self.subAnimations.append(a) } - public struct SubAnimation { + @MainActor public struct SubAnimation { public var skeletalAnimation: SkeletalAnimation public var skipJoints: [Skeleton.SkipJoint] diff --git a/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DSystem.swift b/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DSystem.swift index 6173c55d..c36d7054 100755 --- a/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DSystem.swift +++ b/Sources/GateEngine/ECS/3D Specific/Rig/Rig3DSystem.swift @@ -50,7 +50,7 @@ public final class Rig3DSystem: System { if let component = entity.component(ofType: Rig3DComponent.self), component.disabled == false { - if let animation = component.activeAnimation { + if let animation = component.activeAnimation, animation.isReady { if let scale = entity.component(ofType: Transform3Component.self)?.scale { component.update( deltaTime: deltaTime + component.deltaAccumulator, @@ -66,7 +66,7 @@ public final class Rig3DSystem: System { for animation in animation.subAnimations { component.skeleton.applyAnimation( animation.skeletalAnimation, - withTime: animation.accumulatedTime, + atTime: animation.accumulatedTime, duration: animation.duration, repeating: animation.repeats, skipJoints: animation.skipJoints, diff --git a/Sources/GateEngine/ECS/StandardRenderingSystem.swift b/Sources/GateEngine/ECS/StandardRenderingSystem.swift index a59a400a..0debbbdf 100644 --- a/Sources/GateEngine/ECS/StandardRenderingSystem.swift +++ b/Sources/GateEngine/ECS/StandardRenderingSystem.swift @@ -68,13 +68,15 @@ public final class StandardRenderingSystem: RenderingSystem { if let rigComponent = entity.component(ofType: Rig3DComponent.self) { for skinnedGeometry in renderingGeometry.skinnedGeometries { - scene.insert( - skinnedGeometry, - withPose: rigComponent.skeleton.getPose(), - material: material, - at: transform, - flags: renderingGeometry.flags - ) + if let pose = rigComponent.skeleton.getPose() { + scene.insert( + skinnedGeometry, + withPose: pose, + material: material, + at: transform, + flags: renderingGeometry.flags + ) + } } } } diff --git a/Sources/GateEngine/Resources/Importers/GLTransmissionFormat.swift b/Sources/GateEngine/Resources/Importers/GLTransmissionFormat.swift index d0278d01..d7b772c5 100644 --- a/Sources/GateEngine/Resources/Importers/GLTransmissionFormat.swift +++ b/Sources/GateEngine/Resources/Importers/GLTransmissionFormat.swift @@ -435,6 +435,10 @@ public class GLTransmissionFormat { } return false } + + public static func supportedFileExtensions() -> [String] { + return ["gltf", "glb"] + } } extension GLTransmissionFormat: GeometryImporter { @@ -657,7 +661,7 @@ extension GLTransmissionFormat: SkinImporter { } } -extension GLTransmissionFormat: SkeletonImporter { +extension GLTransmissionFormat: SkeletonImporter { private func skeletonNode(named name: String?, in gltf: GLTF) -> Int? { func findIn(_ parent: Int) -> Int? { let node = gltf.nodes[parent] @@ -687,9 +691,7 @@ extension GLTransmissionFormat: SkeletonImporter { return gltf.scenes[gltf.scene].nodes.first } - public func loadData(path: String, options: SkeletonImporterOptions) async throws -> Skeleton.Joint { - let baseURL = URL(string: path)!.deletingLastPathComponent() - let data = try await Game.shared.platform.loadResource(from: path) + public func process(data: Data, baseURL: URL, options: SkeletonImporterOptions) async throws -> Skeleton.Joint { let gltf = try gltf(from: data, baseURL: baseURL) guard let rootNode = skeletonNode(named: options.subobjectName, in: gltf) else { throw GateEngineError.failedToDecode("Couldn't find skeleton root.") @@ -721,9 +723,7 @@ extension GLTransmissionFormat: SkeletalAnimationImporter { return gltf.animations?.first } - public func loadData(path: String, options: SkeletalAnimationImporterOptions) async throws -> SkeletalAnimation { - let data = try await Game.shared.platform.loadResource(from: path) - let baseURL = URL(string: path)!.deletingLastPathComponent() + public func process(data: Data, baseURL: URL, options: SkeletalAnimationImporterOptions) async throws -> SkeletalAnimationBackend { let gltf = try gltf(from: data, baseURL: baseURL) guard let animation = animation(named: options.subobjectName, from: gltf) else { @@ -835,6 +835,6 @@ extension GLTransmissionFormat: SkeletalAnimationImporter { timeMax = .maximum(times.max()!, timeMax) } - return SkeletalAnimation(name: animation.name, duration: timeMax, animations: animations) + return SkeletalAnimationBackend(name: animation.name, duration: timeMax, animations: animations) } } diff --git a/Sources/GateEngine/Resources/Resource.swift b/Sources/GateEngine/Resources/Resource.swift index 7cb6ee7f..e79db771 100644 --- a/Sources/GateEngine/Resources/Resource.swift +++ b/Sources/GateEngine/Resources/Resource.swift @@ -45,10 +45,16 @@ public protocol Resource: Equatable, Hashable { It is a programming error to use a resource or access it's properties while it's state is anything other then `ready`. */ @MainActor var state: ResourceState { get } + @MainActor var isReady: Bool {get} @MainActor var cacheHint: CacheHint {get} } +public extension Resource { + @_transparent + @MainActor var isReady: Bool {self.state == .ready} +} + public enum ResourceState: Equatable { /// The resource isn't ready for use but may eventually become `ready` or `failed`. case pending diff --git a/Sources/GateEngine/Resources/ResourceManager.swift b/Sources/GateEngine/Resources/ResourceManager.swift index a6e5f880..2814b3c5 100644 --- a/Sources/GateEngine/Resources/ResourceManager.swift +++ b/Sources/GateEngine/Resources/ResourceManager.swift @@ -125,6 +125,7 @@ extension ResourceManager { var geometries: [GeometryKey: GeometryCache] = [:] var skinnedGeometries: [SkinnedGeometryKey: SkinnedGeometryCache] = [:] + var skeletons: [SkeletonKey: SkeletonCache] = [:] var skeletalAnimations: [SkeletalAnimationKey: SkeletalAnimationCache] = [:] var tileSets: [TileSetKey: TileSetCache] = [:] @@ -132,15 +133,6 @@ extension ResourceManager { var audioBuffers: [AudioBufferKey: AudioBufferCache] = [:] - // Skeleton - struct SkeletalAnimationKey: Hashable, Sendable { - let path: String - let options: SkeletalAnimationImporterOptions - } - struct SkeletalAnimationCache { - weak var skeletalAnimation: SkeletalAnimation? = nil - } - // AudioBuffer struct AudioBufferKey: Hashable, Sendable { let path: String diff --git a/Sources/GateEngine/Resources/Skinning/SkeletalAnimation.swift b/Sources/GateEngine/Resources/Skinning/SkeletalAnimation.swift index 08f53184..1351e86d 100644 --- a/Sources/GateEngine/Resources/Skinning/SkeletalAnimation.swift +++ b/Sources/GateEngine/Resources/Skinning/SkeletalAnimation.swift @@ -5,20 +5,75 @@ * http://stregasgate.com */ -public class SkeletalAnimation { - public let name: String - public let duration: Float - let animations: [Skeleton.Joint.ID: JointAnimation] +#if GATEENGINE_ENABLE_HOTRELOADING && GATEENGINE_PLATFORM_FOUNDATION_FILEMANAGER +import Foundation +#endif + +@MainActor public final class SkeletalAnimation { + internal let cacheKey: ResourceManager.Cache.SkeletalAnimationKey + + public var cacheHint: CacheHint { + get { Game.shared.resourceManager.skeletalAnimationCache(for: cacheKey)!.cacheHint } + set { Game.shared.resourceManager.changeCacheHint(newValue, for: cacheKey) } + } + public var state: ResourceState { + return Game.shared.resourceManager.skeletalAnimationCache(for: cacheKey)!.state + } + + @usableFromInline + internal var backend: SkeletalAnimationBackend { + assert(state == .ready, "This resource is not ready to be used. Make sure it's state property is .ready before accessing!") + return Game.shared.resourceManager.skeletalAnimationCache(for: cacheKey)!.skeletalAnimationBackend! + } + + public var name: String { + return backend.name + } + public var duration: Float { + return backend.duration + } + public var animations: [Skeleton.Joint.ID: SkeletalAnimation.JointAnimation] { + return backend.animations + } + + public init( + path: String, + options: SkeletalAnimationImporterOptions = .none + ) { + let resourceManager = Game.shared.resourceManager + self.cacheKey = resourceManager.skeletalAnimationCacheKey( + path: path, + options: options + ) + self.cacheHint = .until(minutes: 5) + resourceManager.incrementReference(self.cacheKey) + } + public init(name: String, duration: Float, animations: [Skeleton.Joint.ID: JointAnimation]) { - self.name = name - self.duration = duration - self.animations = animations + let resourceManager = Game.shared.resourceManager + self.cacheKey = resourceManager.skeletalAnimationCacheKey( + name: name, + duration: duration, + animations: animations + ) + self.cacheHint = .until(minutes: 5) + resourceManager.incrementReference(self.cacheKey) + } +} + +extension SkeletalAnimation: Equatable, Hashable { + nonisolated public static func == (lhs: SkeletalAnimation, rhs: SkeletalAnimation) -> Bool { + return lhs.cacheKey == rhs.cacheKey + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(cacheKey) } } extension SkeletalAnimation { - public class JointAnimation { + public final class JointAnimation { public enum Interpolation { case step case linear @@ -339,15 +394,26 @@ extension SkeletalAnimation { } } +public final class SkeletalAnimationBackend { + let name: String + let duration: Float + let animations: [Skeleton.Joint.ID: SkeletalAnimation.JointAnimation] + + init(name: String, duration: Float, animations: [Skeleton.Joint.ID: SkeletalAnimation.JointAnimation]) { + self.name = name + self.duration = duration + self.animations = animations + } +} // MARK: - Resource Manager public protocol SkeletalAnimationImporter: AnyObject { init() - func loadData(path: String, options: SkeletalAnimationImporterOptions) async throws -> SkeletalAnimation + func process(data: Data, baseURL: URL, options: SkeletalAnimationImporterOptions) async throws -> SkeletalAnimationBackend - static func canProcessFile(_ file: URL) -> Bool + static func supportedFileExtensions() -> [String] } public struct SkeletalAnimationImporterOptions: Equatable, Hashable, Sendable { @@ -370,9 +436,11 @@ extension ResourceManager { importers.skeletalAnimationImporters.insert(type, at: 0) } - fileprivate func importerForFile(_ file: URL) -> (any SkeletalAnimationImporter)? { + fileprivate func importerForFileType(_ file: String) -> (any SkeletalAnimationImporter)? { for type in self.importers.skeletalAnimationImporters { - if type.canProcessFile(file) { + if type.supportedFileExtensions().contains(where: { + $0.caseInsensitiveCompare(file) == .orderedSame + }) { return type.init() } } @@ -380,28 +448,177 @@ extension ResourceManager { } } -extension SkeletalAnimation { - public convenience init(path: String, options: SkeletalAnimationImporterOptions = .none) - async throws - { - let file = URL(fileURLWithPath: path) - guard - let importer: any SkeletalAnimationImporter = await Game.shared.resourceManager - .importerForFile(file) - else { - throw GateEngineError.failedToLoad("No importer for \(file.pathExtension).") +extension ResourceManager.Cache { + @usableFromInline + struct SkeletalAnimationKey: Hashable { + let requestedPath: String + let options: SkeletalAnimationImporterOptions + } + + @usableFromInline + final class SkeletalAnimationCache { + @usableFromInline var skeletalAnimationBackend: SkeletalAnimationBackend? + var lastLoaded: Date + var state: ResourceState + var referenceCount: UInt + var minutesDead: UInt + var cacheHint: CacheHint + init() { + self.skeletalAnimationBackend = nil + self.lastLoaded = Date() + self.state = .pending + self.referenceCount = 0 + self.minutesDead = 0 + self.cacheHint = .until(minutes: 5) + } + } +} +extension ResourceManager { + func changeCacheHint(_ cacheHint: CacheHint, for key: Cache.SkeletalAnimationKey) { + if let tileSetCache = cache.skeletalAnimations[key] { + tileSetCache.cacheHint = cacheHint + tileSetCache.minutesDead = 0 } + } + + func skeletalAnimationCacheKey(path: String, options: SkeletalAnimationImporterOptions) -> Cache.SkeletalAnimationKey { + let key = Cache.SkeletalAnimationKey(requestedPath: path, options: options) + if cache.skeletalAnimations[key] == nil { + cache.skeletalAnimations[key] = Cache.SkeletalAnimationCache() + self._reloadSkeletalAnimation(for: key, isFirstLoad: true) + } + return key + } + + func skeletalAnimationCacheKey( + name: String, + duration: Float, + animations: [Skeleton.Joint.ID: SkeletalAnimation.JointAnimation] + ) -> Cache.SkeletalAnimationKey { + let key = Cache.SkeletalAnimationKey(requestedPath: "$\(rawCacheIDGenerator.generateID())", options: .none) + if cache.skeletalAnimations[key] == nil { + cache.skeletalAnimations[key] = Cache.SkeletalAnimationCache() + Task.detached(priority: .low) { + let backend = SkeletalAnimationBackend( + name: name, + duration: duration, + animations: animations + ) + Task { @MainActor in + if let cache = self.cache.skeletalAnimations[key] { + cache.skeletalAnimationBackend = backend + cache.state = .ready + }else{ + Log.warn("Resource \"(Generated TileSet)\" was deallocated before being loaded.") + } + } + } + } + return key + } + + @usableFromInline + func skeletalAnimationCache(for key: Cache.SkeletalAnimationKey) -> Cache.SkeletalAnimationCache? { + return cache.skeletalAnimations[key] + } + + func incrementReference(_ key: Cache.SkeletalAnimationKey) { + self.skeletalAnimationCache(for: key)?.referenceCount += 1 + } + func decrementReference(_ key: Cache.SkeletalAnimationKey) { + guard let cache = self.skeletalAnimationCache(for: key) else {return} + cache.referenceCount -= 1 + + if case .whileReferenced = cache.cacheHint { + if cache.referenceCount == 0 { + self.cache.skeletalAnimations.removeValue(forKey: key) + Log.debug( + "Removing cache (no longer referenced), Object:", + key.requestedPath.first == "$" ? "(Generated TileSet)" : key.requestedPath + ) + } + } + } + + func reloadSkeletalAniamtionIfNeeded(key: Cache.SkeletalAnimationKey) { + // Skip if made from RawGeometry + guard key.requestedPath[key.requestedPath.startIndex] != "$" else { return } + Task.detached(priority: .low) { + guard self.skeletalAnimationNeedsReload(key: key) else { return } + self._reloadSkeletalAnimation(for: key, isFirstLoad: false) + } + } + + func _reloadSkeletalAnimation(for key: Cache.SkeletalAnimationKey, isFirstLoad: Bool) { + Task.detached(priority: .low) { + let path = key.requestedPath + + do { + guard let fileExtension = path.components(separatedBy: ".").last else { + throw GateEngineError.failedToLoad("Unknown file type.") + } + guard + let importer: any SkeletalAnimationImporter = await Game.shared.resourceManager + .importerForFileType(fileExtension) + else { + throw GateEngineError.failedToLoad("No importer for \(fileExtension).") + } + let data = try await Game.shared.platform.loadResource(from: path) + let backend = try await importer.process( + data: data, + baseURL: URL(string: path)!.deletingLastPathComponent(), + options: key.options + ) + + Task { @MainActor in + if let cache = self.cache.skeletalAnimations[key] { + cache.skeletalAnimationBackend = backend + cache.state = .ready + }else{ + Log.warn("Resource \"\(path)\" was deallocated before being " + (isFirstLoad ? "loaded." : "re-loaded.")) + } + } + } catch let error as GateEngineError { + Task { @MainActor in + Log.warn("Resource \"\(path)\"", error) + if let cache = self.cache.skeletalAnimations[key] { + cache.state = .failed(error: error) + } + } + } catch let error as DecodingError { + let error = GateEngineError(error) + Task { @MainActor in + Log.warn("Resource \"\(path)\"", error) + if let cache = self.cache.skeletalAnimations[key] { + cache.state = .failed(error: error) + } + } + } catch { + Log.fatalError("error must be a GateEngineError") + } + } + } + + func skeletalAnimationNeedsReload(key: Cache.SkeletalAnimationKey) -> Bool { + #if GATEENGINE_ENABLE_HOTRELOADING && GATEENGINE_PLATFORM_FOUNDATION_FILEMANAGER + // Skip if made from RawGeometry + guard key.requestedPath[key.requestedPath.startIndex] != "$" else { return false } + guard let cache = cache.skeletalAnimations[key] else { return false } do { - let animation = try await importer.loadData(path: path, options: options) - self.init( - name: animation.name, - duration: animation.duration, - animations: animation.animations - ) + let attributes = try FileManager.default.attributesOfItem(atPath: key.requestedPath) + if let modified = (attributes[.modificationDate] ?? attributes[.creationDate]) as? Date + { + return modified > cache.lastLoaded + } else { + return false + } } catch { - throw GateEngineError(decodingError: error) + Log.error(error) + return false } + #else + return false + #endif } } - diff --git a/Sources/GateEngine/Resources/Skinning/Skeleton.swift b/Sources/GateEngine/Resources/Skinning/Skeleton.swift index e819ffd1..bd5b2717 100755 --- a/Sources/GateEngine/Resources/Skinning/Skeleton.swift +++ b/Sources/GateEngine/Resources/Skinning/Skeleton.swift @@ -5,22 +5,100 @@ * http://stregasgate.com */ -public final class Skeleton: OldResource { - internal let path: String? - internal let options: SkeletonImporterOptions? +#if GATEENGINE_ENABLE_HOTRELOADING && GATEENGINE_PLATFORM_FOUNDATION_FILEMANAGER +import Foundation +#endif + +@MainActor public final class Skeleton: Resource { + internal let cacheKey: ResourceManager.Cache.SkeletonKey + + public var cacheHint: CacheHint { + get { Game.shared.resourceManager.skeletonCache(for: cacheKey)!.cacheHint } + set { Game.shared.resourceManager.changeCacheHint(newValue, for: cacheKey) } + } - @RequiresState(.ready) - internal var rootJoint: Skeleton.Joint! = nil - @RequiresState(.ready) - internal var bindPose: Pose! = nil + public var state: ResourceState { + return Game.shared.resourceManager.skeletonCache(for: cacheKey)!.state + } + + @usableFromInline + internal var backend: SkeletonBackend { + assert(state == .ready, "This resource is not ready to be used. Make sure it's state property is .ready before accessing!") + return Game.shared.resourceManager.skeletonCache(for: cacheKey)!.skeletonBackend! + } - public func getPose() -> Pose { - self.updateIfNeeded() - return Pose(self.rootJoint) + public func getPose() -> Pose? { + guard self.isReady else {return nil} + return backend.getPose() } - private var jointIDCache: [Int: Joint] = [:] public func jointWithID(_ id: Skeleton.Joint.ID) -> Joint? { + return backend.jointWithID(id) + } + + public func jointNamed(_ name: String) -> Joint? { + return backend.jointNamed(name) + } + + @usableFromInline + internal func updateIfNeeded() { + self.backend.updateIfNeeded() + } + + public init( + path: String, + options: SkeletonImporterOptions = .none + ) { + let resourceManager = Game.shared.resourceManager + self.cacheKey = resourceManager.skeletonCacheKey( + path: path, + options: options + ) + self.cacheHint = .until(minutes: 5) + resourceManager.incrementReference(self.cacheKey) + } + + public init(rootjoint: Skeleton.Joint) { + let resourceManager = Game.shared.resourceManager + self.cacheKey = resourceManager.skeletonCacheKey(rootJoint: rootjoint) + self.cacheHint = .until(minutes: 5) + resourceManager.incrementReference(self.cacheKey) + } + + deinit { + let cacheKey = self.cacheKey + Task.detached(priority: .low) { @MainActor in + Game.shared.resourceManager.decrementReference(cacheKey) + } + } +} + +extension Skeleton: Equatable, Hashable { + nonisolated public static func == (lhs: Skeleton, rhs: Skeleton) -> Bool { + return lhs.cacheKey == rhs.cacheKey + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(cacheKey) + } +} + +@usableFromInline +final class SkeletonBackend { + @usableFromInline + let rootJoint: Skeleton.Joint + @usableFromInline + let bindPose: Skeleton.Pose + + @usableFromInline + func getPose() -> Skeleton.Pose { + self.updateIfNeeded() + return Skeleton.Pose(self.rootJoint) + } + + private var jointIDCache: [Int: Skeleton.Joint] = [:] + @usableFromInline + func jointWithID(_ id: Skeleton.Joint.ID) -> Skeleton.Joint? { if let cached = jointIDCache[id] { return cached } @@ -31,8 +109,9 @@ public final class Skeleton: OldResource { return nil } - private var jointNameCache: [String: Joint] = [:] - public func jointNamed(_ name: String) -> Joint? { + private var jointNameCache: [String: Skeleton.Joint] = [:] + @usableFromInline + func jointNamed(_ name: String) -> Skeleton.Joint? { if let cached = jointNameCache[name] { return cached } @@ -43,23 +122,10 @@ public final class Skeleton: OldResource { return nil } - public init(rootJoint joint: Skeleton.Joint) { - self.path = nil - self.options = nil - self.rootJoint = joint - self.bindPose = Pose(joint) - super.init() - self.state = .ready - - #if DEBUG - self._bindPose.configure(withOwner: self) - self._rootJoint.configure(withOwner: self) - #endif - } - + @usableFromInline - internal func updateIfNeeded() { - func update(joint: Joint) { + func updateIfNeeded() { + func update(joint: Skeleton.Joint) { joint.updateIfNeeded() for child in joint.children { update(joint: child) @@ -67,40 +133,9 @@ public final class Skeleton: OldResource { } update(joint: rootJoint) } -} - -extension Skeleton { - public struct SkipJoint: ExpressibleByStringLiteral { - public typealias StringLiteralType = String - public var name: StringLiteralType - public var method: Method - public enum Method { - case justThis - case includingChildren - } - - public init(stringLiteral: StringLiteralType) { - self.name = stringLiteral - self.method = .includingChildren - } - - public init(name: StringLiteralType, method: Method) { - self.name = name - self.method = method - } - - public static func named(_ name: String, _ method: Method) -> Self { - return Self(name: name, method: method) - } - } -} - -extension Skeleton { - public func applyBindPose() { - self.applyPose(bindPose) - } - - public func applyPose(_ pose: Pose) { + + @usableFromInline + func applyPose(_ pose: Skeleton.Pose) { func applyToJoint(_ joint: Skeleton.Joint) { if let poseJoint = pose.jointWithID(joint.id) { joint.localTransform.position = poseJoint.localTransform.position @@ -113,13 +148,14 @@ extension Skeleton { } applyToJoint(rootJoint) } - - public func applyAnimation( + + @usableFromInline + @MainActor func applyAnimation( _ skeletalAnimation: SkeletalAnimation, - withTime time: Float, + atTime time: Float, duration: Float, repeating: Bool, - skipJoints: [SkipJoint], + skipJoints: [Skeleton.SkipJoint], interpolateProgress: Float ) { @@ -177,10 +213,73 @@ extension Skeleton { } applyToJoint(rootJoint) } + + init(rootJoint joint: Skeleton.Joint) { + self.rootJoint = joint + self.bindPose = Skeleton.Pose(joint) + } + +} + +extension Skeleton { + public struct SkipJoint: ExpressibleByStringLiteral { + public typealias StringLiteralType = String + public var name: StringLiteralType + public var method: Method + public enum Method { + case justThis + case includingChildren + } + + public init(stringLiteral: StringLiteralType) { + self.name = stringLiteral + self.method = .includingChildren + } + + public init(name: StringLiteralType, method: Method) { + self.name = name + self.method = method + } + + public static func named(_ name: String, _ method: Method) -> Self { + return Self(name: name, method: method) + } + } +} + +extension Skeleton { + @inlinable + public func applyBindPose() { + self.applyPose(backend.bindPose) + } + + @inlinable + public func applyPose(_ pose: Pose) { + self.backend.applyPose(pose) + } + + @inlinable + public func applyAnimation( + _ skeletalAnimation: SkeletalAnimation, + atTime time: Float, + duration: Float, + repeating: Bool, + skipJoints: [Skeleton.SkipJoint], + interpolateProgress: Float + ) { + self.backend.applyAnimation( + skeletalAnimation, + atTime: time, + duration: duration, + repeating: repeating, + skipJoints: skipJoints, + interpolateProgress: interpolateProgress + ) + } } extension Skeleton { - public final class Joint { + public final class Joint: Identifiable { public typealias ID = Int public let id: ID public let name: String? @@ -397,9 +496,9 @@ extension Skeleton.Pose.Joint: Hashable { public protocol SkeletonImporter: AnyObject { init() - func loadData(path: String, options: SkeletonImporterOptions) async throws -> Skeleton.Joint + func process(data: Data, baseURL: URL, options: SkeletonImporterOptions) async throws -> Skeleton.Joint - static func canProcessFile(_ file: URL) -> Bool + static func supportedFileExtensions() -> [String] } public struct SkeletonImporterOptions: Equatable, Hashable { @@ -420,9 +519,11 @@ extension ResourceManager { importers.skeletonImporters.insert(type, at: 0) } - internal func skeletonImporterForFile(_ file: URL) -> (any SkeletonImporter)? { + fileprivate func importerForFileType(_ file: String) -> (any SkeletonImporter)? { for type in self.importers.skeletonImporters { - if type.canProcessFile(file) { + if type.supportedFileExtensions().contains(where: { + $0.caseInsensitiveCompare(file) == .orderedSame + }) { return type.init() } } @@ -430,18 +531,169 @@ extension ResourceManager { } } -extension Skeleton { - public convenience init(path: String, options: SkeletonImporterOptions = .none) async throws { - let file = URL(fileURLWithPath: path) - guard let importer: any SkeletonImporter = await Game.shared.resourceManager.skeletonImporterForFile(file) else { - throw GateEngineError.failedToLoad("No importer for \(file.pathExtension).") +extension ResourceManager.Cache { + @usableFromInline + struct SkeletonKey: Hashable { + let requestedPath: String + let options: SkeletonImporterOptions + } + + @usableFromInline + class SkeletonCache { + @usableFromInline var skeletonBackend: SkeletonBackend? + var lastLoaded: Date + var state: ResourceState + var referenceCount: UInt + var minutesDead: UInt + var cacheHint: CacheHint + init() { + self.skeletonBackend = nil + self.lastLoaded = Date() + self.state = .pending + self.referenceCount = 0 + self.minutesDead = 0 + self.cacheHint = .until(minutes: 5) } + } +} +extension ResourceManager { + func changeCacheHint(_ cacheHint: CacheHint, for key: Cache.SkeletonKey) { + if let tileSetCache = cache.skeletons[key] { + tileSetCache.cacheHint = cacheHint + tileSetCache.minutesDead = 0 + } + } + + func skeletonCacheKey(path: String, options: SkeletonImporterOptions) -> Cache.SkeletonKey { + let key = Cache.SkeletonKey(requestedPath: path, options: options) + if cache.skeletons[key] == nil { + cache.skeletons[key] = Cache.SkeletonCache() + self._reloadSkeleton(for: key, isFirstLoad: true) + } + return key + } + + func skeletonCacheKey(rootJoint: Skeleton.Joint) -> Cache.SkeletonKey { + let key = Cache.SkeletonKey(requestedPath: "$\(rawCacheIDGenerator.generateID())", options: .none) + if cache.skeletons[key] == nil { + cache.skeletons[key] = Cache.SkeletonCache() + Task.detached(priority: .low) { + let backend = SkeletonBackend(rootJoint: rootJoint) + Task { @MainActor in + if let cache = self.cache.skeletons[key] { + cache.skeletonBackend = backend + cache.state = .ready + }else{ + Log.warn("Resource \"(Generated TileSet)\" was deallocated before being loaded.") + } + } + } + } + return key + } + + @usableFromInline + func skeletonCache(for key: Cache.SkeletonKey) -> Cache.SkeletonCache? { + return cache.skeletons[key] + } + + func incrementReference(_ key: Cache.SkeletonKey) { + self.skeletonCache(for: key)?.referenceCount += 1 + } + func decrementReference(_ key: Cache.SkeletonKey) { + guard let cache = self.skeletonCache(for: key) else {return} + cache.referenceCount -= 1 + + if case .whileReferenced = cache.cacheHint { + if cache.referenceCount == 0 { + self.cache.skeletons.removeValue(forKey: key) + Log.debug( + "Removing cache (no longer referenced), Object:", + key.requestedPath.first == "$" ? "(Generated TileSet)" : key.requestedPath + ) + } + } + } + + func reloadSkeletonIfNeeded(key: Cache.SkeletonKey) { + // Skip if made from RawGeometry + guard key.requestedPath[key.requestedPath.startIndex] != "$" else { return } + Task.detached(priority: .low) { + guard self.skeletonNeedsReload(key: key) else { return } + self._reloadSkeleton(for: key, isFirstLoad: false) + } + } + + func _reloadSkeleton(for key: Cache.SkeletonKey, isFirstLoad: Bool) { + Task.detached(priority: .low) { + let path = key.requestedPath + + do { + guard let fileExtension = path.components(separatedBy: ".").last else { + throw GateEngineError.failedToLoad("Unknown file type.") + } + guard + let importer: any SkeletonImporter = await Game.shared.resourceManager + .importerForFileType(fileExtension) + else { + throw GateEngineError.failedToLoad("No importer for \(fileExtension).") + } + + let data = try await Game.shared.platform.loadResource(from: path) + let rootJoint: Skeleton.Joint = try await importer.process( + data: data, + baseURL: URL(string: path)!.deletingLastPathComponent(), + options: key.options + ) + Task { @MainActor in + if let cache = self.cache.skeletons[key] { + cache.skeletonBackend = SkeletonBackend(rootJoint: rootJoint) + cache.state = .ready + }else{ + Log.warn("Resource \"\(path)\" was deallocated before being " + (isFirstLoad ? "loaded." : "re-loaded.")) + } + } + } catch let error as GateEngineError { + Task { @MainActor in + Log.warn("Resource \"\(path)\"", error) + if let cache = self.cache.skeletons[key] { + cache.state = .failed(error: error) + } + } + } catch let error as DecodingError { + let error = GateEngineError(error) + Task { @MainActor in + Log.warn("Resource \"\(path)\"", error) + if let cache = self.cache.skeletons[key] { + cache.state = .failed(error: error) + } + } + } catch { + Log.fatalError("error must be a GateEngineError") + } + } + } + + func skeletonNeedsReload(key: Cache.SkeletonKey) -> Bool { + #if GATEENGINE_ENABLE_HOTRELOADING && GATEENGINE_PLATFORM_FOUNDATION_FILEMANAGER + // Skip if made from RawGeometry + guard key.requestedPath[key.requestedPath.startIndex] != "$" else { return false } + guard let cache = cache.skeletons[key] else { return false } do { - let rootJoint = try await importer.loadData(path: path, options: options) - self.init(rootJoint: rootJoint) + let attributes = try FileManager.default.attributesOfItem(atPath: key.requestedPath) + if let modified = (attributes[.modificationDate] ?? attributes[.creationDate]) as? Date + { + return modified > cache.lastLoaded + } else { + return false + } } catch { - throw GateEngineError(decodingError: error) + Log.error(error) + return false } + #else + return false + #endif } } diff --git a/Sources/GateEngine/Resources/Skinning/Skin.swift b/Sources/GateEngine/Resources/Skinning/Skin.swift index 91fdf1a6..b0c2ac0c 100755 --- a/Sources/GateEngine/Resources/Skinning/Skin.swift +++ b/Sources/GateEngine/Resources/Skinning/Skin.swift @@ -85,7 +85,7 @@ extension Skin { do { self = try await importer.loadData(path: path, options: options) } catch { - throw GateEngineError(decodingError: error) + throw GateEngineError(error) } } } diff --git a/Sources/GateEngine/Resources/Tiles/TileMap.swift b/Sources/GateEngine/Resources/Tiles/TileMap.swift index 6820be7e..08c9ee67 100644 --- a/Sources/GateEngine/Resources/Tiles/TileMap.swift +++ b/Sources/GateEngine/Resources/Tiles/TileMap.swift @@ -10,7 +10,7 @@ import Foundation #endif import GameMath -@MainActor public class TileMap: Resource { +@MainActor public final class TileMap: Resource { internal let cacheKey: ResourceManager.Cache.TileMapKey public var cacheHint: CacheHint { diff --git a/Sources/GateEngine/Resources/Tiles/TileSet.swift b/Sources/GateEngine/Resources/Tiles/TileSet.swift index cebd6c88..d9c5b303 100644 --- a/Sources/GateEngine/Resources/Tiles/TileSet.swift +++ b/Sources/GateEngine/Resources/Tiles/TileSet.swift @@ -336,7 +336,7 @@ extension ResourceManager { } } } catch let error as DecodingError { - let error = GateEngineError(decodingError: error) + let error = GateEngineError(error) Task { @MainActor in Log.warn("Resource \"\(path)\"", error) if let cache = self.cache.tileSets[key] { diff --git a/Sources/GateEngine/System/Rendering/Drawables/Scene.swift b/Sources/GateEngine/System/Rendering/Drawables/Scene.swift index 08f26ed6..cc4bbf59 100644 --- a/Sources/GateEngine/System/Rendering/Drawables/Scene.swift +++ b/Sources/GateEngine/System/Rendering/Drawables/Scene.swift @@ -122,6 +122,7 @@ at transforms: [Transform3], flags: SceneElementFlags = .default ) { + guard skinnedGeometry.isReady else {return} var material = material material.vertexShader = .skinned material.setCustomUniformValue(