From 691f1801550fd35ca254e388e1c92e1699ddda62 Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Wed, 21 Oct 2026 12:48:51 +0200 Subject: [PATCH 1/2] Tyre smoke as one billboard mesh instead of particle emitters On iOS 27 every active RealityKit particle emitter cut the race to 12-15 fps for as long as it emitted, whatever its particle count or size (5 particles/s cost as much as 110). Drifting cars - and the smoke warm-up under the loading screen - caused 14-46 hitches per race. DriftSmoke keeps the same look (birth box, cone, lift, growth, spin, linear fade) as camera-facing quads in one LowLevelMesh rewritten every frame; fading picks one of 16 copies of the puff texture by age, because UnlitMaterial has no vertex colour. Simulator iPad M5 iOS 27, all five regions, A/B against main: hitches 14-46 -> 1-3 per race, longest frame 101-150 -> 33-50 ms, loading 0.7 s faster, memory 15 MB lower. Also: -noSmoke / -noSkids / -smokeLook for A/B, track position in hitch logs, tools/perf-run.sh. Co-Authored-By: Claude Opus 5.5 --- App/Render/CarEntity.swift | 54 ++------ App/Render/DriftSmoke.swift | 197 ++++++++++++++++++++++++++++++ App/Render/ParticleTextures.swift | 30 ++++- App/Render/RaceGame.swift | 12 +- README.md | 16 ++- tools/perf-run.sh | 25 ++++ 6 files changed, 281 insertions(+), 53 deletions(-) create mode 100644 App/Render/DriftSmoke.swift create mode 100755 tools/perf-run.sh diff --git a/App/Render/CarEntity.swift b/App/Render/CarEntity.swift index 8b35b44..31cec33 100644 --- a/App/Render/CarEntity.swift +++ b/App/Render/CarEntity.swift @@ -420,7 +420,6 @@ final class CarEntity { let e = Entity() // Nad jezdnią: duże cząsteczki rodzące się nisko były ucinane przez asfalt prostą krawędzią. e.position = [sx * backWheelX, 0.45, backWheelZ - 0.5] - e.components.set(Self.smokeEmitter()) root.addChild(e) smoke.append(e) } @@ -441,42 +440,6 @@ final class CarEntity { } } - private static func smokeEmitter() -> ParticleEmitterComponent { - var p = ParticleEmitterComponent() - // Pudełko wydłużone do tyłu: cząsteczki rodzą się na całej drodze, którą koło przebyło w klatce, - // więc przy dużej prędkości smuga jest ciągła, a nie z osobnych kłębów. - p.emitterShape = .box - p.emitterShapeSize = [0.25, 0.05, 1.4] - p.birthLocation = .volume - p.speed = 0.8 - p.speedVariation = 0.4 - p.emissionDirection = [0, 1, 0] - p.particlesInheritTransform = false - // Mało, ale dużych i krótko żyjących cząsteczek: półprzezroczysty dym nakłada się na siebie - // przed kamerą i to on (nie płomień) przycinał obraz, gdy kilka aut driftowało naraz. - // Gęsta, rzadka smuga zamiast pojedynczych kłębów: dużo słabo kryjących cząsteczek, które rosną - // i rozmywają się w powietrzu (przy 200 km/h auto przejeżdża metr na klatkę). - p.mainEmitter.birthRate = 110 - p.mainEmitter.lifeSpan = 0.8 - p.mainEmitter.lifeSpanVariation = 0.25 - p.mainEmitter.size = 0.6 - p.mainEmitter.sizeVariation = 0.2 - p.mainEmitter.sizeMultiplierAtEndOfLifespan = 2.8 - p.mainEmitter.spreadingAngle = 0.8 - // Dym unosi się, rosnąc — dolny brzeg kłębu nie schodzi pod asfalt. - p.mainEmitter.acceleration = [0, 0.9, 0] - p.mainEmitter.angleVariation = .pi - p.mainEmitter.angularSpeedVariation = 1.2 - // Miękki, nieregularny kłąb zamiast domyślnego kwadratu (dym składał się z prostokątów). - p.mainEmitter.image = ParticleTextures.smoke - // Addytywnie: biały dym spod opon rozjaśnia tło miękko (patrz ParticleTextures). - p.mainEmitter.blendMode = .additive - p.mainEmitter.color = .constant(.single(UIColor(white: 0.5, alpha: 0.5))) - p.mainEmitter.opacityCurve = .linearFadeOut - p.isEmitting = false - return p - } - /// Kolor płomienia: niebieski (nitro), fioletowy (perfect), pomarańczowy (fala). private static func flameColor(_ mode: NitroMode) -> UIColor { switch mode { @@ -677,22 +640,27 @@ final class CarEntity { /// zauważalny koszt, a z daleka smugi i tak prawie nie widać. static let smokeRange: Float = 60 + #if DEBUG || FAMILY + /// `-noSmoke`: drift bez dymu (pomiar kosztu A/B). + private static let smokeOff = ProcessInfo.processInfo.arguments.contains("-noSmoke") + #else + private static let smokeOff = false + #endif + /// Dym spod tylnych kół. `viewer` — pozycja kamery (świat): auto dalej niż `smokeRange` nie dymi. func setSmoke(_ on: Bool, viewer: SIMD3? = nil) { - var on = on + var on = on && !Self.smokeOff if on, let viewer, simd_distance_squared(root.position(relativeTo: nil), viewer) > Self.smokeRange * Self.smokeRange { on = false } guard on != smoking else { return } debugLog("efekt: dym \(on)") smoking = on - for e in smoke { - guard var p = e.components[ParticleEmitterComponent.self] else { continue } - p.isEmitting = on - e.components.set(p) - } } + /// Punkty, z których teraz dymi (przy tylnych kołach; oś Z do tyłu auta) — kłęby rysuje `DriftSmoke`. + var smokeEmitters: [Entity] { smoking ? smoke : [] } + func spinWheels(distance: Double) { let spin = simd_quatf(angle: Float(distance) / wheelRadius, axis: [1, 0, 0]) let steer = simd_quatf(angle: steerAngle, axis: [0, 1, 0]) diff --git a/App/Render/DriftSmoke.swift b/App/Render/DriftSmoke.swift new file mode 100644 index 0000000..94d82e4 --- /dev/null +++ b/App/Render/DriftSmoke.swift @@ -0,0 +1,197 @@ +import RealityKit +import UIKit + +/// Dym spod opon w drifcie: kłęby to czworokąty zwrócone do kamery, wszystkie w jednej siatce +/// `LowLevelMesh` przepisywanej co klatkę (jak ślady opon), ruch liczony na CPU. +/// +/// Zastępuje `ParticleEmitterComponent`: na iOS 27 każdy pracujący emiter RealityKit zbijał wyścig do +/// 12–15 kl./s niezależnie od liczby i rozmiaru cząsteczek (5 cząsteczek/s kosztowało tyle co 110), +/// a dymiło kilka aut naraz. Tu koszt to ~1500 czworokątów zapisanych w bufor — ułamek milisekundy. +/// +/// Wygasanie: `UnlitMaterial` nie zna koloru wierzchołka, więc przezroczystość kłębu bierzemy z paska +/// 16 kopii tekstury o malejącej gęstości — wiek cząsteczki wybiera kopię przez UV. +@MainActor +final class DriftSmoke { + let entity = ModelEntity() + + /// Parametry jak w dawnym emiterze: na sekundę z każdego koła, życie, rozmiar na starcie i mnożnik na końcu. + private static let birthRate: Float = 110 + private static let lifeSpan: Float = 0.8 + private static let lifeSpanVariation: Float = 0.25 + private static let size: Float = 0.6 + private static let sizeVariation: Float = 0.2 + private static let sizeAtEnd: Float = 2.8 + private static let speed: Float = 0.8 + private static let speedVariation: Float = 0.4 + /// Połowa kąta stożka, w którym kłęby wylatują w górę (rad). + private static let spreadingAngle: Float = 0.8 + /// Unoszenie się dymu (m/s²). + private static let lift: Float = 0.9 + private static let angularSpeedVariation: Float = 1.2 + /// Pudełko narodzin w układzie emitera (oś Z do tyłu auta): cząsteczki rodzą się na całej drodze, + /// którą koło przebyło w klatce, więc przy dużej prędkości smuga jest ciągła. + private static let box = SIMD3(0.25, 0.05, 1.4) + private static let capacity = 1536 + private static let fadeSteps = 16 + + private struct Puff { + var position: SIMD3 + var velocity: SIMD3 + var age: Float + var life: Float + var size: Float + var angle: Float + var spin: Float + } + + /// Wierzchołek w buforze: pozycja, normalna, UV (32 bajty, bez wyrównania SIMD3). + private struct Vertex { + var px, py, pz, nx, ny, nz, u, v: Float + } + + private var puffs: [Puff] = [] + /// Ułamek cząsteczki „do urodzenia” per emiter — przy 60 kl./s na klatkę wypada ~1,8 kłębu. + private var pending: [ObjectIdentifier: Float] = [:] + private let mesh: LowLevelMesh? + private var rng = SystemRandomNumberGenerator() + + init() { + puffs.reserveCapacity(Self.capacity) + mesh = Self.makeMesh() + var m = UnlitMaterial(color: UIColor(white: Self.tint, alpha: 1)) + if let tex = ParticleTextures.smokeFadeStrip(steps: Self.fadeSteps) { + m.blending = .transparent(opacity: .init(scale: Self.opacity, texture: .init(tex))) + } else { + m.blending = .transparent(opacity: .init(floatLiteral: Self.opacity * 0.3)) + } + // Przezroczyste kłęby nie zapisują głębi — inaczej bliższy ucinałby dalsze prostą krawędzią. + m.writesDepth = false + if let mesh, let resource = try? MeshResource(from: mesh) { + entity.model = ModelComponent(mesh: resource, materials: [m]) + } + entity.isEnabled = false + } + + #if DEBUG || FAMILY + /// `-smokeLook tint,opacity` — strojenie wyglądu bez przebudowy (zrzuty A/B ze starym dymem). + private static let look: [Float] = { + let args = ProcessInfo.processInfo.arguments + guard let i = args.firstIndex(of: "-smokeLook"), i + 1 < args.count else { return [] } + let v = args[i + 1].split(separator: ",").compactMap { Float($0) } + return v.count == 2 ? v : [] + }() + private static var tint: CGFloat { CGFloat(look.first ?? 0.9) } + private static var opacity: Float { look.last ?? 0.22 } + #else + private static let tint: CGFloat = 0.9 + private static let opacity: Float = 0.22 + #endif + + private static func makeMesh() -> LowLevelMesh? { + let desc = LowLevelMesh.Descriptor( + vertexCapacity: capacity * 4, + vertexAttributes: [ + .init(semantic: .position, format: .float3, offset: 0), + .init(semantic: .normal, format: .float3, offset: 12), + .init(semantic: .uv0, format: .float2, offset: 24), + ], + vertexLayouts: [.init(bufferIndex: 0, bufferStride: MemoryLayout.stride)], + indexCapacity: capacity * 6, + indexType: .uint32) + guard let mesh = try? LowLevelMesh(descriptor: desc) else { return nil } + let order: [UInt32] = [0, 1, 2, 0, 2, 3] + mesh.withUnsafeMutableIndices { raw in + let idx = raw.bindMemory(to: UInt32.self) + for q in 0..) -> Float { Float.random(in: range, using: &rng) } + + /// Kłęby z emiterów (`Entity` przy tylnych kołach; oś Z do tyłu auta) w tej klatce. + func emit(from emitters: [Entity], dt: Float) { + for e in emitters { + let id = ObjectIdentifier(e) + var n = (pending[id] ?? 0) + Self.birthRate * dt + let m = e.transformMatrix(relativeTo: nil) + while n >= 1 { + n -= 1 + guard puffs.count < Self.capacity else { continue } + let local = SIMD4(random(-0.5...0.5) * Self.box.x, random(-0.5...0.5) * Self.box.y, + random(-0.5...0.5) * Self.box.z, 1) + let p = m * local + // Kierunek w stożku wokół osi Y (świat), równomiernie po kącie bryłowym. + let cosA = random(cos(Self.spreadingAngle)...1) + let sinA = (1 - cosA * cosA).squareRoot() + let phi = random(0...(2 * .pi)) + let dir = SIMD3(sinA * cos(phi), cosA, sinA * sin(phi)) + let life = Self.lifeSpan + random(-Self.lifeSpanVariation...Self.lifeSpanVariation) + puffs.append(Puff(position: [p.x, p.y, p.z], + velocity: dir * (Self.speed + random(-Self.speedVariation...Self.speedVariation)), + age: 0, life: max(0.1, life), + size: Self.size + random(-Self.sizeVariation...Self.sizeVariation), + angle: random(-.pi ... .pi), + spin: random(-Self.angularSpeedVariation...Self.angularSpeedVariation))) + } + pending[id] = n + } + } + + /// Raz na klatkę, po `emit`: ruch, starzenie i przepisanie czworokątów zwróconych do kamery. + func update(dt: Float, camera: Entity) { + guard let mesh else { return } + var i = 0 + while i < puffs.count { + puffs[i].age += dt + if puffs[i].age >= puffs[i].life { + puffs.swapAt(i, puffs.count - 1) + puffs.removeLast() + continue + } + puffs[i].velocity.y += Self.lift * dt + puffs[i].position += puffs[i].velocity * dt + puffs[i].angle += puffs[i].spin * dt + i += 1 + } + if puffs.isEmpty { + if entity.isEnabled { entity.isEnabled = false } + pending.removeAll() + return + } + let q = camera.orientation(relativeTo: nil) + let right = q.act([1, 0, 0]), up = q.act([0, 1, 0]), toCamera = q.act([0, 0, 1]) + var lo = SIMD3(repeating: .greatestFiniteMagnitude), hi = -lo + let steps = Float(Self.fadeSteps) + mesh.withUnsafeMutableBytes(bufferIndex: 0) { raw in + let v = raw.bindMemory(to: Vertex.self) + for (k, p) in puffs.enumerated() { + let t = p.age / p.life + // Rozmiar rośnie liniowo do `sizeAtEnd`; promień = połowa boku. + let r = p.size * (1 + (Self.sizeAtEnd - 1) * t) * 0.5 + let c = cos(p.angle), s = sin(p.angle) + let a = (right * c + up * s) * r, b = (up * c - right * s) * r + // Wygasanie liniowe: kopia paska o gęstości 1 − t. + let cell = min(steps - 1, (t * steps).rounded(.down)) + let u0 = cell / steps, u1 = (cell + 1) / steps + let corners = [(p.position - a - b, u0, Float(1)), (p.position + a - b, u1, Float(1)), + (p.position + a + b, u1, Float(0)), (p.position - a + b, u0, Float(0))] + for (j, corner) in corners.enumerated() { + v[k * 4 + j] = Vertex(px: corner.0.x, py: corner.0.y, pz: corner.0.z, + nx: toCamera.x, ny: toCamera.y, nz: toCamera.z, u: corner.1, v: corner.2) + } + lo = simd_min(lo, p.position - r * 1.5) + hi = simd_max(hi, p.position + r * 1.5) + } + } + mesh.parts.replaceAll([LowLevelMesh.Part(indexCount: puffs.count * 6, bounds: BoundingBox(min: lo, max: hi))]) + if !entity.isEnabled { entity.isEnabled = true } + } +} diff --git a/App/Render/ParticleTextures.swift b/App/Render/ParticleTextures.swift index 47c30fd..ad8df76 100644 --- a/App/Render/ParticleTextures.swift +++ b/App/Render/ParticleTextures.swift @@ -1,13 +1,14 @@ import RealityKit import UIKit -/// Tekstury cząsteczek. Emiter bez obrazka rysuje każdą cząsteczkę jako jednolity kwadrat, więc dym -/// składał się z widocznych prostokątów. Kłąb dymu to miękka plama (gęstość opada jak krzywa Gaussa), -/// iskry — rozmyta kropka. Jasność w kolorze (bez kanału alfa): emiter rysuje je addytywnie, bo przy -/// mieszaniu przez alfę RealityKit ucinał przezroczystość progiem i kłęby miały ostre brzegi krążków. +/// Tekstury cząsteczek. Emiter bez obrazka rysuje każdą cząsteczkę jako jednolity kwadrat. Iskry to +/// rozmyta kropka; jasność w kolorze (bez kanału alfa), bo emiter rysuje je addytywnie — przy mieszaniu +/// przez alfę RealityKit ucinał przezroczystość progiem i miały ostre brzegi krążków. Kłąb dymu to miękka +/// plama (gęstość opada jak krzywa Gaussa) w pasku wygasania dla `DriftSmoke` (maska przezroczystości). @MainActor enum ParticleTextures { - static let smoke: TextureResource? = make(size: 128) { x, y in + /// Kłąb dymu, `x`, `y` w −1…1. + private static func puff(_ x: Double, _ y: Double) -> Double { // Gęstość opada łagodnie (krzywa Gaussa) do zera na brzegu — bez konturu, który przy wielu // nałożonych cząsteczkach dawał „komiksowe” chmurki. let r2 = x * x + y * y @@ -17,6 +18,25 @@ enum ParticleTextures { return density * swirl } + /// Pasek `steps` kłębów o gęstości malejącej od pełnej do zera — przezroczystość dymu w `DriftSmoke` + /// (maska w kanale R, liniowo). Brzegi kłębu mają gęstość 0, więc mipmapy nie przeciekają między kopiami. + static func smokeFadeStrip(steps: Int, cell n: Int = 64) -> TextureResource? { + var gray = [UInt8](repeating: 0, count: n * steps * n) + for j in 0.. 0.025 && raceClock > 0.5 { - debugLog(String(format: "klatka: przycięcie %.0f ms (%.1f s wyścigu, faza %@)", ft * 1000, raceClock, "\(phase)")) + 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 { let sorted = frameTimes.sorted() @@ -494,6 +496,10 @@ final class RaceGame { /// `-shotAt`: zamrożony kadr do zrzutów porównawczych. @ObservationIgnored private var shotMode = false private static let sideCam = ProcessInfo.processInfo.arguments.contains("-sideCam") + /// `-noSkids`: drift bez śladów opon (pomiar kosztu A/B). + private static let skidsOff = ProcessInfo.processInfo.arguments.contains("-noSkids") + #else + private static let skidsOff = false #endif private func setVisible(_ e: Entity, _ visible: Bool) { @@ -526,9 +532,10 @@ final class RaceGame { let rev = phase == .countdown && clock < 0.15 e.setFlame(rev ? .normal : car.nitro.mode) e.setSmoke((rev && clock < 0.05) || car.drifting, viewer: camera.position) + driftSmoke.emit(from: e.smokeEmitters, dt: Float(frameDt)) e.spinWheels(distance: pose.distance) // Ślady opon: w drifcie, gdy auto jest blisko gracza (dalej i tak ich nie widać). - if abs(wrapDelta(car.s, playerS, sim.track.length)) < 80 { + if abs(wrapDelta(car.s, playerS, sim.track.length)) < 80 && !Self.skidsOff { let base = cars.firstIndex { $0.0 === car }.map { $0 * 4 } ?? 0 for (i, p) in e.rearContacts().enumerated() { skidMarks.track(key: base + i, at: p, active: car.drifting && !car.wrecked && car.speed > 8) @@ -536,6 +543,7 @@ final class RaceGame { } } skidMarks.flush(dt: frameDt) + driftSmoke.update(dt: Float(frameDt), camera: camera) // Otoczenie: co 0,2 s ukrywamy obiekty dalej niż 320 m po trasie (bliżej przed autem widać dalej). cullTimer -= frameDt if cullTimer <= 0 { diff --git a/README.md b/README.md index 5dd2b82..c2cd2ab 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,14 @@ Debug builds accept launch arguments for testing in the simulator: - `-mute` turns music and sound effects off (simulator tests play through the Mac speakers), - `-rich` gives coins and stars (to look at the shop and unlocks), `-car ` picks and owns a car, - `-autoNitro` presses nitro every 4 s, `-toggleMuteAt N` toggles sound after N seconds, -- `-sideCam` puts the race camera low beside the player's car, `-noAO` turns off baked ambient occlusion. +- `-sideCam` puts the race camera low beside the player's car, `-noAO` turns off baked ambient occlusion, +- `-noSmoke`, `-noSkids`, `-noPostFX`, `-noMSAA`, `-noRoadside` turn one effect off (cost A/B), + `-smokeLook tint,opacity` tunes the tyre smoke. + +`tools/perf-run.sh ` races in every region and prints loading time, frame +times, hitches (frames over 25 ms), CPU work per frame and memory. The simulator draws on the Mac's +GPU at 60 Hz, so compare builds A/B on the same simulator, alternating, and look at hitches rather +than the median; a busy Mac (load average over ~10) makes every build stutter. These and the timing logs work in Debug and Family builds, not in Release. @@ -415,9 +422,12 @@ credited in the app (Settings → "Autorzy i licencje"), as the licence requires - **Skid marks:** while drifting, the rear tyres leave dark marks with soft edges. All marks are one mesh with a ring buffer (the oldest disappear); a small mesh with the newest segments is updated every frame, so the mark starts right under the tyre. -- **Tyre smoke:** many faint, growing particles born along the path the wheel travelled in the +- **Tyre smoke:** many faint, growing puffs born along the path the wheel travelled in the frame, so the smoke is a haze rather than separate puffs. Cars more than 60 m from the camera - don't smoke. + don't smoke. The puffs are camera-facing quads in one mesh rewritten every frame (`DriftSmoke`), + not a RealityKit particle emitter: on iOS 27 every active emitter cut the race to 12–15 fps + regardless of how many particles it had. Fading uses a strip of 16 copies of the puff texture at + decreasing density, picked by the puff's age. - **Nitro flame:** a soft tongue of fire (crossed planes with a texture fading along and across) going from a white-blue core to orange, flickering, plus a stream of particles. - **Nitro canisters:** a modelled N₂O bottle (rounded base, shoulder, valve with handwheel and outlet, diff --git a/tools/perf-run.sh b/tools/perf-run.sh new file mode 100755 index 0000000..e1de6b9 --- /dev/null +++ b/tools/perf-run.sh @@ -0,0 +1,25 @@ +#!/bin/zsh +# Pomiar wydajności wyścigu w każdej krainie: czas ładowania, czasy klatek, praca CPU w klatce, pamięć procesu. +# tools/perf-run.sh [extra launch args...] +# Wymaga zainstalowanego buildu Debug/Family. Zmienne: WAIT (s jazdy na wyścig), REGIONS, CAR. +# Symulator rysuje na GPU Maca i ma vsync 60 Hz — czas klatki pokazuje przycięcia, a nie zapas GPU iPada; +# porównuj pracę CPU, ładowanie i pamięć, zawsze A/B na tym samym symulatorze. +set -u +SIM=$1 OUT=${2:A}; shift 2 +WAIT=${WAIT:-45} +mkdir -p $OUT +typeset -A T; T=(city 1 coast 9 desert 17 forest 25 snow 33) +for r in ${=REGIONS:-city coast desert forest snow}; do + car=(); [[ -n ${CAR:-} ]] && car=(-car $CAR) + xcrun simctl terminate $SIM com.renacode.simpleracer 2>/dev/null + xcrun simctl launch --stdout=$OUT/$r.log --stderr=$OUT/$r.log $SIM com.renacode.simpleracer \ + -mute -autostartRace -track $T[$r] "${car[@]}" "$@" >/dev/null + sleep $WAIT + pid=$(xcrun simctl spawn $SIM launchctl list 2>/dev/null | awk '/com.renacode.simpleracer/ {print $1}') + mem=$( [[ -n $pid && $pid != - ]] && footprint -p $pid 2>/dev/null | awk '/phys_footprint:/ {print $2, $3}' | head -1) + printf '%-7s %s | %s | %s | pamięć %s\n' $r \ + "$(grep -h 'ładowanie wyścigu' $OUT/$r.log | tail -1 | sed 's/.*ładowanie/ładowanie/')" \ + "$(grep -h 'klatki:' $OUT/$r.log | tail -1 | sed 's/.*klatki/klatki/')" \ + "$(grep -h 'praca CPU' $OUT/$r.log | tail -1 | sed 's/.*praca/praca/')" "${mem:-?}" +done +xcrun simctl terminate $SIM com.renacode.simpleracer 2>/dev/null From ca28116b224b71bee053fffeb023e1f31f2f0bdd Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Wed, 21 Oct 2026 13:53:38 +0200 Subject: [PATCH 2/2] Smoke quads in simple expressions: CI compiler timed out type-checking Co-Authored-By: Claude Opus 5.5 --- App/Render/DriftSmoke.swift | 65 +++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/App/Render/DriftSmoke.swift b/App/Render/DriftSmoke.swift index 94d82e4..0b42a18 100644 --- a/App/Render/DriftSmoke.swift +++ b/App/Render/DriftSmoke.swift @@ -167,31 +167,54 @@ final class DriftSmoke { return } let q = camera.orientation(relativeTo: nil) - let right = q.act([1, 0, 0]), up = q.act([0, 1, 0]), toCamera = q.act([0, 0, 1]) - var lo = SIMD3(repeating: .greatestFiniteMagnitude), hi = -lo - let steps = Float(Self.fadeSteps) - mesh.withUnsafeMutableBytes(bufferIndex: 0) { raw in + let basis = Basis(right: q.act(SIMD3(1, 0, 0)), up: q.act(SIMD3(0, 1, 0)), + toCamera: q.act(SIMD3(0, 0, 1))) + var lo = SIMD3(repeating: .greatestFiniteMagnitude) + var hi = -lo + let puffs = self.puffs + mesh.withUnsafeMutableBytes(bufferIndex: 0) { (raw: UnsafeMutableRawBufferPointer) in let v = raw.bindMemory(to: Vertex.self) - for (k, p) in puffs.enumerated() { - let t = p.age / p.life - // Rozmiar rośnie liniowo do `sizeAtEnd`; promień = połowa boku. - let r = p.size * (1 + (Self.sizeAtEnd - 1) * t) * 0.5 - let c = cos(p.angle), s = sin(p.angle) - let a = (right * c + up * s) * r, b = (up * c - right * s) * r - // Wygasanie liniowe: kopia paska o gęstości 1 − t. - let cell = min(steps - 1, (t * steps).rounded(.down)) - let u0 = cell / steps, u1 = (cell + 1) / steps - let corners = [(p.position - a - b, u0, Float(1)), (p.position + a - b, u1, Float(1)), - (p.position + a + b, u1, Float(0)), (p.position - a + b, u0, Float(0))] - for (j, corner) in corners.enumerated() { - v[k * 4 + j] = Vertex(px: corner.0.x, py: corner.0.y, pz: corner.0.z, - nx: toCamera.x, ny: toCamera.y, nz: toCamera.z, u: corner.1, v: corner.2) - } - lo = simd_min(lo, p.position - r * 1.5) - hi = simd_max(hi, p.position + r * 1.5) + for k in puffs.indices { + let r = Self.write(puffs[k], basis: basis, into: v, slot: k) + let margin = SIMD3(repeating: r * 1.5) + lo = simd_min(lo, puffs[k].position - margin) + hi = simd_max(hi, puffs[k].position + margin) } } mesh.parts.replaceAll([LowLevelMesh.Part(indexCount: puffs.count * 6, bounds: BoundingBox(min: lo, max: hi))]) if !entity.isEnabled { entity.isEnabled = true } } + + /// Osie kamery w świecie (billboardy leżą w płaszczyźnie `right`/`up`). + private struct Basis { + var right: SIMD3 + var up: SIMD3 + var toCamera: SIMD3 + } + + /// Czworokąt kłębu w miejscu `slot`; zwraca promień kłębu (do granic siatki). + /// Rozbite na proste wyrażenia: jedno długie przekraczało limit czasu kontroli typów kompilatora na CI. + private static func write(_ p: Puff, basis: Basis, into v: UnsafeMutableBufferPointer, slot: Int) -> Float { + let t: Float = p.age / p.life + // Rozmiar rośnie liniowo do `sizeAtEnd`; promień = połowa boku. + let r: Float = p.size * (1 + (sizeAtEnd - 1) * t) * 0.5 + let c: Float = cos(p.angle) + let s: Float = sin(p.angle) + let a: SIMD3 = (basis.right * c + basis.up * s) * r + let b: SIMD3 = (basis.up * c - basis.right * s) * r + // Wygasanie liniowe: kopia paska o gęstości 1 − t. + let steps = Float(fadeSteps) + let cell: Float = min(steps - 1, (t * steps).rounded(.down)) + let u0: Float = cell / steps + let u1: Float = (cell + 1) / steps + let n = basis.toCamera + func put(_ j: Int, _ pos: SIMD3, _ u: Float, _ w: Float) { + v[slot * 4 + j] = Vertex(px: pos.x, py: pos.y, pz: pos.z, nx: n.x, ny: n.y, nz: n.z, u: u, v: w) + } + put(0, p.position - a - b, u0, 1) + put(1, p.position + a - b, u1, 1) + put(2, p.position + a + b, u1, 0) + put(3, p.position - a + b, u0, 0) + return r + } }