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
36 changes: 21 additions & 15 deletions App/Render/CarAO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,34 @@ enum CarAO {
/// Podpina atlas w materiałach nieprzezroczystych siatek, które mają UV1 (szyby zostają bez AO —
/// przyciemniałyby się od wnętrza auta). Przed scaleniem siatek, żeby części z tym samym materiałem
/// dalej się łączyły.
static func apply(_ atlas: TextureResource, to root: Entity, file: String) {
/// Kawałkami (`MainThreadSlices`): odczyt zawartości siatek kosztował w jednym ciągu do 250 ms na auto.
static func apply(_ atlas: TextureResource, to root: Entity, file: String) async {
var texture = MaterialParameters.Texture(atlas)
texture.uvIndex = 1
var count = 0, skipped = 0
var models: [ModelEntity] = []
func walk(_ e: Entity) {
if let m = e as? ModelEntity, var model = m.model {
let parts = model.mesh.contents.models.flatMap { Array($0.parts) }
if !parts.isEmpty, parts.allSatisfy({ $0[uv1] != nil }) {
model.materials = model.materials.map { material in
guard var p = material as? PhysicallyBasedMaterial, case .opaque = p.blending else { return material }
p.ambientOcclusion = .init(texture: texture)
return p
}
m.model = model
count += 1
} else {
skipped += 1
}
}
if let m = e as? ModelEntity, m.model != nil { models.append(m) }
for c in e.children { walk(c) }
}
walk(root)
let slices = MainThreadSlices()
for m in models {
guard var model = m.model else { continue }
let parts = model.mesh.contents.models.flatMap { Array($0.parts) }
if !parts.isEmpty, parts.allSatisfy({ $0[uv1] != nil }) {
model.materials = model.materials.map { material in
guard var p = material as? PhysicallyBasedMaterial, case .opaque = p.blending else { return material }
p.ambientOcclusion = .init(texture: texture)
return p
}
m.model = model
count += 1
} else {
skipped += 1
}
await slices.pause()
}
debugLog("model: \(file) — AO: \(count) części" + (skipped > 0 ? ", bez UV1: \(skipped)" : ""))
}
}
40 changes: 31 additions & 9 deletions App/Render/CarMeshMerge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ enum CarMeshMerge {
static let paintPrefix = "merged_paint"

/// Zwraca nowy wzorzec (te same współrzędne co `template`) albo nil, gdy scalanie się nie udało.
static func merge(_ template: Entity, paintNames: Set<String>, file: String) -> Entity? {
/// Części zbiera kawałkami (`MainThreadSlices`): w jednym ciągu blokowało to wątek główny na 40–510 ms
/// i obrotnica w menu stawała przy każdym wczytanym aucie.
static func merge(_ template: Entity, paintNames: Set<String>, file: String) async -> Entity? {
let wheelGroups = CarEntity.realisticWheelGroups(template, space: template)
var wheelOf: [ObjectIdentifier: Int] = [:]
for (i, g) in (wheelGroups ?? []).enumerated() {
Expand All @@ -32,19 +34,25 @@ enum CarMeshMerge {
struct Key: Hashable { var wheel: Int; var paint: Bool }
var buckets: [Key: Bucket] = [:]
var sourceCount = 0
// Najpierw sama lista siatek (tanie przejście po drzewie), potem praca nad każdą z przerwami na klatkę.
var sources: [(ModelEntity, Bool)] = []
func walk(_ e: Entity, inPaint: Bool) {
let paint = inPaint || paintNames.contains(e.name)
if let m = e as? ModelEntity, m.isEnabled, let model = m.model {
sourceCount += 1
let wheel = wheelOf[ObjectIdentifier(m)] ?? -1
// Część koła lakierujemy tylko wymienioną z nazwy — opona podpięta w pliku pod lakierowaną
// karoserię dziedziczyła lakier (czerwona opona SLS).
let key = Key(wheel: wheel, paint: wheel >= 0 ? paintNames.contains(e.name) : paint)
buckets[key, default: Bucket()].add(model, transform: m.transformMatrix(relativeTo: template))
}
if let m = e as? ModelEntity, m.isEnabled, m.model != nil { sources.append((m, paint)) }
for c in e.children { walk(c, inPaint: paint) }
}
walk(template, inPaint: false)
let slices = MainThreadSlices()
for (m, paint) in sources {
guard let model = m.model else { continue }
sourceCount += 1
let wheel = wheelOf[ObjectIdentifier(m)] ?? -1
// Część koła lakierujemy tylko wymienioną z nazwy — opona podpięta w pliku pod lakierowaną
// karoserię dziedziczyła lakier (czerwona opona SLS).
let key = Key(wheel: wheel, paint: wheel >= 0 ? paintNames.contains(m.name) : paint)
buckets[key, default: Bucket()].add(model, transform: m.transformMatrix(relativeTo: template))
await slices.pause()
}

let root = Entity()
root.name = template.name
Expand Down Expand Up @@ -251,3 +259,17 @@ private func safeNormalize(_ v: SIMD3<Float>) -> SIMD3<Float> {
let l = simd_length(v)
return l > 1e-12 ? v / l : [0, 1, 0]
}

/// Długa praca na wątku głównym w kawałkach: po `budget` sekund oddaje wątek na chwilę, żeby RealityKit
/// narysował klatkę (sam `Task.yield()` nie gwarantuje obrotu pętli zdarzeń).
@MainActor
final class MainThreadSlices {
private static let budget = 0.006
private var sliceStart = ProcessInfo.processInfo.systemUptime

func pause() async {
guard ProcessInfo.processInfo.systemUptime - sliceStart > Self.budget else { return }
try? await Task.sleep(for: .milliseconds(1))
sliceStart = ProcessInfo.processInfo.systemUptime
}
}
24 changes: 24 additions & 0 deletions App/Render/GaragePreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,31 @@ final class GaragePreview {
placeFloor()
}

#if DEBUG || FAMILY
/// Płynność menu i garażu: przycięcia > 25 ms i podsumowanie co 600 klatek (jak w wyścigu).
private var frameTimes: [Double] = []
private var lastFrameAt = 0.0

private func recordFrame() {
let now = ProcessInfo.processInfo.systemUptime
defer { lastFrameAt = now }
guard lastFrameAt > 0 else { return }
let ft = now - lastFrameAt
frameTimes.append(ft)
if ft > 0.025 { debugLog(String(format: "menu: przycięcie %.0f ms", ft * 1000)) }
if frameTimes.count % 600 == 0 {
let sorted = frameTimes.sorted()
debugLog(String(format: "menu: klatki %d, p95 %.1f ms, max %.0f ms, przycięć %d", sorted.count,
sorted[Int(Double(sorted.count) * 0.95)] * 1000, sorted.last! * 1000,
frameTimes.filter { $0 > 0.025 }.count))
}
}
#endif

func update(dt: Double) {
#if DEBUG || FAMILY
recordFrame()
#endif
angle += Float(dt) * 0.5
turntable.orientation = simd_quatf(angle: angle, axis: [0, 1, 0])
}
Expand Down
39 changes: 36 additions & 3 deletions App/Render/LocalAssets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ final class LocalAssets {
/// Ile realistycznych modeli już się wczytało; widoki odświeżają się, gdy rośnie.
private(set) var loadedCount = 0
private(set) var loadingDone = false
/// Trwa wyścig: dalsze auta czekają z wczytaniem. Scalanie siatek modelu idzie na wątku głównym
/// (do 0,6 s) i w czasie jazdy dawało przycięcia; dokończą się po powrocie do menu.
/// Licznik, nie flaga: przy „Od nowa” nowy ekran wyścigu startuje, zanim stary zniknie.
@ObservationIgnored var racesShowing = 0
/// Ekran wyścigu jest otwarty i ma już auta, których potrzebuje — kolejne nie startują. Liczone od wejścia
/// na ekran ładowania: auto zaczęte wtedy kończyło `Entity(contentsOf:)` (też wątek główny) już po
/// odsłonięciu sceny i dawało przycięcie ~90 ms na starcie wyścigu.
var raceInProgress: Bool { racesShowing > 0 && loadedCount >= raceCarCount }
private var raceCarCount: Int { max(1, min(cars.count, RaceConfig.opponents + 1)) }
@ObservationIgnored private(set) var cars: [LocalCarEntry] = []
@ObservationIgnored private(set) var templates: [String: Entity] = [:]
@ObservationIgnored private(set) var sceneryEntries: [SceneryEntry] = []
Expand Down Expand Up @@ -140,6 +149,12 @@ final class LocalAssets {
return "Pewnie trzyma się zakrętów i świetnie driftuje."
}

/// Wyścig może ruszyć bez czekania na całą listę: wystarczy auto gracza i tyle aut, ile jest rywali
/// (ich wygląd losujemy z wczytanych). Na zimnym starcie to ok. 6 z 9 modeli.
func readyForRace(player: CarDef) -> Bool {
loadingDone || (isLoaded(player) && loadedCount >= raceCarCount)
}

func isLoaded(_ def: CarDef) -> Bool {
def.realistic.map { templates[$0.file] != nil } ?? true
}
Expand All @@ -159,11 +174,25 @@ final class LocalAssets {
// Od razu, bo lista autorów może się otworzyć, zanim wczytają się auta.
sceneryEntries = Self.sceneryEntries(in: folder)
}
let ordered = cars.sorted { a, _ in a.id == first }
// Auto gracza pierwsze, reszta od najmniejszego pliku — wyścig rusza po 6 wczytanych autach.
func size(_ e: LocalCarEntry) -> Int {
(try? folder.appendingPathComponent(e.file).resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
}
let rest = cars.filter { $0.id != first }.map { ($0, size($0)) }.sorted { $0.1 < $1.1 }.map(\.0)
let ordered = cars.filter { $0.id == first } + rest
if let head = ordered.first { await load(head, from: folder) }
// Po dwa naraz: jedno auto czyta się z dysku w tle, gdy drugie scala siatki na wątku głównym.
// Wszystkie naraz kończyły wczytywanie w przypadkowych chwilach — także już w trakcie wyścigu,
// a koniec `Entity(contentsOf:)` też zajmuje wątek główny (przycięcia ~90 ms po odsłonięciu sceny).
await withTaskGroup(of: Void.self) { group in
var running = 0
for e in ordered.dropFirst() {
if running == 2 {
await group.next()
running -= 1
}
group.addTask { await self.load(e, from: folder) }
running += 1
}
}
if Self.sceneryEnabled { await preloadScenery(from: folder) }
Expand Down Expand Up @@ -222,14 +251,18 @@ final class LocalAssets {

private func load(_ e: LocalCarEntry, from folder: URL) async {
guard templates[e.file] == nil else { return }
while raceInProgress { try? await Task.sleep(for: .milliseconds(250)) }
// Druga próba po chwili: przy dużym obciążeniu wczytywanie USDZ potrafi się nie udać za pierwszym razem.
for attempt in 1...2 {
do {
let entity = try await Entity(contentsOf: folder.appendingPathComponent(e.file))
// Wczytanie trwa — w tym czasie mógł ruszyć wyścig.
if raceInProgress { debugLog("model: \(e.file) czeka na koniec wyścigu") }
while raceInProgress { try? await Task.sleep(for: .milliseconds(250)) }
let normalized = Self.normalized(entity, yawDegrees: e.yaw)
Self.markWheels(normalized, names: Set(e.wheels ?? []))
Self.tuneGlass(normalized, file: e.file)
if let ao = await CarAO.atlas(for: e.file, in: folder) { CarAO.apply(ao, to: normalized, file: e.file) }
if let ao = await CarAO.atlas(for: e.file, in: folder) { await CarAO.apply(ao, to: normalized, file: e.file) }
// Setki prim USD sklejone w kilka siatek (nadwozie, lakier, cztery koła) — raz na model,
// klony aut współdzielą wynik. Gdy scalanie się nie uda, zostaje wzorzec bez zmian.
#if DEBUG || FAMILY
Expand All @@ -238,7 +271,7 @@ final class LocalAssets {
let noMerge = false
#endif
let t0 = ProcessInfo.processInfo.systemUptime
let merged = noMerge ? nil : CarMeshMerge.merge(normalized, paintNames: Set(e.paint), file: e.file)
let merged = noMerge ? nil : await CarMeshMerge.merge(normalized, paintNames: Set(e.paint), file: e.file)
if merged != nil {
debugLog(String(format: "model: %@ scalanie %.0f ms", e.file, (ProcessInfo.processInfo.systemUptime - t0) * 1000))
}
Expand Down
2 changes: 1 addition & 1 deletion App/Render/RaceGame.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ final class RaceGame {
let ft = now - lastFrameAt
raceClock += ft
frameTimes.append(ft)
if ft > 0.025 && raceClock > 0.5 {
if ft > 0.025 && (raceClock > 0.5 || !holdStart) {
debugLog(String(format: "klatka: przycięcie %.0f ms (%.1f s wyścigu, faza %@, s=%.0f m)", ft * 1000, raceClock, "\(phase)", player.s))
}
if frameTimes.count % 600 == 0 {
Expand Down
20 changes: 18 additions & 2 deletions App/Views/RaceScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ struct RaceScreen: View {
@State private var game: RaceGame?
@State private var revealed = false
@State private var progress = 0.0
/// Ten ekran wstrzymał wczytywanie dalszych aut (`LocalAssets.racesShowing`).
@State private var holdsAssets = false

var body: some View {
ZStack {
Expand All @@ -22,6 +24,14 @@ struct RaceScreen: View {
}
.background(Color.black)
.task { await load() }
// Dopiero po wyjściu: na ekranie wyników scalanie modelu (wątek główny) zamrażało go na 0,5–0,7 s.
.onDisappear { releaseAssets() }
}

private func releaseAssets() {
guard holdsAssets else { return }
holdsAssets = false
LocalAssets.shared.racesShowing -= 1
}

/// Ładowanie jak w silnikach gier: ekran ładowania od razu, ciężka praca w tle z prawdziwym postępem,
Expand All @@ -34,8 +44,14 @@ struct RaceScreen: View {
// 1. Auta (0–45%) i 2. tekstury krainy (45–90%) — równolegle.
async let sceneryDone: Void = scenery.prepare(spec.region)
await SceneShaders.warmUp()
while !assets.loadingDone || !scenery.isReady(spec.region) {
let cars = assets.cars.isEmpty ? 1 : Double(assets.loadedCount) / Double(assets.cars.count)
if !holdsAssets {
holdsAssets = true
assets.racesShowing += 1
}
let player = model.playerCar
let needed = Double(max(1, min(assets.cars.count, RaceConfig.opponents + 1)))
while !assets.readyForRace(player: player) || !scenery.isReady(spec.region) {
let cars = assets.loadingDone ? 1 : Double(assets.loadedCount) / needed
progress = 0.45 * min(1, cars) + 0.45 * (scenery.isReady(spec.region) ? 1 : scenery.progress)
try? await Task.sleep(for: .milliseconds(50))
}
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,11 @@ Loading works the way game engines do it:
pictogram and a real progress bar. The scene is built from the ready textures and renders its first
frames under the cover (shader and effect warm-up). Only then does the countdown start, so there
are no black screens and no hitches at the start.
- Car models load two at a time, the player's car first and then the smallest files. A race waits
only for the player's car and as many cars as there are rivals (6 of the 9 on a cold start); the rest
wait until the race screen closes, because merging a model's meshes runs on the main thread.
- Merging meshes and applying baked AO are split into slices of about 6 ms with a pause for a frame,
so the turntable in the menu and garage keeps turning while cars load.
- Memory: the garage and one region at a time; preparing another region frees the previous one.

## Project layout
Expand Down
Loading