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
54 changes: 11 additions & 43 deletions App/Render/CarEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Float>? = 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])
Expand Down
220 changes: 220 additions & 0 deletions App/Render/DriftSmoke.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
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<Float>(0.25, 0.05, 1.4)
private static let capacity = 1536
private static let fadeSteps = 16

private struct Puff {
var position: SIMD3<Float>
var velocity: SIMD3<Float>
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<Vertex>.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..<capacity {
for k in 0..<6 { idx[q * 6 + k] = UInt32(q * 4) + order[k] }
}
}
mesh.withUnsafeMutableBytes(bufferIndex: 0) { raw in
raw.initializeMemory(as: UInt8.self, repeating: 0)
}
// Część z jednym zdegenerowanym trójkątem: pusta siatka bywa odrzucana przy tworzeniu zasobu.
mesh.parts.replaceAll([LowLevelMesh.Part(indexCount: 3, bounds: BoundingBox(min: [-1, -1, -1], max: [1, 1, 1]))])
return mesh
}

private func random(_ range: ClosedRange<Float>) -> 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<Float>(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<Float>(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 basis = Basis(right: q.act(SIMD3<Float>(1, 0, 0)), up: q.act(SIMD3<Float>(0, 1, 0)),
toCamera: q.act(SIMD3<Float>(0, 0, 1)))
var lo = SIMD3<Float>(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 in puffs.indices {
let r = Self.write(puffs[k], basis: basis, into: v, slot: k)
let margin = SIMD3<Float>(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<Float>
var up: SIMD3<Float>
var toCamera: SIMD3<Float>
}

/// 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<Vertex>, 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<Float> = (basis.right * c + basis.up * s) * r
let b: SIMD3<Float> = (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<Float>, _ 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
}
}
30 changes: 25 additions & 5 deletions App/Render/ParticleTextures.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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..<n {
for i in 0..<(n * steps) {
let level = 1 - (Double(i / n) + 0.5) / Double(steps)
let x = (Double(i % n) + 0.5) / Double(n) * 2 - 1, y = (Double(j) + 0.5) / Double(n) * 2 - 1
gray[j * n * steps + i] = UInt8(max(0, min(1, puff(x, y) * level)) * 255)
}
}
guard let provider = CGDataProvider(data: Data(gray) as CFData),
let image = CGImage(width: n * steps, height: n, bitsPerComponent: 8, bitsPerPixel: 8, bytesPerRow: n * steps,
space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
else { return nil }
return try? TextureResource(image: image, options: .init(semantic: .raw, mipmapsMode: .allocateAndGenerateAll))
}

static let dot: TextureResource? = make(size: 64) { x, y in
let r = (x * x + y * y).squareRoot()
let v = max(0, 1 - r)
Expand Down
Loading
Loading