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
405 changes: 382 additions & 23 deletions Editor/Sources/EditorApp/Panels/EditorStatPanel.swift

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Editor/Sources/EditorCore/Scene/EditorSceneAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1553,14 +1553,14 @@ public final class EditorSceneAdapter: @unchecked Sendable {
scene.snapshot.entityCount
}

/// True while any emitter is still emitting or has live particles. Under the
/// True while any emitter can still emit or has live particles. Under the
/// event-driven frame policy an idle viewport stops ticking, which would
/// freeze particle motion; the render gate uses this to keep driving frames
/// while particles are alive and fall back to idle once they die out.
public func hasActiveParticles() -> Bool {
for entity in scene.entities(with: ParticleEmitter.self) {
guard let emitter = scene.component(ParticleEmitter.self, for: entity) else { continue }
if emitter.isEmitting || emitter.aliveCount > 0 { return true }
if emitter.isEmissionActive || emitter.aliveCount > 0 { return true }
}
return false
}
Expand Down
180 changes: 180 additions & 0 deletions Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,122 @@ struct DeveloperParticleDiagnosticsTests {
#expect(summary.details.contains("GPU workgroups 15"))
}

@Test("render budget skips are reported as particle tuning diagnostics")
func renderBudgetSkipsAreReportedAsTuningDiagnostics() {
let stats = ParticleFrameStatsResource(emitterStats: [
ParticleEmitterFrameStats(liveParticleCount: 8,
maxParticleCount: 16,
liveParticleLimit: 16),
])
let renderSummary = ParticleRenderSummary(particleCount: 5,
sourceParticleCount: 8,
submittedSourceParticleCount: 5,
cpuRenderInstanceCount: 5,
batchCount: 1,
cpuBatchCount: 1)

let summary = makeDeveloperParticleDiagnosticSummary(
stats: stats,
eventReport: .empty,
scalability: .default,
renderSummary: renderSummary,
renderStats: .init()
)

#expect(summary.severity == .info)
#expect(summary.status == "Particle render budget limiting")
#expect(summary.primarySignal == "3 source particles skipped before render")
#expect(summary.recommendation.contains("Max Rendered Particles"))
#expect(summary.details.contains("Source 8, submitted 5"))
}

@Test("emitter labels preserve hierarchy paths")
func emitterLabelsPreserveHierarchyPaths() {
let roots = [
EditorSceneNode(id: 1,
name: "World",
kind: "Scene",
children: [
EditorSceneNode(id: 2,
name: "FX Rig",
kind: "Entity",
children: [
EditorSceneNode(id: 3,
name: "Muzzle Sparks",
kind: "Particle Emitter",
children: []),
]),
]),
]

let labels = makeDeveloperParticleEmitterLabels(roots: roots)

#expect(labels[1]?.path == "World")
#expect(labels[2]?.path == "World / FX Rig")
#expect(labels[3]?.name == "Muzzle Sparks")
#expect(labels[3]?.kind == "Particle Emitter")
#expect(labels[3]?.path == "World / FX Rig / Muzzle Sparks")
}

@Test("authoring diagnostics report GPU-required blockers")
func authoringDiagnosticsReportGPURequiredBlockers() {
let emitter = ParticleEmitter(distanceEmissionRate: 12,
maxParticles: 256,
simulationBackend: .gpuRequired)
let issue = ParticleModuleIssue(moduleID: "gpuSimulation",
severity: .error,
code: "gpuRequiredButUnsupported",
message: "GPU simulation is required but unsupported: distance emission.")

let summary = makeDeveloperParticleAuthoringDiagnosticSummary(
gpuPlan: emitter.gpuSimulationPlan,
moduleIssues: [issue]
)

#expect(summary.severity == .critical)
#expect(summary.status == "Authoring blocked")
#expect(summary.primarySignal == "1 module error")
#expect(summary.recommendation.contains("blocked GPU-required"))
#expect(summary.details.contains { $0.contains("gpuSimulation [error]") })
#expect(summary.details.contains { $0.contains("Unsupported distance emission") })
}

@Test("authoring diagnostics explain GPU fallback reasons")
func authoringDiagnosticsExplainGPUFallbackReasons() {
let emitter = ParticleEmitter(distanceEmissionRate: 8,
maxParticles: 128,
simulationBackend: .gpuIfSupported)

let summary = makeDeveloperParticleAuthoringDiagnosticSummary(
gpuPlan: emitter.gpuSimulationPlan,
moduleIssues: []
)

#expect(summary.severity == .warning)
#expect(summary.status == "GPU fallback to CPU")
#expect(summary.primarySignal == "Unsupported: distance emission")
#expect(summary.recommendation.contains("Remove unsupported features"))
#expect(summary.details.contains("GPU plan Fallback"))
}

