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
Binary file modified App/Ground/ground-city.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified App/Ground/road-asphalt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified App/Ground/road-paint.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified App/Ground/start-line.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions App/Render/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,30 @@ enum SkyEnvironment {
return ctx.makeImage()
}

/// Średni kolor (liniowy RGB) pasa wierszy v0…v1 obrazu (ułamki wysokości od góry) — do dopasowania
/// kolorów otoczenia 3D do zdjęcia panoramy.
static func meanColor(_ url: URL, rows: ClosedRange<Float> = 0...1) -> SIMD3<Float>? {
guard let image = UIImage(contentsOfFile: url.path)?.cgImage else { return nil }
let w = 64, h = 64
guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue),
let data = { ctx.interpolationQuality = .medium
ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
return ctx.data?.assumingMemoryBound(to: UInt8.self) }() else { return nil }
func lin(_ v: UInt8) -> Float { let c = Float(v) / 255; return c <= 0.04045 ? c / 12.92 : powf((c + 0.055) / 1.055, 2.4) }
var sum = SIMD3<Float>(repeating: 0), n: Float = 0
// W pamięci kontekstu wiersze idą od góry obrazu.
for y in Int(rows.lowerBound * Float(h))..<max(Int(rows.lowerBound * Float(h)) + 1, Int(rows.upperBound * Float(h))) {
for x in 0..<w {
let p = data + (y * w + x) * 4
sum += SIMD3(lin(p[0]), lin(p[1]), lin(p[2]))
n += 1
}
}
return n > 0 ? sum / n : nil
}

private static func attach(_ e: Entity, to ibl: Entity) {
if e is ModelEntity { e.components.set(ImageBasedLightReceiverComponent(imageBasedLight: ibl)) }
for c in e.children { attach(c, to: ibl) }
Expand Down
116 changes: 111 additions & 5 deletions App/Render/Ground.metal
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ using namespace metal;

// Grunt krainy: tekstura kafelkowana w układzie świata, a w oddali grunt płynnie znika i odsłania
// grunt ze zdjęcia panoramy — bez widocznej krawędzi „podłogi” na horyzoncie.
// custom_parameter: x = rozmiar kafla (m), y = odległość początku zanikania (m), z = 1 — zanikanie włączone
// (pierścień przezroczysty), 0 — środek nieprzezroczysty.
// custom_parameter: x = rozmiar kafla (m), y = odległość początku zanikania (m; ujemna — bez zanikania, środek
// nieprzezroczysty), z = mgła (hazeAmount), w = wzmocnienie koloru (z tintem: barwa gruntu panoramy).
//
// Żeby nie było widać powtarzania kafla, ta sama tekstura idzie w dwóch skalach: drobnej (kafel) i dużej
// (~8,3 kafla, obróconej), a na to makro-szum jasności i odcienia w skali dziesiątek metrów — jak plamy
Expand All @@ -30,6 +30,35 @@ float valueNoise(float2 p)
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0;
}

// Mgła powietrzna (Haze.swift): `packed` = zasięg (m, część całkowita) + gęstość (część ułamkowa); 0 — bez mgły.
float hazeAmount(float dist, float packed)
{
if (packed < 1.0) { return 0.0; }
float scale = floor(packed), density = fract(packed);
return density * (1.0 - exp(-dist / scale));
}

// Jasność emisji, przy której mgła daje kolor horyzontu panoramy po oświetleniu i tonemappingu
// (dobrana kanarkiem `-haze 0.99,20`: zamglony kadr w barwie nieba nad horyzontem).
constant float hazeEmission = 1.0;

// Mgła w oświetlanym materiale: kolor własny i odbicia gasną, a barwa mgły świeci (emisja) — tak samo,
// jak gdyby obraz zmieszać z kolorem mgły po oświetleniu.
void applyHaze(realitykit::surface_parameters params, float3 baseColor, float dist, float packed)
{
float f = hazeAmount(dist, packed);
float3 fog = float3(params.material_constants().emissive_color());
params.surface().set_base_color(half3(saturate(baseColor * (1.0 - f))));
params.surface().set_emissive_color(half3(fog * f * hazeEmission));
params.surface().set_specular(half(0.5 * (1.0 - f)));
}

float viewDistance(realitykit::surface_parameters params)
{
float3 wp = params.geometry().world_position();
return length((params.uniforms().world_to_view() * float4(wp, 1.0)).xyz);
}

}

