Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,37 @@ public class DerivedAssetNodeComponent: Component {
}
}

// MARK: - LOD Component

public struct LODLevel {
public var mesh: [Mesh] // Meshes for this lod
public var maxDistance: Float // Switch to next LOD beyond this
public var screenPercentage: Float // Optional: screen-space threshold
public var url: URL? // URL to the LOD file (for display purposes)
}

public class LODComponent: Component {
public var lodLevels: [LODLevel] = [] // Sorted by distance (LOD0 first)
public var currentLOD: Int = 0 // Active LOD index
public var fadeTransition: Bool = false // Enable cross-fade
public var transitionDuration: Float = 0.3 // Fade time in seconds
public var transitionProgress: Float = 0.0 // Current fade (0-1)
public var previousLOD: Int? // For blending
public var forcedLOD: Int? // Manual override (-1 = auto)

public required init() {}
}

// MARK: Static Batching Component

public class StaticBatchComponent: Component {
public var isStatic: Bool = true // Object doesn't move
public var batchGroupId: UUID? // Assigned during batching
public var canBatch: Bool = true // User can disable batching per object

public required init() {}
}

// MARK: - USC Scripting Component

public class ScriptComponent: Component, Codable {
Expand Down
336 changes: 336 additions & 0 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Sources/UntoldEngine/Renderer/UntoldEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {
traverseSceneGraph()
handleInputCallback?()

LODSystem.shared.update(deltaTime: fixedStep)

if gameMode == true {
AnimationSystem.shared.update(timeSinceLastUpdate)

Expand Down
160 changes: 160 additions & 0 deletions Sources/UntoldEngine/Scenes/SceneSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ struct AssetInstanceData: Codable {
var overrides: [AssetOverrideData]
}

// MARK: - LOD Data

struct LODLevelData: Codable {
var url: URL
var maxDistance: Float
var screenPercentage: Float
}

struct LODData: Codable {
var lodLevels: [LODLevelData]
var currentLOD: Int
var fadeTransition: Bool
var transitionDuration: Float
}

struct EntityData: Codable {
var uuid: UUID = .init() // Unique identifier for this entity
var parentUUID: UUID? = nil // UUID of the parent entity, if any
Expand All @@ -165,11 +180,16 @@ struct EntityData: Codable {
var hasSpotLightComponent: Bool?
var hasAreaLightComponent: Bool?
var hasCameraComponent: Bool?
var hasLODComponent: Bool?
var hasStaticBatchComponent: Bool?

var customComponents: [String: Data]? = nil

// New Asset Instance system
var assetInstance: AssetInstanceData? = nil

// LOD system
var lodData: LODData? = nil
}

private func isProceduralAssetURL(_ url: URL) -> Bool {
Expand Down Expand Up @@ -386,6 +406,60 @@ public func serializeScene() -> SceneData {
entityData.cameraData?.up = getCameraUp(entityId: entityId)
}

// LOD properties
let hasLOD: Bool = hasComponent(entityId: entityId, componentType: LODComponent.self)

if hasLOD {
entityData.hasLODComponent = hasLOD

if let lodComponent = scene.get(component: LODComponent.self, for: entityId) {
var lodLevelsData: [LODLevelData] = []

for lodLevel in lodComponent.lodLevels {
// Only serialize if URL is available
if let url = lodLevel.url {
let lodLevelData = LODLevelData(
url: url,
maxDistance: lodLevel.maxDistance,
screenPercentage: lodLevel.screenPercentage
)
lodLevelsData.append(lodLevelData)
}
}

if !lodLevelsData.isEmpty {
entityData.lodData = LODData(
lodLevels: lodLevelsData,
currentLOD: lodComponent.currentLOD,
fadeTransition: lodComponent.fadeTransition,
transitionDuration: lodComponent.transitionDuration
)
}
}
}

// Static Batch properties
// Check if entity or any of its children have StaticBatchComponent
func hasStaticBatchInHierarchy(entityId: EntityID) -> Bool {
// Check self
if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) {
return true
}
// Check children recursively
let children = getEntityChildren(parentId: entityId)
for child in children {
if hasStaticBatchInHierarchy(entityId: child) {
return true
}
}
return false
}

// Only set the flag if true (leave as nil otherwise)
if hasStaticBatchInHierarchy(entityId: entityId) {
entityData.hasStaticBatchComponent = true
}

// custom component
var customComponents: [String: Data] = [:]

Expand Down Expand Up @@ -666,6 +740,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM
// Apply overrides synchronously after import
applyAssetInstanceOverrides(entityId: entityId, overrides: assetInstance.overrides)

// Restore Static Batch Component (sync mode - mesh already loaded)
if sceneDataEntity.hasStaticBatchComponent == true {
setEntityStaticBatchComponent(entityId: entityId)
}

// Setup animations (skeleton is now available)
if sceneDataEntity.hasAnimationComponent == true {
for animations in sceneDataEntity.animations {
Expand All @@ -687,6 +766,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM
// Apply overrides after async import completes
applyAssetInstanceOverrides(entityId: entityId, overrides: assetInstance.overrides)

// Restore Static Batch Component (meshes now loaded)
if sceneDataEntity.hasStaticBatchComponent == true {
setEntityStaticBatchComponent(entityId: entityId)
}

// Setup animations (skeleton is now available)
if sceneDataEntity.hasAnimationComponent == true {
for animations in sceneDataEntity.animations {
Expand Down Expand Up @@ -717,9 +801,19 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM
let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName)
setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: sceneDataEntity.assetName)
applyLocalTransform()

// Restore Static Batch Component (procedural mesh already loaded)
if sceneDataEntity.hasStaticBatchComponent == true {
setEntityStaticBatchComponent(entityId: entityId)
}
} else {
setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName)
applyLocalTransform()

// Restore Static Batch Component (sync mode - mesh already loaded)
if sceneDataEntity.hasStaticBatchComponent == true {
setEntityStaticBatchComponent(entityId: entityId)
}
}

// Setup animations (skeleton is now available)
Expand All @@ -739,6 +833,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM
let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName)
setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: sceneDataEntity.assetName)
applyLocalTransform()

// Restore Static Batch Component (procedural mesh already loaded)
if sceneDataEntity.hasStaticBatchComponent == true {
setEntityStaticBatchComponent(entityId: entityId)
}
} else {
let fallbackLabel = withExtension.isEmpty ? filename : "\(filename).\(withExtension)"
let meshLabel = sceneDataEntity.name.isEmpty ? fallbackLabel : sceneDataEntity.name
Expand All @@ -749,6 +848,11 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM
if success {
Logger.log(message: "✅ Mesh loaded for \(meshLabel)")

// Restore Static Batch Component (mesh now loaded)
if sceneDataEntity.hasStaticBatchComponent == true {
setEntityStaticBatchComponent(entityId: entityId)
}

// Setup animations (skeleton is now available)
if sceneDataEntity.hasAnimationComponent == true {
for animations in sceneDataEntity.animations {
Expand Down Expand Up @@ -987,6 +1091,62 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM
}
}

// LOD Component
if sceneDataEntity.hasLODComponent == true {
if let lodData = sceneDataEntity.lodData {
switch meshLoadingMode {
case .sync:
// Synchronous LOD loading not yet implemented
Logger.logWarning(message: "[SceneSerializer] Synchronous LOD loading not supported, skipping LOD for '\(sceneDataEntity.name)'")
case .asyncDefault:
// Register LOD component first
setEntityLodComponent(entityId: entityId)

// Track completion
var loadedCount = 0
let totalLevels = lodData.lodLevels.count

// Load each LOD level using the granular API
for (index, lodLevelData) in lodData.lodLevels.enumerated() {
let url = lodLevelData.url
let filename = url.deletingPathExtension().lastPathComponent
let ext = url.pathExtension
let maxDistance = lodLevelData.maxDistance

addLODLevel(
entityId: entityId,
lodIndex: index,
fileName: filename,
withExtension: ext,
maxDistance: maxDistance
) { success in
if success {
loadedCount += 1
// When all levels are loaded, restore LOD settings
if loadedCount == totalLevels {
Logger.log(message: "✅ LOD loaded for '\(sceneDataEntity.name)' with \(totalLevels) levels")
Task {
await MainActor.run {
if let lodComponent = scene.get(component: LODComponent.self, for: entityId) {
lodComponent.currentLOD = lodData.currentLOD
lodComponent.fadeTransition = lodData.fadeTransition
lodComponent.transitionDuration = lodData.transitionDuration
}
}
}
}
} else {
Logger.logWarning(message: "⚠️ Failed to load LOD level \(index) for '\(sceneDataEntity.name)'")
}
}
}
}
}
}

// Static Batch Component is now restored inside mesh loading completion handlers
// (moved there to ensure RenderComponent exists before adding StaticBatchComponent)

// custom components
if let customComponents = sceneDataEntity.customComponents {
for (typeName, jsonData) in customComponents {
Expand Down
Loading
Loading