@Test("authoring diagnostics summarize ready GPU dispatch")
func authoringDiagnosticsSummarizeReadyGPUDispatch() {
let emitter = ParticleEmitter(maxParticles: 512,
simulationBackend: .gpuIfSupported,
gpuSimulationWorkgroupSize: 128)

let summary = makeDeveloperParticleAuthoringDiagnosticSummary(
gpuPlan: emitter.gpuSimulationPlan,
moduleIssues: []
)

#expect(summary.severity == .nominal)
#expect(summary.status == "GPU simulation ready")
#expect(summary.primarySignal == "Dispatch 4x128 for 512 capacity")
#expect(summary.details.contains("GPU plan Ready"))
#expect(summary.details.contains("Capacity 512, dispatch 4x128"))
}

@Test("emitter hotspots prioritize drops and merge event feedback")
func emitterHotspotsPrioritizeDropsAndMergeEventFeedback() {
let noisyButHealthy = ParticleEmitterFrameStats(liveParticleCount: 40,
Expand Down Expand Up @@ -169,9 +285,33 @@ struct DeveloperParticleDiagnosticsTests {
#expect(hotspots.map(\.entityID) == [20, 10])
#expect(hotspots[0].severity == .critical)
#expect(hotspots[0].reason == "Capacity drops")
#expect(hotspots[0].primarySignal == "2 capacity-limited spawns")
#expect(hotspots[0].recommendation.contains("max particles"))
#expect(hotspots[0].details.contains("Frame drops 8, event drops 2"))
#expect(hotspots[0].requestedSpawnCount == 15)
#expect(hotspots[0].droppedSpawnCount == 10)
#expect(hotspots[1].severity == .info)
#expect(hotspots[1].primarySignal == "40 spawn requests")
#expect(hotspots[1].recommendation.contains("emission curves"))
}

@Test("emitter hotspot explains live-budget pressure")
func emitterHotspotExplainsLiveBudgetPressure() {
let stats = ParticleEmitterFrameStats(liveParticleCount: 95,
maxParticleCount: 100,
liveParticleLimit: 100,
requestedSpawnCount: 0,
spawnBudgetLimit: 20,
spawnBudgetConsumedCount: 0)

let hotspot = makeDeveloperParticleEmitterHotspot(entityID: 42,
frameStats: stats)

#expect(hotspot.severity == .warning)
#expect(hotspot.reason == "Live budget")
#expect(hotspot.primarySignal == "95% live budget used")
#expect(hotspot.recommendation.contains("Reduce lifetime"))
#expect(hotspot.details.contains("Live 95/100"))
}

@Test("emitter hotspots respect the requested limit")
Expand All @@ -196,6 +336,46 @@ struct DeveloperParticleDiagnosticsTests {
#expect(hotspots.map(\.entityID) == [3, 2])
}

@Test("emitter hotspots sort severity before raw pressure score")
func emitterHotspotsSortSeverityBeforeRawPressureScore() {
let highRequestInfo = ParticleEmitterFrameStats(liveParticleCount: 10,
maxParticleCount: 1_000,
liveParticleLimit: 1_000,
continuousSpawnedCount: 50_000,
requestedSpawnCount: 50_000,
spawnBudgetConsumedCount: 50_000)
let budgetWarning = ParticleEmitterFrameStats(liveParticleCount: 4,
maxParticleCount: 1_000,
liveParticleLimit: 1_000,
requestedSpawnCount: 8,
spawnBudgetLimit: 4,
spawnBudgetConsumedCount: 4,
spawnBudgetLimitedCount: 4)
let capacityCritical = ParticleEmitterFrameStats(liveParticleCount: 4,
maxParticleCount: 4,
liveParticleLimit: 4,
requestedSpawnCount: 5,
capacityLimitedSpawnCount: 1)
let stats = ParticleFrameStatsResource(
emitterStats: [highRequestInfo, budgetWarning, capacityCritical],
emitterStatsByEntity: [
1: highRequestInfo,
2: budgetWarning,
3: capacityCritical,
]
)

let hotspots = makeDeveloperParticleEmitterHotspots(stats: stats,
eventReport: .empty)

#expect(hotspots.map { $0.entityID } == [3, 2, 1])
#expect(hotspots.map { $0.severity } == [
DeveloperParticleDiagnosticSeverity.critical,
DeveloperParticleDiagnosticSeverity.warning,
DeveloperParticleDiagnosticSeverity.info,
])
}

