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
14 changes: 0 additions & 14 deletions .github/workflows/ci-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,6 @@ jobs:
--skip 'UntoldEngineRenderTests.RemoteStreamFlyThroughTests' \
--skip 'UntoldEngineRenderTests.PerformanceTests'

# ✅ Run remote streaming PSNR in isolation.
# The flythrough test mutates streaming budgets and waits on remote tile
# residency; running it inside the broad parallel sweep can capture an
# unsettled waypoint frame even when the same test is deterministic alone.
- name: Run remote stream flythrough PSNR
timeout-minutes: 20
env:
CI: true
UNTOLD_PSNR_THRESHOLD: "33.5"
UNTOLD_PYTHON: "python3"
run: |
command -v "${UNTOLD_PYTHON}" >/dev/null 2>&1 || { echo "python not found"; exit 1; }
swift test -v --disable-swift-testing --filter 'UntoldEngineRenderTests.RemoteStreamFlyThroughTests/testRemoteStreamFlythrough_psnr'

# ✅ Run performance benchmarks as CI gates, isolated from the parallel renderer suite.
# These numbers are intentionally configurable because GitHub-hosted macOS runners
# are shared machines. They still fail the build on meaningful regressions, while
Expand Down
23 changes: 23 additions & 0 deletions Sources/CShaderTypes/ShaderTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,20 @@ typedef struct{
// 1 when the material has an emissive texture: the light it gives off is then
// `emmissive` times the texture's color, and `emmissive` alone otherwise.
int hasEmissiveTexture;
// How much of the surface is glass, from 0 to 1: what is behind it shows through,
// tinted by the base color (see fragmentTransparencyShader).
float transmission;
}MaterialParametersUniform;

// Glass and roughness. Nothing is blurred behind glass, so rough glass shows less of
// what is behind it and glows with the light that comes from behind it instead: all of
// what is behind it shows up to the first roughness, none of it from the second, and a
// smooth step leads from one to the other (see fragmentTransparencyShader). From the
// second roughness on a material is drawn as a solid surface (Material.transmitsLight).
// The exporter's roughness_sharpness repeats the two values.
#define GLASS_CLEAR_UP_TO_ROUGHNESS 0.05f
#define GLASS_FROSTED_FROM_ROUGHNESS 0.5f

// Runtime-tunable Parallax Occlusion Mapping cost controls (global, not per-material — see
// POMQualitySettings in Globals.swift). minSteps/maxSteps bound the adaptive ray-march step
// count; maxDistance/fadeStartDistance fade POM out entirely beyond a configurable distance,
Expand Down Expand Up @@ -1386,8 +1398,19 @@ typedef enum {
transparencyPassAreaLightsIndex, // AreaLightBlock
transparencyPassIBLParamIndex, // IBLParamsUniform
transparencyPassIBLRotationAngleIndex, // float
transparencyPassFacesIndex, // int (TransparencyPassFaces)
} TransparencyPassLightingBufferIndices;

// Which faces of a surface a draw of the transparency pass keeps. Glass is drawn in two
// goes, its faces turned away from the viewer first and the ones turned towards the
// viewer over them, so that the far side of a pane or a bottle never comes out over
// its near side. The other materials are drawn in one go.
typedef enum {
transparencyPassEveryFace,
transparencyPassFarFaces,
transparencyPassNearFaces,
} TransparencyPassFaces;