[[visible]]
Expand All @@ -41,7 +70,7 @@ void groundSurface(realitykit::surface_parameters params)
float tile = p.x;

// Odległość od kamery z macierzy widoku — bez podmiany materiału co klatkę.
float dist = length((params.uniforms().world_to_view() * float4(wp, 1.0)).xyz);
float dist = viewDistance(params);

auto tex = params.textures().base_color();
// Średni kolor zdjęcia (najmniejszy mip) — względem niego liczymy, o ile duża skala rozjaśnia lub przyciemnia.
Expand All @@ -68,10 +97,87 @@ void groundSurface(realitykit::surface_parameters params)
color *= 1.0 + n1 * 0.09 + n2 * 0.04;
color *= mix(float3(0.985, 1.0, 1.02), float3(1.02, 1.0, 0.97), n3 * 0.5 + 0.5);

params.surface().set_base_color(half3(saturate(color)));
// Barwa dopasowana do gruntu na zdjęciu panoramy (tint — odcień, w — wzmocnienie; garaż: biały i 1).
color *= float3(params.material_constants().base_color_tint()) * p.w;

applyHaze(params, color, dist, p.z);
params.surface().set_roughness(1.0);
params.surface().set_metallic(0.0);
if (p.z > 0.5) {
if (p.y > 0.0) {
params.surface().set_opacity(half(1.0 - smoothstep(p.y, p.y * 2.5, dist)));
}
}

// Jezdnia: zdjęcie asfaltu (kafel 4 m, UV w metrach / kafel: u — w poprzek, v — wzdłuż) i to, czego kafel
// nie ma, a widać na każdej prawdziwej drodze: plamy jaśniejsze i ciemniejsze w skali kilkudziesięciu metrów,
// wyjeżdżone (gładsze, ciemniejsze) ślady kół na każdym pasie i jaśniejszy, matowy kurz przy krawężnikach.
// custom_parameter: x = kafel (m), y = połowa szerokości jezdni (m), z = mgła (hazeAmount).
[[visible]]
void roadSurface(realitykit::surface_parameters params)
{
constexpr sampler tiled(address::repeat, filter::linear, mip_filter::linear, max_anisotropy(16));
float4 p = params.uniforms().custom_parameter();
float tile = p.x, hw = p.y;
float2 uv = float2(params.geometry().uv0());
float3 wp = params.geometry().world_position();
// UV w RealityKit ma odwrócone v względem siatki — d bierzemy z u, które nie jest odwracane.
float d = uv.x * tile;

float3 color = float3(params.textures().base_color().sample(tiled, uv).rgb)
* float3(params.material_constants().base_color_tint());
float rough = float(params.textures().roughness().sample(tiled, uv).r) * 0.9;

// Plamy: łatki, starsze i nowsze odcinki nawierzchni.
float n1 = valueNoise(wp.xz / 38.0 + 3.0), n2 = valueNoise(wp.xz / 9.0 - 11.0);
color *= 1.0 + n1 * 0.08 + n2 * 0.035;

// Ślady kół: trzy pasy (granice na ±hw/3), w każdym dwa ślady 0,8 m od środka pasa.
float lane = hw * 2.0 / 3.0;
float inLane = fmod(d + hw + lane * 4.0, lane) - lane * 0.5; // odległość od środka pasa
float wheel = 1.0 - smoothstep(0.18, 0.45, abs(abs(inLane) - 0.8));
// Ślady nie są jednolite: wyraźniejsze i słabsze odcinki wzdłuż drogi.
wheel *= 0.6 + 0.4 * (valueNoise(float2(d * 0.5, wp.x * 0.05 + wp.z * 0.05)) * 0.5 + 0.5);
color *= 1.0 - wheel * 0.12;
rough -= wheel * 0.12;
// Środek pasa: olej kapiący z aut — lekko ciemniejszy pas.
color *= 1.0 - (1.0 - smoothstep(0.15, 0.5, abs(inLane))) * 0.05;

// Kurz i piasek przy krawężnikach: jaśniej, cieplej, matowo.
float edge = smoothstep(hw - 1.1, hw - 0.1, abs(d));
edge *= 0.7 + 0.3 * (valueNoise(wp.xz / 4.0) * 0.5 + 0.5);
color = mix(color, color * float3(1.22, 1.16, 1.06), edge);
rough = mix(rough, 1.0, edge * 0.6);

applyHaze(params, color, viewDistance(params), p.z);
params.surface().set_roughness(half(saturate(rough)));
params.surface().set_metallic(0.0);
float3 n = float3(params.textures().normal().sample(tiled, uv).rgb) * 2.0 - 1.0;
params.surface().set_normal(normalize(n));
}