@Test("frame trend summary reports recent pacing and peak work")
func frameTrendSummaryReportsRecentPacingAndPeakWork() {
let history = [
Expand Down
23 changes: 23 additions & 0 deletions Editor/Tests/EditorCoreTests/EditorSceneAdapterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ struct EditorSceneAdapterTests {
#expect(scene.hasActiveParticles())
}

@Test("Non-looping particles stop driving preview frames after they expire")
func nonLoopingParticlesStopDrivingPreviewFramesAfterExpiry() {
let adapter = EditorSceneAdapter()
adapter.scene = SceneRuntime()
let entity = adapter.scene.createEntity()
_ = adapter.scene.setComponent(
ParticleEmitter(looping: false,
duration: 0.1,
emissionRate: 10,
maxParticles: 8,
lifetime: 0.1,
startVelocity: .zero,
gravity: .zero),
for: entity
)

#expect(adapter.hasActiveParticles())
adapter.tickScene(deltaTime: 0.1)
#expect(adapter.hasActiveParticles())
adapter.tickScene(deltaTime: 0.2)
#expect(!adapter.hasActiveParticles())
}

@Test("Editor scene adapter exposes particle frame stats after ticking")
func editorSceneAdapterExposesParticleFrameStats() {
let adapter = EditorSceneAdapter()
Expand Down
65 changes: 60 additions & 5 deletions Engine/Sources/SceneRuntime/Particles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,15 @@ public struct ParticleEmitter: RuntimeComponent, Sendable, Equatable {
/// Number of currently-alive particles.
public var aliveCount: Int { particles.count }

/// True while this emitter can still produce new particles from its authored
/// emission controls. Non-looping emitters become inactive once `duration`
/// is exhausted, even if `isEmitting` remains enabled for authoring.
public var isEmissionActive: Bool {
guard isEmitting else { return false }
guard duration > 0 else { return true }
return looping || emitterAge < duration
}

public mutating func reseed(_ newSeed: UInt64) {
seed = newSeed
rngState = newSeed
Expand Down Expand Up @@ -1654,7 +1663,7 @@ public struct ParticleEmitter: RuntimeComponent, Sendable, Equatable {
lastFrameStats = frameStats
return
}
let emissionRateMultiplier = max(0, emissionRateCurve.evaluate(at: emissionStep.normalizedAge))
let emissionRateMultiplier = emissionStep.averageMultiplier(for: emissionRateCurve)
let scaledEmissionRateMultiplier = emissionRateMultiplier * options.emissionScale
if emissionRate > 0, scaledEmissionRateMultiplier > 0 {
emissionAccumulator += emissionRate * scaledEmissionRateMultiplier * emissionStep.delta
Expand Down Expand Up @@ -1693,7 +1702,7 @@ public struct ParticleEmitter: RuntimeComponent, Sendable, Equatable {
}
}
}
let distanceRateMultiplier = max(0, distanceEmissionRateCurve.evaluate(at: emissionStep.normalizedAge))
let distanceRateMultiplier = emissionStep.averageMultiplier(for: distanceEmissionRateCurve)
let distanceSpawnResult = spawnDistanceEmission(
from: previousEmitterPosition,
to: currentEmitterPosition,
Expand Down Expand Up @@ -1887,24 +1896,70 @@ public struct ParticleEmitter: RuntimeComponent, Sendable, Equatable {
private struct ActiveEmissionStep {
var delta: Float
var normalizedAge: Float
var startAge: Float
var duration: Float
var looping: Bool

func averageMultiplier(for curve: ParticleCurve) -> Float {
guard duration > 0, delta > 0 else {
return max(0, curve.evaluate(at: normalizedAge))
}
let samples = Self.averageCurveSampleCount(delta: delta, duration: duration)
guard samples > 1 else {
return max(0, curve.evaluate(at: normalizedAge))
}

var total: Float = 0
for index in 0..<samples {
let t = (Float(index) + 0.5) / Float(samples)
var age = startAge + delta * t
if looping {
age = age.truncatingRemainder(dividingBy: duration)
if age < 0 { age += duration }
} else {
age = min(max(0, age), duration)
}
total += max(0, curve.evaluate(at: simd_clamp(age / duration, 0, 1)))
}
return total / Float(samples)
}

private static func averageCurveSampleCount(delta: Float, duration: Float) -> Int {
guard delta > 0, duration > 0 else { return 1 }
let samplesPerCycle: Float = 8
let rawSamples = Int(ceil((delta / duration) * samplesPerCycle))
return min(32, max(1, rawSamples))
}
}

private mutating func activeEmissionStep(_ dt: Float) -> ActiveEmissionStep {
guard duration > 0 else {
return ActiveEmissionStep(delta: dt, normalizedAge: 1)
return ActiveEmissionStep(delta: dt,
normalizedAge: 1,
startAge: 0,
duration: 0,
looping: false)
}
let startAge = emitterAge
if looping {
emitterAge = (emitterAge + dt).truncatingRemainder(dividingBy: duration)
let sampleAge = (startAge + dt * 0.5).truncatingRemainder(dividingBy: duration)
return ActiveEmissionStep(delta: dt, normalizedAge: simd_clamp(sampleAge / duration, 0, 1))
return ActiveEmissionStep(delta: dt,
normalizedAge: simd_clamp(sampleAge / duration, 0, 1),
startAge: startAge,
duration: duration,
looping: true)
}

let remaining = max(0, duration - emitterAge)
let activeDelta = min(dt, remaining)
emitterAge += dt
let sampleAge = startAge + activeDelta * 0.5
return ActiveEmissionStep(delta: activeDelta, normalizedAge: simd_clamp(sampleAge / duration, 0, 1))
return ActiveEmissionStep(delta: activeDelta,
normalizedAge: simd_clamp(sampleAge / duration, 0, 1),
startAge: startAge,
duration: duration,
looping: false)
}

@discardableResult
Expand Down
Loading
Loading