typedef enum {
transparencyPassAreaLTCMatTextureIndex = 5, // starts after TransparencyPassTextureIndices
transparencyPassAreaLTCMagTextureIndex, // LTC magnitude texture
Expand Down
8 changes: 2 additions & 6 deletions Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
@MainActor
@Observable
final class LargeSceneStreamingState {
var status = "Loading default remote scene..."
var status = "Loading offline reference field..."
var customManifestURL = ""
var isLoading = false
var tileBoundsEnabled = true
Expand All @@ -47,7 +47,7 @@
setupWindow()
setupRendererAndScene()
presentSceneView()
gameScene.loadPreset(.dungeon)
gameScene.loadFallbackField()
}

func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
Expand Down Expand Up @@ -99,7 +99,6 @@
renderer: renderer,
state: state,
actions: .init(
loadPreset: { [weak self] preset in self?.gameScene.loadPreset(preset) },
loadCustomURL: { [weak self] url in self?.gameScene.loadManifest(url: url, label: "Custom Manifest") },
loadFallbackField: { [weak self] in self?.gameScene.loadFallbackField() },
setTileBounds: { [weak self] enabled in self?.gameScene.setTileBoundsDebug(enabled) },
Expand All @@ -116,7 +115,6 @@
}

private struct LargeSceneStreamingActions {
let loadPreset: (GameScene.RemoteScenePreset) -> Void
let loadCustomURL: (URL) -> Void
let loadFallbackField: () -> Void
let setTileBounds: (Bool) -> Void
Expand Down Expand Up @@ -155,8 +153,6 @@
}

HStack {
Button("Dungeon") { actions.loadPreset(.dungeon) }
Button("City") { actions.loadPreset(.city) }
Button("Field") { actions.loadFallbackField() }
}

Expand Down
29 changes: 2 additions & 27 deletions Sources/Demos/LargeSceneStreamingDemo/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,8 @@
import UntoldEngine

final class GameScene: @unchecked Sendable {
enum RemoteScenePreset: String, CaseIterable {
case dungeon = "Dungeon"
case city = "City"

var manifestURL: URL {
switch self {
case .dungeon:
URL(string: "https://d8pyi1c08k1w.cloudfront.net/dungeon3/dungeon3.json")!
case .city:
URL(string: "https://d8pyi1c08k1w.cloudfront.net/city/city.json")!
}
}

var cameraEye: simd_float3 {
switch self {
case .dungeon: simd_float3(0.0, 4.0, 18.0)
case .city: simd_float3(0.0, 18.35, 73.56)
}
}
}

private enum Constants {
static let defaultCameraEye = simd_float3(0.0, 4.0, 18.0)
static let cameraMoveSpeed: Float = 9.0
static let cameraInputDeltaTime: Float = 1.0 / 60.0
static let orbitTargetOffset: Float = 25.0
Expand All @@ -64,11 +44,6 @@
setSceneReady(false)
}

func loadPreset(_ preset: RemoteScenePreset) {
placeCamera(eye: preset.cameraEye)
loadManifest(url: preset.manifestURL, label: preset.rawValue)
}

func loadManifest(url: URL, label: String) {
clearLoadedContent()
setSceneReady(false)
Expand Down Expand Up @@ -206,7 +181,7 @@
}

private func createCamera() {
makeDemoCamera(name: "Streaming Camera", eye: RemoteScenePreset.dungeon.cameraEye, orbitOffset: Constants.orbitTargetOffset)
makeDemoCamera(name: "Streaming Camera", eye: Constants.defaultCameraEye, orbitOffset: Constants.orbitTargetOffset)
}

private func createLight() {
Expand Down
3 changes: 1 addition & 2 deletions Sources/Demos/LargeSceneStreamingDemo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ What it demonstrates:
- tile bounds, LOD debug, and texture tier debug overlays
- live engine stats for streaming, batching, draw calls, and memory

The default remote scenes reuse the same public manifests as `ShowcaseDemo`. The
`Field` button loads a procedural offline reference field so the executable still
The `Field` button loads a procedural offline reference field so the executable
runs without network access, but that mode is not tile streaming.

To test your own exported world, paste a full `https://.../scene.json` or
Expand Down
51 changes: 0 additions & 51 deletions Sources/Demos/ShowcaseDemo/DemoHUD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,26 +186,6 @@
resolutionMenu
}

HStack(alignment: .center, spacing: 8) {
Picker("Remote Scene", selection: $state.selectedRemoteSceneID) {
ForEach(state.remoteScenes) { scene in
Text(scene.title).tag(scene.id)
}
}
.pickerStyle(.menu)
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(state.isLoading || state.remoteScenes.isEmpty)
Button("Load", action: loadSelectedRemoteScene)
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(state.isLoading || state.selectedRemoteScene?.manifestURL == nil)
if state.isLoading {
ProgressView()
.scaleEffect(0.6)
.frame(width: 16, height: 16)
}
}

HStack(alignment: .center, spacing: 8) {
Text("Local Scene")
.foregroundStyle(.secondary)
Expand Down Expand Up @@ -495,30 +475,6 @@
.disabled(!enabled)
}

private func loadSelectedRemoteScene() {
guard let scene = state.selectedRemoteScene,
let manifestURL = scene.manifestURL,
let onLoadTiledScene = state.onLoadTiledScene
else { return }

state.batchingEnabled = false
state.streamingEnabled = false
state.isLoading = true

let sceneID = scene.id
onLoadTiledScene(sceneID, manifestURL) { success in
Task { @MainActor in
state.isLoading = false
state.hasLoadedEntity = success
state.streamingEnabled = success
if success {
state.selectedPostFXPreset = Self.postFXPreset(for: sceneID)
state.applySelectedPostFXPreset()
}
}
}
}

private func openLocalAssetPicker() {
localImportMode = .asset
showFilePicker = true
Expand Down Expand Up @@ -590,13 +546,6 @@
}
}

private static func postFXPreset(for sceneID: String) -> DemoState.PostFXPreset {
switch sceneID {
case "f1car", "airplane", "porsche964": .cinematic
default: .neutral
}
}

private func finishLocalImport(url: URL, accessing: Bool, success: Bool, streamingEnabled: Bool) {
state.isLoading = false
state.hasLoadedEntity = success
Expand Down
39 changes: 0 additions & 39 deletions Sources/Demos/ShowcaseDemo/DemoState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
private enum Defaults {
static let streamingRadius: Double = 200.0
static let unloadRadius: Double = 350.0
static let selectedRemoteSceneID = "dungeon"
}

enum PostFXPreset: String, CaseIterable, Identifiable {
Expand Down Expand Up @@ -70,48 +69,10 @@

@ObservationIgnored private var isApplyingPostFXPreset = false

struct RemoteSceneOption: Identifiable, Hashable {
let id: String
let title: String
let manifestURL: URL?
}

// MARK: - File Loading

var hasLoadedEntity: Bool = false
var isLoading: Bool = false
let remoteScenes: [RemoteSceneOption] = [
.init(
id: "dungeon",
title: "Game Dungeon",
manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/dungeon3/dungeon3.json")!
),
.init(
id: "city",
title: "Cartoon City",
manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/city/city.json")!
),
.init(
id: "f1car",
title: "Formula 1",
manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/F1Car/F1Car.json")!
),
.init(
id: "airplane",
title: "Skyhawk",
manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Shyhawk_stream/Skyhawks.json")!
),
.init(
id: "porsche964",
title: "Porsche 964",
manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Porsche964-stream/Porsche964-stream.json")!
),
]
var selectedRemoteSceneID: String = Defaults.selectedRemoteSceneID

var selectedRemoteScene: RemoteSceneOption? {
remoteScenes.first { $0.id == selectedRemoteSceneID }
}

var localSceneAuthoredEnabled: Bool = false

Expand Down
22 changes: 21 additions & 1 deletion Sources/UntoldEngine/AssetFormat/UntoldFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,24 @@ public struct UntoldMaterialRecordV1: Sendable, Equatable {
return UntoldTextureChannel.decoded(from: word >> Self.metallicChannelShift)
}

/// How much of the surface is glass: 0 for a solid surface, 1 for one that lets
/// through what is behind it, tinted by its base color (Blender's Transmission
/// Weight). It is kept in the second reserved word, which every file written
/// before it leaves at zero: those files read as no transmission.
public var transmissionFactor: Float {
get {
let value = Float(bitPattern: reserved0.count > 1 ? reserved0[1] : 0)
return value.isFinite ? min(max(value, 0.0), 1.0) : 0.0
}
set {
while reserved0.count < 2 {
reserved0.append(0)
}
let factor = newValue.isFinite ? min(max(newValue, 0.0), 1.0) : 0.0
reserved0[1] = factor > 0.0 ? factor.bitPattern : 0
}
}

public static func packTextureChannels(
roughness: UntoldTextureChannel = .r,
metallic: UntoldTextureChannel = .r
Expand Down Expand Up @@ -476,7 +494,8 @@ public struct UntoldMaterialRecordV1: Sendable, Equatable {
heightRemapMin: Float = 0.0,
heightRemapMax: Float = 1.0,
roughnessTextureChannel: UntoldTextureChannel = .r,
metallicTextureChannel: UntoldTextureChannel = .r
metallicTextureChannel: UntoldTextureChannel = .r,
transmissionFactor: Float = 0.0
) {
self.nameOffset = nameOffset
self.flags = flags
Expand Down Expand Up @@ -505,6 +524,7 @@ public struct UntoldMaterialRecordV1: Sendable, Equatable {
),
0,
]
self.transmissionFactor = transmissionFactor
}
}

Expand Down
23 changes: 17 additions & 6 deletions Sources/UntoldEngine/ECS/ComponentPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ enum ComponentSlot {
while !_stdlib_atomicCompareExchangeStrongPtr(object: word, expected: &held, desired: reference) {}
}

/// The component in `slot`, read as a `T`. Only for a slot that holds one.
/// The component in `slot`, read as a `T`. Nil for a slot of references that never
/// held one; a slot of values is only read when it holds one.
@inline(__always)
static func load<T>(from slot: UnsafeMutableRawPointer, as _: T.Type, asReference: Bool) -> T? {
guard asReference else {
Expand Down Expand Up @@ -268,15 +269,25 @@ public struct ComponentPool {
guard capacity <= index else { return }
var chunks = chunks
while chunks.count * Self.chunkCapacity <= index {
chunks.append(
UnsafeMutableRawPointer.allocate(
byteCount: elementSize * Self.chunkCapacity, alignment: MemoryLayout<UInt8>.alignment
)
)
chunks.append(Self.makeChunk(elementSize: elementSize, holdsReferences: holdsReferences))
}
storage = Self.makeStorage(chunks: chunks, elementSize: elementSize, holdsReferences: holdsReferences, quarantine: quarantine)
}

/// The memory of one chunk. Slots of references start as no reference: a slot is
/// read before it is first written (ComponentSlot.store exchanges what the slot
/// holds), and one that never held a component then reads as none.
private static func makeChunk(elementSize: Int, holdsReferences: Bool) -> UnsafeMutableRawPointer {
guard holdsReferences else {
return .allocate(byteCount: elementSize * chunkCapacity, alignment: MemoryLayout<UInt8>.alignment)
}
let chunk = UnsafeMutableRawPointer.allocate(
byteCount: elementSize * chunkCapacity, alignment: MemoryLayout<UnsafeRawPointer?>.alignment
)
chunk.initializeMemory(as: UnsafeRawPointer?.self, repeating: nil, count: chunkCapacity)
return chunk
}

public func get(_ index: Int) -> UnsafeMutableRawPointer? {
storage.withUnsafeMutablePointers { header, chunks in
guard index >= 0, index < header.pointee.chunkCount * Self.chunkCapacity else { return nil }
Expand Down
Loading
Loading