// Materiał PBR (mury, budynki, linie, krawężniki, rekwizyty) z mgłą — Haze.apply przepisuje na niego
// PhysicallyBasedMaterial z tymi samymi teksturami. custom_parameter: x = mgła, y = maska tekstur
// (1 — chropowatość, 2 — metaliczność, 4 — normalne, 8 — kolor); bez tekstury liczy się sama skala.
[[visible]]
void hazeSurface(realitykit::surface_parameters params)
{
constexpr sampler tiled(address::repeat, filter::linear, mip_filter::linear, max_anisotropy(16));
float4 p = params.uniforms().custom_parameter();
int flags = int(p.y + 0.5);
float2 uv = float2(params.geometry().uv0());
uv.y = 1.0 - uv.y; // w shaderze v jest odwrócone względem materiału PBR
auto mc = params.material_constants();
float3 color = float3(mc.base_color_tint());
if (flags & 8) { color *= float3(params.textures().base_color().sample(tiled, uv).rgb); }
float rough = mc.roughness_scale();
if (flags & 1) { rough *= float(params.textures().roughness().sample(tiled, uv).r); }
float metal = mc.metallic_scale();
if (flags & 2) { metal *= float(params.textures().metallic().sample(tiled, uv).r); }
if (flags & 4) {
float3 n = float3(params.textures().normal().sample(tiled, uv).rgb) * 2.0 - 1.0;
params.surface().set_normal(normalize(n));
}
applyHaze(params, color, viewDistance(params), p.x);
params.surface().set_roughness(half(saturate(rough)));
params.surface().set_metallic(half(saturate(metal)));
}
100 changes: 100 additions & 0 deletions App/Render/Haze.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Metal
import RaceCore
import RealityKit
import UIKit

/// Mgła powietrzna liczona w shaderach otoczenia (grunt, jezdnia, mury, budynki, rekwizyty): dalekie obiekty 3D
/// bledną w stronę koloru horyzontu panoramy jak prawdziwe budynki i drzewa na zdjęciu za nimi.
///
/// Dawniej mgła była w obróbce obrazu (RacePostEffect) i liczyła odległość z tekstury głębi — ale RealityKit
/// (iOS 27, symulator) oddaje tam pustą głębię (z = 0 w każdym pikselu), więc mgły nie było nigdy.
/// W shaderze materiału odległość od kamery jest zawsze znana.
///
/// Parametry w shaderze: gęstość i zasięg spakowane w jedną liczbę `packed = zasięg (m, całkowity) + gęstość (0…1)`,
/// kolor w `emissiveColor` materiału; `packed = 0` — bez mgły (garaż).
struct SceneHaze {
/// Kolor mgły (sRGB 0…1) — rozjaśniony i ochłodzony kolor pasa nad horyzontem panoramy.
var color: SIMD3<Float>
/// Największy udział mgły (0…1) i odległość (m), na której mgła osiąga ~63% tej wartości.
var density: Float
var scale: Float

var packed: Float { scale.rounded() + min(max(density, 0), 0.999) }
var uiColor: UIColor { UIColor(red: CGFloat(color.x), green: CGFloat(color.y), blue: CGFloat(color.z), alpha: 1) }

/// Bez mgły (garaż, testy).
static let none = SceneHaze(color: [1, 1, 1], density: 0, scale: 0)

/// Mgła krainy: pustynia — suche, przejrzyste powietrze; las i śnieg — wilgotne, gęstsze.
@MainActor static func of(_ region: Region) -> SceneHaze {
let key = LocalAssets.regionKey(region)
let fog: [String: (density: Float, scale: Float)] = [
"city": (0.5, 300), "coast": (0.45, 380), "desert": (0.32, 480), "forest": (0.45, 260), "snow": (0.55, 230),
]
let f = fog[key] ?? (0.4, 400)
var haze = SceneHaze(color: PostFXSettings.forRegion(key).fogColor, density: f.density, scale: f.scale)
#if DEBUG || FAMILY
// `-haze d,s`: gęstość i zasięg do strojenia (i do kanarka: `-haze 0.99,20` musi zamglić cały kadr).
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-haze"), i + 1 < args.count {
let v = args[i + 1].split(separator: ",").compactMap { Float($0) }
if v.count == 2 { haze.density = v[0]; haze.scale = v[1] }
}
if args.contains("-noHaze") { haze.density = 0 }
#endif
return haze
}

