From a96edf51ac09a6848520eca2e4bc73edae3ceea4 Mon Sep 17 00:00:00 2001 From: AlexCat315 <99124972+AlexCat315@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:31:57 +0800 Subject: [PATCH] Add emission curve averaging, render budget tracking, and diagnostics UI Particle Emitter: - Add isEmissionActive property: respects duration for non-looping emitters (isEmitting remains for authoring, isEmissionActive reflects runtime state) - Replace single-point emission curve evaluation with averageMultiplier() - Samples curve across delta step (up to 32 samples, 8 per cycle) - Prevents long frames from skipping curve features (especially looping) - Applies to both continuous emission and distance emission rate curves RenderScene / Schedule: - Add sourceParticleCount, submittedSourceParticleCount, renderBudgetSkippedSourceParticleCount to ParticleRenderSummary - Track source vs submitted particle counts for CPU and GPU batches - Expose render budget skips separately from active instance count EditorStatPanel: - Add Source / Submitted Source / Render Skips to Render Batches group - Add emitter labels (name, kind, path) to hotspot list and detail - Add authoring diagnostic rows to selected emitter section - Add hotspot signal/action/details fields - Widen hotspot name column, move entity ID to end - Add ParticleAuthoringDiagnosticsRows component EditorSceneAdapter: - hasActiveParticles() uses isEmissionActive instead of isEmitting (non-looping emitters stop driving frames after duration expires) Tests: - Add isEmissionActive tests (looping/non-looping) - Add looping emission curve averaging tests (continuous + distance) - Add render budget skip diagnostic tests - Add emitter labels hierarchy path tests - Add authoring diagnostic tests (GPU blocker, fallback, ready) - Add hotspot detail/signal/recommendation tests - Add hotspot severity sort priority test - Add non-looping particle frame-driving test --- .../EditorApp/Panels/EditorStatPanel.swift | 405 +++++++++++++++++- .../EditorCore/Scene/EditorSceneAdapter.swift | 4 +- .../DeveloperParticleDiagnosticsTests.swift | 180 ++++++++ .../EditorSceneAdapterTests.swift | 23 + Engine/Sources/SceneRuntime/Particles.swift | 65 ++- Engine/Sources/SceneRuntime/RenderScene.swift | 35 +- Engine/Sources/SceneRuntime/Schedule.swift | 31 +- .../SceneRuntimeTests/ParticleTests.swift | 87 ++++ .../RenderExtractionTests.swift | 12 + 9 files changed, 806 insertions(+), 36 deletions(-) diff --git a/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift b/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift index 3ec3ad3b..49b8d55b 100644 --- a/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift +++ b/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift @@ -15,12 +15,9 @@ struct DeveloperToolsPanel: View { let timingRevision = store.frameTimingRevision let frameStats = store.state.frameStats let frameStatsHistory = store.frameStatsHistory - let particleDiagnosticsHistory = store.particleDiagnosticsHistory - let renderStats = app.currentRenderStats() - let particleStats = app.currentParticleFrameStats() - let particleEventReport = app.currentParticleSimulationEventApplyReport() - let particleScalability = app.currentParticleScalabilityState() - let particleRenderSummary = app.currentRenderScene().particleSummary + let renderStats: RenderFrameStats = selectedTab == .render + ? app.currentRenderStats() + : .init() TabView(selection: $selectedTab, tabs: [ TabItem(L("Performance"), id: DeveloperToolTab.performance) { @@ -37,13 +34,8 @@ struct DeveloperToolsPanel: View { timingRevision: timingRevision) }, TabItem("Particles", id: DeveloperToolTab.particles) { - ParticleDiagnosticsView(stats: particleStats, - eventReport: particleEventReport, - scalability: particleScalability, - renderSummary: particleRenderSummary, - renderStats: renderStats, - history: particleDiagnosticsHistory, - selectedEntityID: store.selectedEntityID) + ParticleDiagnosticsTabView(app: app, + store: store) }, TabItem(L("Console"), id: DeveloperToolTab.console) { ConsoleDiagnosticsView(store: store) @@ -383,10 +375,20 @@ struct DeveloperParticleDiagnosticSummary: Equatable { var details: [String] } +struct DeveloperParticleEmitterLabel: Equatable { + var entityID: UInt64 + var name: String + var kind: String + var path: String +} + struct DeveloperParticleEmitterHotspot: Equatable { var entityID: UInt64 var severity: DeveloperParticleDiagnosticSeverity var reason: String + var primarySignal: String + var recommendation: String + var details: [String] var score: Int var liveParticleCount: Int var requestedSpawnCount: Int @@ -399,6 +401,209 @@ struct DeveloperParticleEmitterHotspot: Equatable { var spawnBudgetText: String } +func makeDeveloperParticleEmitterLabels(roots: [EditorSceneNode]) -> [UInt64: DeveloperParticleEmitterLabel] { + var labels: [UInt64: DeveloperParticleEmitterLabel] = [:] + + func visit(_ node: EditorSceneNode, parentPath: String?) { + let path = parentPath.map { "\($0) / \(node.name)" } ?? node.name + labels[node.id] = DeveloperParticleEmitterLabel( + entityID: node.id, + name: node.name, + kind: node.kind, + path: path + ) + for child in node.children { + visit(child, parentPath: path) + } + } + + for root in roots { + visit(root, parentPath: nil) + } + return labels +} + +func makeDeveloperParticleAuthoringDiagnosticSummary( + gpuPlan: ParticleGPUSimulationPlan?, + moduleIssues: [ParticleModuleIssue] +) -> DeveloperParticleDiagnosticSummary { + let sortedIssues = moduleIssues.sorted(by: developerParticleModuleIssuePrecedes) + let errorCount = sortedIssues.filter { $0.severity == .error }.count + let warningCount = sortedIssues.filter { $0.severity == .warning }.count + let infoCount = sortedIssues.filter { $0.severity == .info }.count + let issueDetails = developerParticleModuleIssueDetails(sortedIssues) + let gpuDetails = developerParticleGPUPlanDetails(gpuPlan) + + if errorCount > 0 { + return DeveloperParticleDiagnosticSummary( + severity: .critical, + status: "Authoring blocked", + primarySignal: "\(errorCount) module \(errorCount == 1 ? "error" : "errors")", + recommendation: "Fix module errors before profiling runtime pressure; blocked GPU-required emitters cannot execute as authored.", + details: issueDetails + gpuDetails + ) + } + + if let gpuPlan, gpuPlan.status == .requiredButUnsupported { + return DeveloperParticleDiagnosticSummary( + severity: .critical, + status: "GPU simulation blocked", + primarySignal: "Unsupported: \(developerParticleGPUUnsupportedReasonList(gpuPlan.unsupportedReasons))", + recommendation: "Remove unsupported modules or switch the backend to GPU If Supported/CPU before relying on this effect.", + details: gpuDetails + ) + } + + if warningCount > 0 { + return DeveloperParticleDiagnosticSummary( + severity: .warning, + status: "Authoring warnings", + primarySignal: "\(warningCount) module \(warningCount == 1 ? "warning" : "warnings")", + recommendation: "Resolve warnings before chasing frame-time regressions; they often explain CPU fallback or clamped behavior.", + details: issueDetails + gpuDetails + ) + } + + if let gpuPlan { + switch gpuPlan.status { + case .disabled: + return DeveloperParticleDiagnosticSummary( + severity: .nominal, + status: "CPU simulation selected", + primarySignal: "Backend CPU", + recommendation: "Keep CPU for low-volume emitters; move high-volume compatible effects to GPU If Supported.", + details: gpuDetails + ) + case .supported: + return DeveloperParticleDiagnosticSummary( + severity: .nominal, + status: "GPU simulation ready", + primarySignal: "Dispatch \(gpuPlan.dispatchWorkgroups)x\(gpuPlan.workgroupSize) for \(gpuPlan.particleCapacity) capacity", + recommendation: "Track GPU workgroup split, sort padding, and readback drops as effect complexity grows.", + details: gpuDetails + ) + case .fallbackToCPU: + return DeveloperParticleDiagnosticSummary( + severity: .warning, + status: "GPU fallback to CPU", + primarySignal: "Unsupported: \(developerParticleGPUUnsupportedReasonList(gpuPlan.unsupportedReasons))", + recommendation: "Remove unsupported features from this emitter or accept CPU simulation for this effect.", + details: gpuDetails + ) + case .requiredButUnsupported: + return DeveloperParticleDiagnosticSummary( + severity: .critical, + status: "GPU simulation blocked", + primarySignal: "Unsupported: \(developerParticleGPUUnsupportedReasonList(gpuPlan.unsupportedReasons))", + recommendation: "Remove unsupported modules or switch the backend to GPU If Supported/CPU before relying on this effect.", + details: gpuDetails + ) + } + } + + if infoCount > 0 { + return DeveloperParticleDiagnosticSummary( + severity: .info, + status: "Authoring notes", + primarySignal: "\(infoCount) module \(infoCount == 1 ? "note" : "notes")", + recommendation: "Review module notes when tuning the selected particle emitter.", + details: issueDetails + ) + } + + return DeveloperParticleDiagnosticSummary( + severity: .idle, + status: "No selected particle emitter", + primarySignal: "No GPU plan or module issues", + recommendation: "Select a particle emitter to inspect authored backend and module health.", + details: [] + ) +} + +func developerParticleGPUUnsupportedReasonList(_ reasons: [ParticleGPUSimulationUnsupportedReason]) -> String { + guard !reasons.isEmpty else { return "none" } + return reasons.map(developerParticleGPUUnsupportedReasonLabel).joined(separator: ", ") +} + +func developerParticleGPUUnsupportedReasonLabel(_ reason: ParticleGPUSimulationUnsupportedReason) -> String { + switch reason { + case .backendCPU: + return "CPU backend" + case .noParticleCapacity: + return "no capacity" + case .eventSubEmitters: + return "sub-emitters" + case .distanceEmission: + return "distance emission" + case .noise: + return "noise" + case .forceFields: + return "force fields" + case .collisions: + return "collisions" + case .angularVelocity: + return "angular velocity" + } +} + +private func developerParticleGPUPlanDetails(_ plan: ParticleGPUSimulationPlan?) -> [String] { + guard let plan else { return [] } + var details = [ + "GPU plan \(developerParticleGPUPlanStatusLabel(plan.status))", + "Capacity \(plan.particleCapacity), dispatch \(plan.dispatchWorkgroups)x\(plan.workgroupSize)", + ] + if !plan.unsupportedReasons.isEmpty { + details.append("Unsupported \(developerParticleGPUUnsupportedReasonList(plan.unsupportedReasons))") + } + return details +} + +private func developerParticleGPUPlanStatusLabel(_ status: ParticleGPUSimulationPlanStatus) -> String { + switch status { + case .disabled: + return "CPU" + case .supported: + return "Ready" + case .fallbackToCPU: + return "Fallback" + case .requiredButUnsupported: + return "Blocked" + } +} + +private func developerParticleModuleIssueDetails(_ issues: [ParticleModuleIssue], + limit: Int = 4) -> [String] { + guard !issues.isEmpty else { return [] } + let clippedLimit = max(0, limit) + var details = issues.prefix(clippedLimit).map { issue in + "\(issue.moduleID) [\(issue.severity.rawValue)]: \(issue.message)" + } + if issues.count > clippedLimit { + details.append("\(issues.count - clippedLimit) more module issues") + } + return details +} + +private func developerParticleModuleIssuePrecedes(_ lhs: ParticleModuleIssue, + _ rhs: ParticleModuleIssue) -> Bool { + let lhsRank = developerParticleModuleIssueSeverityRank(lhs.severity) + let rhsRank = developerParticleModuleIssueSeverityRank(rhs.severity) + if lhsRank != rhsRank { return lhsRank > rhsRank } + if lhs.moduleID != rhs.moduleID { return lhs.moduleID < rhs.moduleID } + return lhs.code < rhs.code +} + +private func developerParticleModuleIssueSeverityRank(_ severity: ParticleModuleIssueSeverity) -> Int { + switch severity { + case .error: + return 3 + case .warning: + return 2 + case .info: + return 1 + } +} + struct DeveloperParticleTrendSummary: Equatable { var sampleCount: Int var firstSampleIndex: UInt64 @@ -516,6 +721,9 @@ func makeDeveloperParticleEmitterHotspots(stats: ParticleFrameStatsResource, } return hotspots .sorted { + let lhsSeverity = particleDiagnosticSeverityRank($0.severity) + let rhsSeverity = particleDiagnosticSeverityRank($1.severity) + if lhsSeverity != rhsSeverity { return lhsSeverity > rhsSeverity } if $0.score != $1.score { return $0.score > $1.score } return $0.entityID < $1.entityID } @@ -523,6 +731,16 @@ func makeDeveloperParticleEmitterHotspots(stats: ParticleFrameStatsResource, .map { $0 } } +private func particleDiagnosticSeverityRank(_ severity: DeveloperParticleDiagnosticSeverity) -> Int { + switch severity { + case .critical: 4 + case .warning: 3 + case .info: 2 + case .nominal: 1 + case .idle: 0 + } +} + func makeDeveloperParticleEmitterHotspot(entityID: UInt64, frameStats: ParticleEmitterFrameStats?, eventStats: ParticleEmitterFrameStats? = nil) -> DeveloperParticleEmitterHotspot { @@ -531,6 +749,9 @@ func makeDeveloperParticleEmitterHotspot(entityID: UInt64, entityID: entityID, severity: .idle, reason: "Idle", + primarySignal: "No particle frame or event stats", + recommendation: "Select an active particle emitter or play the scene to collect runtime diagnostics.", + details: [], score: 0, liveParticleCount: 0, requestedSpawnCount: 0, @@ -553,6 +774,11 @@ func makeDeveloperParticleEmitterHotspot(entityID: UInt64, let liveCount = frameStats?.liveParticleCount ?? baseStats.liveParticleCount let livePressure = frameStats?.liveParticleBudgetUtilization ?? baseStats.liveParticleBudgetUtilization let livePressureScore = Int((livePressure * 10_000).rounded()) + let liveBudgetText = formatBudget(liveCount, baseStats.liveParticleBudgetLimit) + let spawnBudgetText = formatBudget( + (frameStats?.spawnBudgetConsumedCount ?? 0) + (eventStats?.spawnBudgetConsumedCount ?? 0), + baseStats.spawnBudgetLimit + ) let score = totalDrops * 1_000_000 + livePressureScore + requested * 100 @@ -560,30 +786,72 @@ func makeDeveloperParticleEmitterHotspot(entityID: UInt64, let severity: DeveloperParticleDiagnosticSeverity let reason: String + let primarySignal: String + let recommendation: String + let details: [String] if capacityDrops > 0 { severity = .critical reason = "Capacity drops" + primarySignal = "\(capacityDrops) capacity-limited spawns" + recommendation = "Raise this emitter's max particles/effective budget or lower lifetime and high-rate spawn sources." + details = [ + "Live \(liveBudgetText)", + "Frame drops \(frameDrops), event drops \(eventDrops)", + ] } else if budgetDrops > 0 { severity = .warning reason = "Spawn budget drops" + primarySignal = "\(budgetDrops) spawn-budget drops" + recommendation = "Raise Max Spawn / Frame for this emitter or reduce burst, distance, and sub-emitter rates." + details = [ + "Spawn budget \(spawnBudgetText)", + "Requested \(requested), accepted \(spawned)", + ] } else if livePressure >= 0.9 { severity = .warning reason = "Live budget" + primarySignal = "\(formatPercent(liveCount, baseStats.liveParticleBudgetLimit)) live budget used" + recommendation = "Reduce lifetime or spawn rate before this emitter starts dropping new particles." + details = [ + "Live \(liveBudgetText)", + "Requested \(requested), accepted \(spawned)", + ] } else if requested > 0 { severity = .info reason = "High spawn requests" + primarySignal = "\(requested) spawn requests" + recommendation = "Audit emission curves, bursts, and distance emission if this emitter becomes a frame hotspot." + details = [ + "Accepted \(spawned)", + "Live \(liveBudgetText)", + ] } else if liveCount > 0 { severity = .nominal reason = "Live particles" + primarySignal = "\(liveCount) live particles" + recommendation = "Emitter is active without spawn pressure in the latest frame." + details = [ + "Live \(liveBudgetText)", + "Spawn budget \(spawnBudgetText)", + ] } else { severity = .idle reason = "Idle" + primarySignal = "No live particles or spawn requests" + recommendation = "Emitter has no particle workload in the latest frame." + details = [ + "Live \(liveBudgetText)", + "Spawn budget \(spawnBudgetText)", + ] } return DeveloperParticleEmitterHotspot( entityID: entityID, severity: severity, reason: reason, + primarySignal: primarySignal, + recommendation: recommendation, + details: details, score: score, liveParticleCount: liveCount, requestedSpawnCount: requested, @@ -592,9 +860,8 @@ func makeDeveloperParticleEmitterHotspot(entityID: UInt64, capacityLimitedSpawnCount: capacityDrops, spawnBudgetLimitedCount: budgetDrops, eventDroppedSpawnCount: eventDrops, - liveBudgetText: formatBudget(liveCount, baseStats.liveParticleBudgetLimit), - spawnBudgetText: formatBudget((frameStats?.spawnBudgetConsumedCount ?? 0) + (eventStats?.spawnBudgetConsumedCount ?? 0), - baseStats.spawnBudgetLimit) + liveBudgetText: liveBudgetText, + spawnBudgetText: spawnBudgetText ) } @@ -678,6 +945,19 @@ func makeDeveloperParticleDiagnosticSummary(stats: ParticleFrameStatsResource, ) } + if renderSummary.renderBudgetSkippedSourceParticleCount > 0 { + return DeveloperParticleDiagnosticSummary( + severity: .info, + status: "Particle render budget limiting", + primarySignal: "\(renderSummary.renderBudgetSkippedSourceParticleCount) source particles skipped before render", + recommendation: "Tune Max Rendered Particles and render LOD so simulation cost and visual density stay balanced.", + details: [ + "Source \(renderSummary.sourceParticleCount), submitted \(renderSummary.submittedSourceParticleCount)", + "Render instances \(renderSummary.particleCount)", + ] + ) + } + let averageBatchSize = particleAverageBatchSize(renderSummary) if renderSummary.batchCount >= 8 && averageBatchSize < 4 { return DeveloperParticleDiagnosticSummary( @@ -730,6 +1010,26 @@ func makeDeveloperParticleDiagnosticSummary(stats: ParticleFrameStatsResource, ) } +private struct ParticleDiagnosticsTabView: View { + let app: EditorApplication + let store: EditorStore + + var body: some View { + ParticleDiagnosticsView( + stats: app.currentParticleFrameStats(), + eventReport: app.currentParticleSimulationEventApplyReport(), + scalability: app.currentParticleScalabilityState(), + renderSummary: app.currentRenderScene().particleSummary, + renderStats: app.currentRenderStats(), + history: store.particleDiagnosticsHistory, + selectedEntityID: store.selectedEntityID, + emitterLabels: makeDeveloperParticleEmitterLabels(roots: app.scene.roots), + selectedGPUSimulationPlan: app.scene.currentParticleGPUSimulationPlan(for: store.selectedEntityID), + selectedModuleValidationIssues: app.scene.currentParticleModuleValidationIssues(for: store.selectedEntityID) + ) + } +} + private struct ParticleDiagnosticsView: View { let stats: ParticleFrameStatsResource let eventReport: ParticleSimulationEventApplyReport @@ -738,6 +1038,9 @@ private struct ParticleDiagnosticsView: View { let renderStats: RenderFrameStats let history: [EditorParticleDiagnosticsSample] let selectedEntityID: UInt64? + let emitterLabels: [UInt64: DeveloperParticleEmitterLabel] + let selectedGPUSimulationPlan: ParticleGPUSimulationPlan? + let selectedModuleValidationIssues: [ParticleModuleIssue] var body: some View { let summary = makeDeveloperParticleDiagnosticSummary(stats: stats, @@ -746,6 +1049,10 @@ private struct ParticleDiagnosticsView: View { renderSummary: renderSummary, renderStats: renderStats) let trend = makeDeveloperParticleTrendSummary(history: history) + let authoringSummary = makeDeveloperParticleAuthoringDiagnosticSummary( + gpuPlan: selectedGPUSimulationPlan, + moduleIssues: selectedModuleValidationIssues + ) let hotspots = makeDeveloperParticleEmitterHotspots(stats: stats, eventReport: eventReport) let selectedHotspot = selectedEntityID.flatMap { entityID -> DeveloperParticleEmitterHotspot? in @@ -776,7 +1083,9 @@ private struct ParticleDiagnosticsView: View { ParticleEmitterHotspotsView(hotspots: hotspots, selectedHotspot: selectedHotspot, - selectedEntityID: selectedEntityID) + selectedEntityID: selectedEntityID, + emitterLabels: emitterLabels, + authoringSummary: authoringSummary) .padding(horizontal: 12, vertical: 10) Divider() @@ -861,6 +1170,9 @@ private struct ParticleDiagnosticsView: View { StatGroup(title: "Render Batches") { StatRow(label: "Submitted", value: "\(renderSummary.particleCount)") + StatRow(label: "Source", value: "\(renderSummary.sourceParticleCount)") + StatRow(label: "Submitted Source", value: "\(renderSummary.submittedSourceParticleCount)") + StatRow(label: "Render Skips", value: "\(renderSummary.renderBudgetSkippedSourceParticleCount)") StatRow(label: "CPU Submitted", value: "\(renderSummary.cpuRenderInstanceCount)") StatRow(label: "GPU Submitted", value: "\(renderSummary.gpuRenderInstanceCount)") StatRow(label: "Batches", value: "\(renderSummary.batchCount)") @@ -1030,20 +1342,32 @@ private struct ParticleEmitterHotspotsView: View { let hotspots: [DeveloperParticleEmitterHotspot] let selectedHotspot: DeveloperParticleEmitterHotspot? let selectedEntityID: UInt64? + let emitterLabels: [UInt64: DeveloperParticleEmitterLabel] + let authoringSummary: DeveloperParticleDiagnosticSummary var body: some View { Row(alignment: .top, spacing: 12) { StatGroup(title: "Selected Emitter") { if let selectedHotspot { - ParticleEmitterHotspotDetail(hotspot: selectedHotspot) + ParticleEmitterHotspotDetail(hotspot: selectedHotspot, + label: emitterLabels[selectedHotspot.entityID]) } else if let selectedEntityID { - StatRow(label: "Entity", value: "\(selectedEntityID)") + if let label = emitterLabels[selectedEntityID] { + StatRow(label: "Entity", value: label.name) + StatRow(label: "ID", value: "#\(selectedEntityID)") + StatWrappedValue(label: "Path", value: label.path) + } else { + StatRow(label: "Entity", value: "#\(selectedEntityID)") + } StatWrappedValue(label: "Status", value: "Selected entity has no particle runtime stats in the latest frame.") } else { StatWrappedValue(label: "Status", value: "Select a particle emitter to inspect per-emitter runtime pressure.") } + if selectedEntityID != nil { + ParticleAuthoringDiagnosticRows(summary: authoringSummary) + } } .flex(1, shrink: 1) @@ -1054,6 +1378,7 @@ private struct ParticleEmitterHotspotsView: View { } else { for hotspot in hotspots { ParticleEmitterHotspotRow(hotspot: hotspot, + label: emitterLabels[hotspot.entityID], isSelected: hotspot.entityID == selectedEntityID) } } @@ -1063,12 +1388,39 @@ private struct ParticleEmitterHotspotsView: View { } } +private struct ParticleAuthoringDiagnosticRows: View { + let summary: DeveloperParticleDiagnosticSummary + + var body: some View { + StatRow(label: "Authoring", value: summary.status) + ParticleSeverityRow(severity: summary.severity) + StatWrappedValue(label: "Author Signal", value: summary.primarySignal) + StatWrappedValue(label: "Author Action", value: summary.recommendation) + if !summary.details.isEmpty { + StatWrappedValue(label: "Author Details", value: summary.details.joined(separator: " | ")) + } + } +} + private struct ParticleEmitterHotspotDetail: View { let hotspot: DeveloperParticleEmitterHotspot + let label: DeveloperParticleEmitterLabel? var body: some View { - StatRow(label: "Entity", value: "\(hotspot.entityID)") + if let label { + StatRow(label: "Entity", value: label.name) + StatRow(label: "Kind", value: label.kind) + StatRow(label: "ID", value: "#\(hotspot.entityID)") + StatWrappedValue(label: "Path", value: label.path) + } else { + StatRow(label: "Entity", value: "#\(hotspot.entityID)") + } StatRow(label: "Reason", value: hotspot.reason) + StatWrappedValue(label: "Signal", value: hotspot.primarySignal) + StatWrappedValue(label: "Action", value: hotspot.recommendation) + if !hotspot.details.isEmpty { + StatWrappedValue(label: "Details", value: hotspot.details.joined(separator: " | ")) + } StatRow(label: "Live", value: hotspot.liveBudgetText) StatRow(label: "Requests", value: "\(hotspot.requestedSpawnCount)") StatRow(label: "Accepted", value: "\(hotspot.spawnedParticleCount)") @@ -1081,6 +1433,7 @@ private struct ParticleEmitterHotspotDetail: View { private struct ParticleEmitterHotspotRow: View { let hotspot: DeveloperParticleEmitterHotspot + let label: DeveloperParticleEmitterLabel? let isSelected: Bool var body: some View { @@ -1091,11 +1444,11 @@ private struct ParticleEmitterHotspotRow: View { .foregroundColor(.accent) .frame(width: 8) - Text("#\(hotspot.entityID)") + Text(label?.name ?? "#\(hotspot.entityID)") .lineLimit(1) .font(.mono) .foregroundColor(.onSurface) - .frame(width: 64) + .frame(width: 112) Text(hotspot.severity.rawValue) .lineLimit(1) @@ -1111,7 +1464,13 @@ private struct ParticleEmitterHotspotRow: View { .foregroundColor(.onSurface) .flex(1, shrink: 1) - Text("live \(hotspot.liveParticleCount) req \(hotspot.requestedSpawnCount) drop \(hotspot.droppedSpawnCount)") + Text(hotspot.primarySignal) + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + .flex(1, shrink: 1) + + Text("#\(hotspot.entityID)") .lineLimit(1) .font(.caption) .foregroundColor(.onSurfaceMuted) diff --git a/Editor/Sources/EditorCore/Scene/EditorSceneAdapter.swift b/Editor/Sources/EditorCore/Scene/EditorSceneAdapter.swift index 934912f3..d0e63d56 100644 --- a/Editor/Sources/EditorCore/Scene/EditorSceneAdapter.swift +++ b/Editor/Sources/EditorCore/Scene/EditorSceneAdapter.swift @@ -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 } diff --git a/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift b/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift index 09267a9e..924e153d 100644 --- a/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift +++ b/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift @@ -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, @@ -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") @@ -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 = [ diff --git a/Editor/Tests/EditorCoreTests/EditorSceneAdapterTests.swift b/Editor/Tests/EditorCoreTests/EditorSceneAdapterTests.swift index 6d4f4b05..5b4a36d4 100644 --- a/Editor/Tests/EditorCoreTests/EditorSceneAdapterTests.swift +++ b/Editor/Tests/EditorCoreTests/EditorSceneAdapterTests.swift @@ -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() diff --git a/Engine/Sources/SceneRuntime/Particles.swift b/Engine/Sources/SceneRuntime/Particles.swift index 3f3f0983..a921a622 100644 --- a/Engine/Sources/SceneRuntime/Particles.swift +++ b/Engine/Sources/SceneRuntime/Particles.swift @@ -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 @@ -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 @@ -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, @@ -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.. 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 diff --git a/Engine/Sources/SceneRuntime/RenderScene.swift b/Engine/Sources/SceneRuntime/RenderScene.swift index 1493585e..9657bf03 100644 --- a/Engine/Sources/SceneRuntime/RenderScene.swift +++ b/Engine/Sources/SceneRuntime/RenderScene.swift @@ -295,6 +295,9 @@ public struct ParticleRenderBatchPlan: Sendable, Equatable { } public struct ParticleRenderSummary: Sendable, Equatable { + public var sourceParticleCount: Int + public var submittedSourceParticleCount: Int + public var renderBudgetSkippedSourceParticleCount: Int public var particleCount: Int public var cpuRenderInstanceCount: Int public var gpuRenderInstanceCount: Int @@ -308,6 +311,8 @@ public struct ParticleRenderSummary: Sendable, Equatable { public var bounds: RenderBounds public init(particleCount: Int = 0, + sourceParticleCount: Int? = nil, + submittedSourceParticleCount: Int? = nil, cpuRenderInstanceCount: Int? = nil, gpuRenderInstanceCount: Int = 0, alphaCount: Int = 0, @@ -322,6 +327,20 @@ public struct ParticleRenderSummary: Sendable, Equatable { let safeGPURenderInstanceCount = max(0, gpuRenderInstanceCount) let safeBatchCount = max(0, batchCount) let safeGPUBatchCount = max(0, gpuBatchCount) + let safeSubmittedSourceParticleCount = max( + 0, + submittedSourceParticleCount ?? safeParticleCount + ) + let safeSourceParticleCount = max( + safeSubmittedSourceParticleCount, + sourceParticleCount ?? safeSubmittedSourceParticleCount + ) + self.sourceParticleCount = safeSourceParticleCount + self.submittedSourceParticleCount = safeSubmittedSourceParticleCount + self.renderBudgetSkippedSourceParticleCount = max( + 0, + safeSourceParticleCount - safeSubmittedSourceParticleCount + ) self.particleCount = safeParticleCount self.cpuRenderInstanceCount = max( 0, @@ -343,13 +362,20 @@ public struct ParticleRenderSummary: Sendable, Equatable { } public init(particles: [RenderParticle], - simulationBatches: [RenderParticleSimulationBatch]) { + simulationBatches: [RenderParticleSimulationBatch], + cpuSourceParticleCount: Int? = nil, + cpuSubmittedSourceParticleCount: Int? = nil) { var alphaCount = 0 var additiveCount = 0 var texturedCount = 0 var bounds = RenderBounds() var uniqueTextures = Set() let cpuRenderInstanceCount = particles.count + var sourceParticleCount = max(0, cpuSourceParticleCount ?? particles.count) + var submittedSourceParticleCount = max( + 0, + cpuSubmittedSourceParticleCount ?? particles.count + ) var cpuBatchCount = 0 var currentCPUKey: ParticleRenderBatchKey? var gpuRenderInstanceCount = 0 @@ -376,6 +402,11 @@ public struct ParticleRenderSummary: Sendable, Equatable { } for batch in simulationBatches where batch.renderOnGPU { + let sourceCount = batch.particleCount + if sourceCount > 0 { + sourceParticleCount += sourceCount + submittedSourceParticleCount += batch.renderParticleCount + } let renderInstanceCount = batch.renderInstanceCount guard renderInstanceCount > 0 else { continue } gpuRenderInstanceCount += renderInstanceCount @@ -394,6 +425,8 @@ public struct ParticleRenderSummary: Sendable, Equatable { } self.init(particleCount: cpuRenderInstanceCount + gpuRenderInstanceCount, + sourceParticleCount: sourceParticleCount, + submittedSourceParticleCount: submittedSourceParticleCount, cpuRenderInstanceCount: cpuRenderInstanceCount, gpuRenderInstanceCount: gpuRenderInstanceCount, alphaCount: alphaCount, diff --git a/Engine/Sources/SceneRuntime/Schedule.swift b/Engine/Sources/SceneRuntime/Schedule.swift index f84a0aed..1b5929c5 100644 --- a/Engine/Sources/SceneRuntime/Schedule.swift +++ b/Engine/Sources/SceneRuntime/Schedule.swift @@ -564,17 +564,24 @@ public struct RuntimeWorldSchedule { let lightCollection = collectRenderLights(from: view) let instances = instanceCollection.instances let lights = lightCollection.lights - let particles = collectRenderParticles(in: world, camera: cameraSelection.camera) + let particleCollection = collectRenderParticles(in: world, camera: cameraSelection.camera) let particleSimulationBatches = collectParticleSimulationBatches(in: world, camera: cameraSelection.camera) + let particleSummary = ParticleRenderSummary( + particles: particleCollection.particles, + simulationBatches: particleSimulationBatches, + cpuSourceParticleCount: particleCollection.sourceParticleCount, + cpuSubmittedSourceParticleCount: particleCollection.submittedSourceParticleCount + ) return ( ExtractedRenderSceneResource( scene: RenderScene( camera: cameraSelection.camera, instances: instances.map(\.instance), lights: lights.map(\.light), - particles: particles, - particleSimulationBatches: particleSimulationBatches + particles: particleCollection.particles, + particleSimulationBatches: particleSimulationBatches, + particleSummary: particleSummary ), activeCameraEntity: cameraSelection.entity, instanceEntities: instances.map(\.entity), @@ -696,12 +703,20 @@ public struct RuntimeWorldSchedule { /// by the entity's world matrix; world-space particles are already stored in /// render space. The result is sorted back-to-front for the camera so alpha /// blending composites correctly. + private struct CollectedRenderParticles { + var particles: [RenderParticle] + var sourceParticleCount: Int + var submittedSourceParticleCount: Int + } + private func collectRenderParticles( in world: RuntimeWorld, camera: RenderCamera - ) -> [RenderParticle] { + ) -> CollectedRenderParticles { var sortableParticles: [SortableRenderParticle] = [] var nextSourceOrder = 0 + var sourceParticleCount = 0 + var submittedSourceParticleCount = 0 for entity in world.entities(with: ParticleEmitter.self) { guard let emitter = world.component(ParticleEmitter.self, for: entity), !emitter.particles.isEmpty @@ -726,6 +741,8 @@ public struct RuntimeWorldSchedule { cameraEye: camera.eye) let sourceParticles = renderSourceParticles(for: emitter, cameraDistance: cameraDistance) + sourceParticleCount += emitter.particles.count + submittedSourceParticleCount += sourceParticles.count if emitter.renderMode == .ribbon { appendRibbonParticles(sourceParticles, emitter: emitter, @@ -773,7 +790,11 @@ public struct RuntimeWorldSchedule { } } sortableParticles.sort(by: compareSortableParticles) - return sortableParticles.map(\.particle) + return CollectedRenderParticles( + particles: sortableParticles.map(\.particle), + sourceParticleCount: sourceParticleCount, + submittedSourceParticleCount: submittedSourceParticleCount + ) } private func renderSourceParticles( diff --git a/Engine/Tests/SceneRuntimeTests/ParticleTests.swift b/Engine/Tests/SceneRuntimeTests/ParticleTests.swift index 24dd526f..8c8be158 100644 --- a/Engine/Tests/SceneRuntimeTests/ParticleTests.swift +++ b/Engine/Tests/SceneRuntimeTests/ParticleTests.swift @@ -771,6 +771,42 @@ struct ParticleTests { #expect(emitter.aliveCount == 1) } + @Test("non-looping emitter reports inactive once duration is exhausted") + func nonLoopingEmitterReportsInactiveAfterDuration() { + var emitter = ParticleEmitter(looping: false, + duration: 0.25, + emissionRate: 4, + maxParticles: 8, + lifetime: 0.1, + startVelocity: .zero, + gravity: .zero) + + #expect(emitter.isEmissionActive) + emitter.advance(deltaTime: 0.25) + #expect(!emitter.isEmissionActive) + #expect(emitter.aliveCount == 1) + + emitter.advance(deltaTime: 0.2) + #expect(!emitter.isEmissionActive) + #expect(emitter.aliveCount == 0) + + emitter.clear() + #expect(emitter.isEmissionActive) + } + + @Test("looping emitter remains active after duration wraps") + func loopingEmitterRemainsActiveAfterDurationWraps() { + var emitter = ParticleEmitter(looping: true, + duration: 0.25, + emissionRate: 0, + startVelocity: .zero, + gravity: .zero) + + emitter.advance(deltaTime: 2) + + #expect(emitter.isEmissionActive) + } + @Test("particles are culled once they exceed their lifetime") func lifetimeCulling() { var emitter = ParticleEmitter(emissionRate: 0, lifetime: 0.5, gravity: .zero) @@ -1605,6 +1641,29 @@ struct ParticleTests { #expect(emitter.aliveCount == 20) } + @Test("looping emission curves are averaged across long frames") + func loopingEmissionCurvesAreAveragedAcrossLongFrames() { + var emitter = ParticleEmitter( + looping: true, + duration: 1, + emissionRate: 10, + emissionRateCurve: .keyframes([ + ParticleCurveKeyframe(time: 0, value: 0), + ParticleCurveKeyframe(time: 1, value: 2), + ]), + maxParticles: 100, + lifetime: 10, + startVelocity: .zero, + gravity: .zero + ) + + emitter.advance(deltaTime: 2) + + #expect(emitter.aliveCount == 20) + #expect(emitter.lastFrameStats.requestedSpawnCount == 20) + #expect(emitter.lastFrameStats.continuousSpawnedCount == 20) + } + @Test("distance emission rate curve modulates movement-based spawn rate") func distanceEmissionRateCurveModulatesDistanceEmission() { var emitter = ParticleEmitter( @@ -1633,6 +1692,34 @@ struct ParticleTests { #expect(emitter.aliveCount == 20) } + @Test("looping distance emission curves are averaged across long frames") + func loopingDistanceEmissionCurvesAreAveragedAcrossLongFrames() { + var emitter = ParticleEmitter( + looping: true, + duration: 1, + emissionRate: 0, + distanceEmissionRate: 10, + distanceEmissionRateCurve: .keyframes([ + ParticleCurveKeyframe(time: 0, value: 0), + ParticleCurveKeyframe(time: 1, value: 2), + ]), + maxParticles: 100, + lifetime: 10, + startVelocity: .zero, + gravity: .zero, + simulationSpace: .world + ) + var transform = matrix_identity_float4x4 + + emitter.advance(deltaTime: 0.01, worldTransform: transform) + transform.columns.3.x = 2 + emitter.advance(deltaTime: 2, worldTransform: transform) + + #expect(emitter.aliveCount == 20) + #expect(emitter.lastFrameStats.requestedSpawnCount == 20) + #expect(emitter.lastFrameStats.distanceSpawnedCount == 20) + } + @Test("prewarm simulates once before the first active tick") func prewarmSimulatesBeforeFirstTick() { var emitter = ParticleEmitter( diff --git a/Engine/Tests/SceneRuntimeTests/RenderExtractionTests.swift b/Engine/Tests/SceneRuntimeTests/RenderExtractionTests.swift index 0046fae9..a0098a49 100644 --- a/Engine/Tests/SceneRuntimeTests/RenderExtractionTests.swift +++ b/Engine/Tests/SceneRuntimeTests/RenderExtractionTests.swift @@ -35,6 +35,9 @@ struct RenderExtractionTests { #expect(summary.cpuBatchCount == 3) #expect(summary.gpuBatchCount == 0) #expect(summary.particleCount == 3) + #expect(summary.sourceParticleCount == 3) + #expect(summary.submittedSourceParticleCount == 3) + #expect(summary.renderBudgetSkippedSourceParticleCount == 0) #expect(summary.cpuRenderInstanceCount == 3) #expect(summary.gpuRenderInstanceCount == 0) #expect(summary.uniqueTextureCount == 2) @@ -682,6 +685,9 @@ struct RenderExtractionTests { #expect(extracted.scene.particles.count == 2) #expect(extracted.scene.particleSummary.particleCount == 2) + #expect(extracted.scene.particleSummary.sourceParticleCount == 5) + #expect(extracted.scene.particleSummary.submittedSourceParticleCount == 2) + #expect(extracted.scene.particleSummary.renderBudgetSkippedSourceParticleCount == 3) #expect(simulated.aliveCount == 5) } @@ -714,6 +720,9 @@ struct RenderExtractionTests { #expect(extracted.scene.particles.count == 5) #expect(extracted.scene.particleSummary.particleCount == 5) + #expect(extracted.scene.particleSummary.sourceParticleCount == 8) + #expect(extracted.scene.particleSummary.submittedSourceParticleCount == 5) + #expect(extracted.scene.particleSummary.renderBudgetSkippedSourceParticleCount == 3) #expect(simulated.aliveCount == 8) } @@ -1236,6 +1245,9 @@ struct RenderExtractionTests { #expect(batch.renderParticleStartIndex == 3) #expect(batch.renderInstanceCount == 3) #expect(scene.particleSummary.particleCount == 3) + #expect(scene.particleSummary.sourceParticleCount == 6) + #expect(scene.particleSummary.submittedSourceParticleCount == 3) + #expect(scene.particleSummary.renderBudgetSkippedSourceParticleCount == 3) #expect(scene.particleSummary.cpuRenderInstanceCount == 0) #expect(scene.particleSummary.gpuRenderInstanceCount == 3) #expect(scene.particleSummary.alphaCount == 3)