/// Mgła trasy, która się właśnie buduje — materiały otoczenia biorą ją stąd.
@MainActor static var current = SceneHaze.none

/// Materiał PBR przepisany na shader `hazeSurface` (te same tekstury i parametry + mgła).
/// `nil` bez mgły albo bez shadera — wtedy zostaje zwykły materiał.
@MainActor static func apply(_ pbr: PhysicallyBasedMaterial) -> (any Material)? {
let haze = current
// Przezroczyste i wycinane (banery, szyby) zostają przy swoim materiale.
guard haze.density > 0, pbr.opacityThreshold == nil, case .opaque = pbr.blending, let library = Self.library,
var m = try? CustomMaterial(surfaceShader: .init(named: "hazeSurface", in: library), lightingModel: .lit)
else { return nil }
m.baseColor = .init(tint: pbr.baseColor.tint, texture: pbr.baseColor.texture.map { .init($0.resource) })
var flags: Float = 0
if let t = pbr.roughness.texture { m.roughness = .init(scale: pbr.roughness.scale, texture: .init(t.resource)); flags += 1 } else {
m.roughness = .init(floatLiteral: pbr.roughness.scale)
}
if let t = pbr.metallic.texture { m.metallic = .init(scale: pbr.metallic.scale, texture: .init(t.resource)); flags += 2 } else {
m.metallic = .init(floatLiteral: pbr.metallic.scale)
}
if let t = pbr.normal.texture { m.normal = .init(texture: .init(t.resource)); flags += 4 }
if pbr.baseColor.texture != nil { flags += 8 }
m.emissiveColor = .init(color: haze.uiColor)
m.custom.value = [haze.packed, flags, 0, 0]
return m
}

/// Podmienia materiały PBR wszystkich modeli w drzewie na wersje z mgłą; zwraca liczbę podmienionych
/// (do logu — zero przy włączonej mgle znaczy, że mgły nie ma nigdzie).
@MainActor @discardableResult static func applyAll(_ root: Entity) -> Int {
var count = 0
func visit(_ e: Entity) {
if let model = e as? ModelEntity, var comp = model.model {
var changed = false
comp.materials = comp.materials.map { m in
guard let pbr = m as? PhysicallyBasedMaterial, let hazy = apply(pbr) else { return m }
changed = true
return hazy
}
if changed { model.model = comp; count += 1 }
}
for c in e.children { visit(c) }
}
visit(root)
return count
}

/// Mgła w materiale `CustomMaterial` z własnym shaderem (grunt, jezdnia): kolor w `emissiveColor`.
@MainActor static func tag(_ m: inout CustomMaterial) {
m.emissiveColor = .init(color: current.uiColor)
}

@MainActor private static let library: (any MTLLibrary)? = MTLCreateSystemDefaultDevice()?.makeDefaultLibrary()
}
20 changes: 15 additions & 5 deletions App/Render/Impostors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,26 @@ struct GroundMaterial {
/// Zanik kończy się na `fadeStart * fadeEnd` (musi się zgadzać z Ground.metal).
static let fadeEnd: Float = 2.5

static func make(_ tex: TextureResource, tile: Float = tile, fadeStart: Float = fadeStart) -> GroundMaterial? {
/// `colorScale` — mnożnik koloru tekstury (liniowy RGB, może przekraczać 1): grunt w barwie gruntu ze zdjęcia
/// panoramy, w który przechodzi w oddali.
@MainActor static func make(_ tex: TextureResource, tile: Float = tile, fadeStart: Float = fadeStart,
colorScale: SIMD3<Float> = [1, 1, 1]) -> GroundMaterial? {
guard let device = MTLCreateSystemDefaultDevice(), let library = device.makeDefaultLibrary(),
var m = try? CustomMaterial(surfaceShader: .init(named: "groundSurface", in: library), lightingModel: .lit)
else { return nil }
m.baseColor = .init(tint: .white, texture: .init(tex))
// x = kafel, y = początek zanikania; z = 1 włącza zanikanie (materiał nieprzezroczysty go nie potrzebuje).
m.custom.value = [tile, fadeStart, 0, 0]
// Odcień w tincie (składowe ≤ 1), wzmocnienie w custom.w.
let gain = max(colorScale.max(), 0.01)
let tint = colorScale / gain
m.baseColor = .init(tint: UIColor(red: CGFloat(tint.x), green: CGFloat(tint.y), blue: CGFloat(tint.z), alpha: 1),
texture: .init(tex))
// x = kafel, y = początek zanikania (ujemny — bez zanikania: materiał nieprzezroczysty go nie potrzebuje),
// z = mgła (SceneHaze.packed), w = wzmocnienie koloru.
let haze = SceneHaze.current.packed
SceneHaze.tag(&m)
m.custom.value = [tile, -fadeStart, haze, gain]
let o = m
m.blending = .transparent(opacity: 1.0)
m.custom.value = [tile, fadeStart, 1, 0]
m.custom.value = [tile, fadeStart, haze, gain]
return GroundMaterial(material: m, opaque: o, tile: tile, fadeStart: fadeStart)
}

Expand Down
2 changes: 2 additions & 0 deletions App/Render/LocalAssets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ final class LocalAssets {
author: "Poly Haven (polyhaven.com)", license: "CC0", source: "https://polyhaven.com/hdris"),
Credit(title: "Fasady budynków (Facade 018A, 019A, 020A, 006, 001)",
author: "ambientCG (ambientcg.com)", license: "CC0", source: "https://ambientcg.com/list?category=Facade"),
Credit(title: "Chodnik w mieście (Paving Stones 128)",
author: "ambientCG (ambientcg.com)", license: "CC0", source: "https://ambientcg.com/view?id=PavingStones128"),
Credit(title: "Stal barier, latarni i słupków (Metal 049 B)",
author: "ambientCG (ambientcg.com)", license: "CC0", source: "https://ambientcg.com/view?id=Metal049B"),
Credit(title: "Parter i gzymsy budynków (Stone Tile Wall, Beige Wall 001)",
Expand Down
21 changes: 19 additions & 2 deletions App/Render/Materials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,24 @@ enum Materials {
static let roadSurfaceMaterial = tiled(roadSurface, roughness: 0.82)

/// Asfalt PBR: kolor, normalne (drobne kamyki łapią światło) i chropowatość — tekstury z ScenePreloader.
/// Jezdnia z shaderem `roadSurface` (Ground.metal): zdjęcie asfaltu + plamy, ślady kół, kurz przy krawędziach.
/// `nil`, gdy brak plików albo shadera — wtedy zwykły `photoAsphalt`.
@MainActor static func roadAsphalt(halfWidth: Double, tile: Float = 4) -> CustomMaterial? {
func tex(_ name: String) -> TextureResource? {
Bundle.main.url(forResource: name, withExtension: "jpg").flatMap(SkyEnvironment.texture)
}
guard let color = tex("road-asphalt"), let normal = tex("road-asphalt-normal"), let rough = tex("road-asphalt-rough"),
let device = MTLCreateSystemDefaultDevice(), let library = device.makeDefaultLibrary(),
var m = try? CustomMaterial(surfaceShader: .init(named: "roadSurface", in: library), lightingModel: .lit)
else { return nil }
m.baseColor = .init(tint: UIColor(white: 0.92, alpha: 1), texture: .init(color))
m.normal = .init(texture: .init(normal))
m.roughness = .init(scale: 1, texture: .init(rough))
SceneHaze.tag(&m)
m.custom.value = [tile, Float(halfWidth), SceneHaze.current.packed, 0]
return m
}

static func photoAsphalt() -> PhysicallyBasedMaterial? {
func tex(_ name: String) -> TextureResource? {
Bundle.main.url(forResource: name, withExtension: "jpg").flatMap(SkyEnvironment.texture)
Expand All @@ -209,8 +227,7 @@ enum Materials {
s.maxAnisotropy = 16
if let n = tex("road-asphalt-normal") { m.normal = .init(texture: .init(n, sampler: .init(s))) }
if let r = tex("road-asphalt-rough") { m.roughness = .init(scale: 0.9, texture: .init(r, sampler: .init(s))) }
// Odrobinę ciemniej niż na zdjęciu: jasny asfalt zlewał się z betonowymi barierami.
m.baseColor.tint = UIColor(white: 0.82, alpha: 1)
m.baseColor.tint = UIColor(white: 0.92, alpha: 1)
return m
}
}
Loading
Loading