-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathEnhancedSceneRenderer.cpp
More file actions
5950 lines (5485 loc) · 276 KB
/
Copy pathEnhancedSceneRenderer.cpp
File metadata and controls
5950 lines (5485 loc) · 276 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "EnhancedSceneRenderer.h"
#include "EnhancedSceneRendererLiveDX12Adapter.h"
#include "EnhancedPbrCapture.h"
#include "../Graph/EnhancedRenderGraph.h"
#include "../Graph/EnhancedRenderPass.h"
#include "../Core/EnhancedLivePipelineDesc.h"
#include "../Passes/Geometry/EnhancedGBufferPass.h"
#include "../Passes/Geometry/EnhancedShadowPass.h"
#include "../Passes/Geometry/EnhancedDeferredPass.h"
#include "../Passes/Lighting/EnhancedSSGIPass.h"
#include "../Passes/Geometry/EnhancedForwardPass.h"
#include "../Passes/Geometry/EnhancedSpritePass.h"
#include "../Passes/Lighting/EnhancedSSAOPass.h"
#include "../Passes/Lighting/EnhancedSSRPass.h"
#include "../Passes/Lighting/EnhancedSSSPass.h"
#include "../Passes/Geometry/EnhancedDecalPass.h"
#include "../Passes/Lighting/EnhancedVolumetricFogPass.h"
#include "../Passes/Lighting/EnhancedSkyBoxPass.h"
#include "../../RHI/DX12/EnhancedIBLGenerator.h"
#include "../Passes/PostProcess/EnhancedPostChainPass.h"
#include "../Passes/UI/EnhancedUIPass.h"
#include "../Core/RenderFeatureContributor.h"
#include "../../RHI/IDisplayPresentationSink.h"
#include "../../RHI/Vulkan/VulkanDeviceResources.h"
#include "../../RHI/Vulkan/VulkanCommandBufferPool.h"
#include "../../RHI/Vulkan/VulkanPipelineCache.h"
#include "../../RHI/RHIShaderCompiler.h"
#include "../../RHI/RHIShaderSource.h"
#include "../../RHI/Vulkan/VulkanLoader.h"
#include "../../RHI/ScreenSizedResource.h"
#include "../../RHI/RHISubmissionThread.h"
#include "../../EnhancedGizmoSceneBinding.h"
#include "ExperimentMaterialSealing.h"
#include "../../DataSystem.h"
#include "../../ShaderMeta.h"
#include "../../StandardMaterialProperty.h"
#include "../../Camera.h"
#include "../../Material.h"
#include "../../RenderScene.h"
#include "../Core/EnhancedLightPacking.h"
#include "../../Texture.h"
#include "../../PrimitiveRenderProxy.h"
#include "../../UIRenderProxy.h"
#include "../../UIClipping.h"
#include "../../BoneRegion.h" // MAX_BONES
#include "../../Mesh.h"
#include "../../RenderState.h"
#include "../../../Utility_Framework/PathFinder.h"
// ★ <d3d11_1.h> include가 여기 있었다 (E, 2026-08-09).
// 공유 텍스처를 DX11에서 열어 SRV를 만들던 자리를 D4에서 걷은 뒤로 이
// 파일에 DX11 타입이 하나도 남지 않았다.
#include <algorithm>
#include <cstdlib>
#include <cstdio>
#include <cmath>
#include <vector>
#include <unordered_set>
#include <mutex>
#include <array>
#include <cassert>
#include <thread>
#include <deque>
#include <condition_variable>
#include <chrono>
#include <string_view>
#include <unordered_map>
// EnhancedSceneRenderer의 단독 메인 런타임 구현.
//
// 공개 표면은 EnhancedSceneRenderer의 정적 Live API다(헤더의 규약 주석 참조).
// 상태를 이 파일 안의 싱글턴에 숨긴 이유: EnhancedSceneRenderer 인스턴스는
// 콘솔 명령마다 스택에 만들어지는 검증용이라 상시 상태를 들 수 없고, 그렇다고
// 별도 공개 클래스를 두면 "DX12 렌더러 = EnhancedSceneRenderer"라는 로드맵의
// 명칭 체계가 흐려진다(실제로 그렇게 만들었다가 물렸다).
namespace
{
// I6-C — 신원 키 정본. experiment 핸들의 stableKey가 우선이고, 없으면
// legacy Mesh 신원(m_hashingMesh)이다. 두 키는 D4b가 적은 대로 같은
// 64비트 공간을 쓰므로 섞여도 충돌 가정이 같다.
//
// 포인터로 정렬하던 것을 값으로 바꾼다 — 주소는 할당 순서에 따라 달라지고,
// 무엇보다 렌더가 게임 객체 주소를 신원으로 쓰는 것 자체가 I6이 지우려는
// 결합이다.
[[nodiscard]] std::size_t MakeGeometryKey(const EnhancedDrawItem& item)
{
// MBC7 — typed 뷰가 첫 축(EnhancedDrawIdentity::GeometryKey와 같은 순서).
if (item.modelMeshView.handle.IsValid())
{
return HashModelMeshHandle(item.modelMeshView.handle);
}
return nullptr != item.mesh
? static_cast<std::size_t>(item.mesh->m_hashingMesh.m_ID_Data)
: std::size_t{ 0 };
}
// 유니티 빌드에서 익명 네임스페이스가 파일 간 합쳐지므로 이름을 고유하게 둔다.
// 블랙보드 슬롯 이름(LiveSlots)은 EnhancedLivePipelineDesc.h로 갔다 —
// 파이프라인에 노드를 기여하는 Host도 같은 계약으로 잇기 때문이다(E4-2).
/// 블랙보드에 흩어져 있는 여섯 슬롯을 GBuffer 출력 구조로 다시 묶는다.
///
/// 데칼과 Deferred가 Outputs를 통째로 받기 때문에 필요하다. 슬롯을 낱개로
/// 두는 이유는 그것이 실제 의존 관계이기 때문이고(데칼은 셋만 수정한다),
/// 묶는 비용은 핸들 여섯 개 복사뿐이다.
EnhancedGBufferPass::Outputs GatherGBufferOutputs(const LiveBlackboard& blackboard)
{
EnhancedGBufferPass::Outputs outputs{};
outputs.diffuse = blackboard.Get(LiveSlots::kGBufferDiffuse);
outputs.metalRough = blackboard.Get(LiveSlots::kGBufferMetalRough);
outputs.normal = blackboard.Get(LiveSlots::kGBufferNormal);
outputs.emissive = blackboard.Get(LiveSlots::kGBufferEmissive);
outputs.bitmask = blackboard.Get(LiveSlots::kGBufferBitmask);
outputs.depth = blackboard.Get(LiveSlots::kGBufferDepth);
return outputs;
}
std::string ReadLivePostEnvironment(const char* name)
{
const DWORD length = GetEnvironmentVariableA(name, nullptr, 0);
if (0 == length) return {};
std::string value(length, '\0');
GetEnvironmentVariableA(name, value.data(), length);
value.resize(length - 1);
return value;
}
bool ReadLivePostFlag(const char* name, bool fallback)
{
const std::string value = ReadLivePostEnvironment(name);
if (value.empty()) return fallback;
if (value == "1" || value == "true" || value == "on") return true;
if (value == "0" || value == "false" || value == "off") return false;
return fallback;
}
// P2d-c texture 밀봉의 정본은 ExperimentMaterialSealing::SealCore로 이전됐다
// (I5-M4). legacy Material을 읽던 SealMaterialTextureBindings는 그 치환으로
// 소비자가 0이 되어 제거됐다.
float ReadLivePostFloat(const char* name, float fallback)
{
const std::string value = ReadLivePostEnvironment(name);
if (value.empty()) return fallback;
char* end = nullptr;
const float parsed = std::strtof(value.c_str(), &end);
return (end != value.c_str() && '\0' == *end && std::isfinite(parsed))
? parsed : fallback;
}
struct LiveStopwatch
{
LARGE_INTEGER frequency{};
LARGE_INTEGER started{};
LiveStopwatch() { ::QueryPerformanceFrequency(&frequency); }
void Start() { ::QueryPerformanceCounter(&started); }
double ElapsedMs() const
{
LARGE_INTEGER now;
::QueryPerformanceCounter(&now);
return static_cast<double>(now.QuadPart - started.QuadPart)
* 1000.0 / static_cast<double>(frequency.QuadPart);
}
};
// 파이프라인 번들. 켤 때마다 힙에 새로 만든다.
//
// ★ 멤버 재사용(Shutdown 후 같은 객체에 다시 Initialize)이 아니다.
// 처음에 그렇게 했다가 off→on 재활성화에서 죽었다 — 디바이스·캐시·
// 패스들은 전부 '새 객체에 한 번 Initialize'만 검증돼 있고(테스트가
// 항상 스택에 새로 만든다), 재초기화 경로는 아무도 밟은 적이 없는
// 길이었다. 검증된 수명 패턴을 그대로 쓰는 것이 맞다.
struct LivePipeline
{
uint32_t width{ 0 };
uint32_t height{ 0 };
double lastNativeRecordMs{ 0.0 };
EnhancedRenderGraph::Stats lastGraphStats{};
// ★ SSGI는 여기 없다 — CameraView가 뷰마다 하나씩 든다.
// 시간축 누적을 하는 유일한 라이브 패스라서 그렇다(아래 CameraView
// 주석 참조). 나머지 패스는 프레임 안에서 입력→출력이 끝나므로
// 카메라가 둘이어도 한 인스턴스로 충분하다.
EnhancedGBufferPass gbuffer;
EnhancedShadowPass shadow;
EnhancedDecalPass decal;
EnhancedDeferredPass deferred;
EnhancedForwardPass forward;
EnhancedSpritePass sprite;
EnhancedSSAOPass ssao;
EnhancedSSSPass sss;
EnhancedSSRPass ssr;
EnhancedSkyBoxPass skyBox;
EnhancedIBLGenerator ibl;
EnhancedPostChainPass postChain;
bool iblGenerated{ false };
RHITextureHandle fogCloudNeutralHandle;
RHITextureHandle fogBlueNoiseHandle;
// 기즈모 체인(에디터 보조 표시)은 여기 없다 — Editor Host가
// IRenderFeatureContributor로 기여하고 패스 수명도 기여 노드가
// 소유한다(E4-2). UI는 런타임 게임 UI라 남는다(E4-1 재분류).
EnhancedUIPass ui;
EnhancedFrameContext frameContext{};
// 이 파이프라인의 조립 기술. 파이프라인이 설 때 한 번 짜이고, 노드의
// 접착 람다가 이 LivePipeline과 LiveState를 캡처한다 — 그래서 수명이
// 파이프라인에 묶여야 하고, 여기 멤버로 두는 것이 그 계약이다.
// 파이프라인을 헐면 노드도 함께 사라진다.
LivePipelineDesc desc;
// 프레임마다 비우고 다시 채운다. 뷰가 둘이어도 한 벌로 충분하다 —
// 한 프레임의 한 뷰를 그리는 동안에만 살아 있는 값이다.
LiveBlackboard blackboard;
// 표시 슬롯 둘 — 비동기의 본체다. DX12가 한 슬롯에 그리는 동안
// DX11은 다른 슬롯을 표시한다. 슬롯은 펜스 값이 완료된 뒤에만
// 표시로 승격되므로(TickLive), DX11이 쓰기 중인 텍스처를 읽는
// 일이 구조적으로 없다 — WaitForGpu가 필요 없어지는 이유다.
struct DisplaySlot
{
RHITextureHandle rhiTexture;
EnhancedSceneRendererLiveDX12Adapter::DisplayToken interopToken{
EnhancedSceneRendererLiveDX12Adapter::kInvalidDisplayToken };
uint64_t fenceValue{ 0 };
uint64_t frameId{ 0 };
EnhancedLiveViewKey key{};
// 이 슬롯 프레임의 그래프. 규칙(dx12.compare 크래시의 교훈):
// 그래프의 수명은 그 커맨드를 GPU가 끝낼 때까지다 — transient를
// 그래프가 들고 있다. 승격(펜스 완료) 때 놓으면 풀로 반납된다.
std::shared_ptr<EnhancedRenderGraph> graph;
};
// 카메라 하나가 쓰는 표시 슬롯 묶음. 씬뷰(에디터 카메라)와 게임뷰
// (게임 카메라)가 서로 다른 카메라를 넘기는데, 슬롯이 한 벌이면 한
// 프레임에 한 카메라만 그림을 받아 다른 뷰가 검거나 깜빡인다 —
// 뷰마다 독립한 슬롯 집합을 굴려 각 뷰가 자기 최신 프레임을 계속
// 표시한다(MultiCameraRenderPlan.md).
//
// 슬롯 셋 = 표시 1 + 인플라이트 최대 2. 인플라이트 1개로는 GPU 완료
// 신호 지연(제출→관측 ~1ms대)이 2ms 틱을 넘겨 틱의 38%가 제출을
// 쉬었다(실측 81/214) — 표시 프레임률이 절반이 된다. 2개를 겹치면
// 매 틱 제출이 성립한다.
//
// ★ 단, '인플라이트 2 = 링 3의 안전 거리'라는 실측 근거는 제출
// 총량 기준이지 뷰당이 아니다. 뷰마다 2씩 들면 총 4가 되어
// BeginFrame이 얼로케이터 펜스에서 블로킹한다 — TickLive가 뷰
// 합산 인플라이트를 2로 묶는 이유다.
static constexpr int kSlotsPerView = 3; // 표시 1 + 인플라이트 2
struct CameraView
{
EnhancedLiveViewKey key{};
EnhancedLiveDisplayTarget displayTarget{ EnhancedLiveDisplayTarget::Game };
EnhancedLiveViewFlags viewFlags{ EnhancedLiveViewFlags::ScreenSpaceUI };
DisplaySlot slots[kSlotsPerView];
int displaySlot{ -1 }; // DX11이 표시 중인 슬롯(-1 = 아직 없음)
std::vector<int> pendingQueue; // 제출 순서의 인플라이트 슬롯들
uint64_t promotionCount{ 0 }; // GPU 완료 뒤 표시로 승격한 횟수
uint32_t promotedSlotMask{ 0 }; // 실제 사용한 슬롯 인덱스 집합
// ★ SSGI를 뷰마다 따로 든다 — 라이브 그래프에서 프레임을 넘겨
// 상태를 잇는 유일한 패스이기 때문이다(히스토리 텍스처 2장 +
// 재투영 행렬 + 슬롯 인덱스).
//
// 한 인스턴스를 두 카메라가 쓰면 히스토리 슬롯 회전(2칸)이
// 프레임당 두 번 돌아 각 카메라가 '상대 카메라의' 히스토리를
// 읽고, 재투영 행렬도 직전에 렌더한 다른 카메라의 것이 된다.
// 리졸브는 깊이 차이만 보고 히스토리를 받아들이므로(같은 씬을
// 보면 대부분 통과한다) 다른 시점의 화면 공간 GI가 최대 32프레임
// 지수 평균으로 섞인다 — 씬 뷰의 천이 통째로 얼룩지던 잔상이
// 그것이었다(2026-08-07, 세 조건 대조로 확정).
//
// PSO는 psoManager가 바이트코드 해시로 공유하므로 인스턴스가
// 늘어도 컴파일은 한 번이다. 추가 비용은 히스토리 텍스처뿐 —
// GI가 절반 해상도라 1920x1080 기준 뷰당 약 12MB.
EnhancedSSGIPass ssgi;
// 포그도 같은 이유로 뷰마다 든다 — 프록셀 격자(m_voxelTemp 둘 +
// m_voxelFinal)가 프레임을 넘겨 살고, m_readIndex 핑퐁과
// m_previousViewProjection이 SSGI의 히스토리와 똑같은 역할을 한다.
//
// ★ 다만 Initialize를 미룬다. 격자가 160x90x128 RGBA16F 셋이라
// 뷰당 42MB이고, 합성 출력과 힙까지 더한 실측 증가가 켤 때
// +127MB다(Private, 1437→1564). 기본이 꺼짐인데 미리 잡으면
// 안 쓰는 기능이 그만큼을 묶는다 — 처음 켜지는 프레임에
// 만든다(fogReady). 끈 채로는 증가가 없음을 실측으로 확인했다.
EnhancedVolumetricFogPass fog;
bool fogReady{ false };
};
static constexpr int kMaxCameraViews =
static_cast<int>(EnhancedSceneRenderer::kMaxLiveCameraViews);
CameraView views[kMaxCameraViews];
// transient 풀 — 프레임당 CreateCommittedResource ~35건을 없앤다.
// 그래프 소멸(펜스 완료 후)이 반납하므로 GPU 사용 중 재배포가 없다.
RGTransientPool transientPool;
};
// Vulkan 공용 scene graph 라이브 경로. 에디터 창 자체는 아직 DX12 ImGui
// 셸이므로 최종 LDR를 비동기 리드백한 뒤 셸에 넘긴다.
// 그래프와 리드백 슬롯은 timeline completion까지 살아 있어 D3D12 경로의
// 표시 슬롯과 같은 수명 계약을 지킨다. 이 브리지는 기능 동등성 단계이며,
// external-memory 직접 공유는 별도 성능 단계다.
struct VulkanLivePipeline
{
static constexpr uint32_t kSlotCount = 3;
static constexpr uint64_t kDisplayKeyBase = 0x564B4C4956450000ull; // "VKLIVE"
uint32_t width{ 0 };
uint32_t height{ 0 };
uint64_t frameCounter{ 0 };
VulkanDeviceResources resources;
VulkanPipelineCache pipelines;
VulkanMeshCache meshCache;
VulkanTextureCache textureCache;
VulkanCommandBufferPool commandPool;
EnhancedShadowPass shadow;
EnhancedGBufferPass gbuffer;
EnhancedDecalPass decal;
EnhancedSSAOPass ssao;
EnhancedDeferredPass deferred;
EnhancedForwardPass forward;
EnhancedSpritePass sprite;
EnhancedSSSPass sss;
EnhancedSSRPass ssr;
EnhancedSkyBoxPass skyBox;
EnhancedIBLGenerator ibl;
EnhancedPostChainPass postChain;
// 기즈모 체인은 Host 기여 노드가 소유한다(E4-2) — DX12 쪽과 같다.
EnhancedUIPass ui;
bool iblGenerated{ false };
RHITextureHandle fogCloudNeutralHandle;
RHITextureHandle fogBlueNoiseHandle;
bool fogInputsReady{ false };
EnhancedFrameContext frameContext{};
LivePipelineDesc desc;
LiveBlackboard blackboard;
RGTransientPool transientPool;
EnhancedRenderGraph::Stats lastGraphStats{};
double lastNativeRecordMs{ 0.0 };
uint32_t commandPoolFrame{ 0 };
struct View
{
EnhancedLiveViewKey key{};
EnhancedLiveDisplayTarget displayTarget{ EnhancedLiveDisplayTarget::Game };
EnhancedLiveViewFlags viewFlags{ EnhancedLiveViewFlags::ScreenSpaceUI };
bool ready{ false };
// temporal GI는 카메라별 히스토리·이전 행렬을 가진다. 공용
// 인스턴스를 쓰면 여러 씬 뷰가 서로의 화면 공간 GI를 섞는다.
EnhancedSSGIPass ssgi;
EnhancedVolumetricFogPass fog;
bool fogReady{ false };
uint64_t promotionCount{ 0 };
uint32_t promotedSlotMask{ 0 };
uint64_t completedFrameId{ 0 };
};
mutable std::mutex viewMutex;
View views[EnhancedSceneRenderer::kMaxLiveCameraViews];
struct Slot
{
RHIReadback readback{};
std::shared_ptr<EnhancedRenderGraph> graph;
uint64_t fenceValue{ 0 };
uint32_t viewIndex{ 0 };
EnhancedLiveViewKey key{};
uint64_t frameId{ 0 };
bool pending{ false };
};
Slot slots[kSlotCount];
static std::vector<uint8_t> TonemapToRgba8(const RHIReadbackImage& image)
{
std::vector<uint8_t> output(static_cast<size_t>(image.width) * image.height * 4u);
if (RHIFormat::RGBA8Unorm == image.format ||
RHIFormat::RGBA8UnormSrgb == image.format)
{
const size_t tightRow = static_cast<size_t>(image.width) * 4u;
for (uint32_t y = 0; y < image.height; ++y)
{
memcpy(output.data() + static_cast<size_t>(y) * tightRow,
image.data.data() + static_cast<size_t>(y) * image.rowPitch,
tightRow);
}
return output;
}
if (RHIFormat::RGBA16Float != image.format) return output;
for (uint32_t y = 0; y < image.height; ++y)
{
const auto* source = reinterpret_cast<const uint16_t*>(
image.data.data() + static_cast<size_t>(y) * image.rowPitch);
uint8_t* destination = output.data() + static_cast<size_t>(y) * image.width * 4u;
for (uint32_t x = 0; x < image.width; ++x)
{
for (uint32_t channel = 0; channel < 3; ++channel)
{
float value = RHIReadbackImage::DecodeHalf(source[x * 4u + channel]);
value = (std::max)(0.f, value);
// 예전 HDR 직접 리드백 슬롯을 읽을 수 있게 남긴 호환 경로다.
// 현재 공용 PostChain은 RGBA8 LDR를 내므로 위에서 끝난다.
value = value / (1.f + value);
value = std::pow((std::min)(1.f, value), 1.f / 2.2f);
destination[x * 4u + channel] = static_cast<uint8_t>(
(std::min)(255.f, value * 255.f + 0.5f));
}
const float alpha = (std::max)(0.f, (std::min)(1.f,
RHIReadbackImage::DecodeHalf(source[x * 4u + 3u])));
destination[x * 4u + 3u] = static_cast<uint8_t>(alpha * 255.f + 0.5f);
}
}
return output;
}
bool Initialize(uint32_t newWidth, uint32_t newHeight,
FrameCameraSnapshot& camera, std::vector<EnhancedDrawItem>& draws,
std::vector<EnhancedDrawItem>& forwardDraws,
std::vector<EnhancedLight>& lights, std::string& outError)
{
if (!VulkanApi::LoadLoader(outError)) return false;
if (!resources.Initialize(newWidth, newHeight,
#if defined(_DEBUG)
true,
#else
false,
#endif
outError)) return false;
pipelines.Initialize(resources.GetDevice());
resources.SetPipelineCache(&pipelines);
if (!meshCache.Initialize(&resources, outError) ||
!textureCache.Initialize(&resources, outError) ||
!commandPool.Initialize(resources, 4,
VulkanDeviceResources::kFrameCount, outError)) return false;
width = newWidth;
height = newHeight;
frameContext = {};
frameContext.resources = &resources;
frameContext.psoManager = &pipelines;
frameContext.rootSignatures = &pipelines;
frameContext.meshCache = &meshCache;
frameContext.textureCache = &textureCache;
frameContext.width = width;
frameContext.height = height;
frameContext.camera = &camera;
frameContext.draws = &draws;
frameContext.forwardDraws = &forwardDraws;
frameContext.lights = &lights;
// 기여 노드(기즈모 체인)의 같은 규약은 Contribute가
// RenderFeatureContext.ldrFormat으로 받는다(E4-2).
ui.SetOutputFormat(EnhancedPostChainPass::kLDRFormat);
sss.SetEnabled(ReadLivePostFlag("CREATOR_DX12_SSS", false));
ssr.SetEnabled(ReadLivePostFlag("CREATOR_DX12_SSR", false));
{
EnhancedPostChainPass::Tuning tuning = postChain.GetTuning();
tuning.bloomEnabled = ReadLivePostFlag(
"CREATOR_DX12_POST_BLOOM", tuning.bloomEnabled);
tuning.toneMapEnabled = ReadLivePostFlag(
"CREATOR_DX12_POST_TONEMAP", tuning.toneMapEnabled);
const std::string toneMapper = ReadLivePostEnvironment(
"CREATOR_DX12_POST_TONEMAPPER");
if (toneMapper == "aces" || toneMapper == "0")
tuning.toneMapper = EnhancedPostChainPass::ToneMapper::ACES;
else if (toneMapper == "agx" || toneMapper == "1")
tuning.toneMapper = EnhancedPostChainPass::ToneMapper::AgX;
tuning.exposure = ReadLivePostFloat(
"CREATOR_DX12_POST_EXPOSURE", tuning.exposure);
postChain.SetTuning(tuning);
}
RHIShaderCompiler::ScopedOutput spirv(RHIShaderBinary::SpirV);
if (!desc.InitializeAll(frameContext,
static_cast<uint32_t>(EnhancedSceneRenderer::kMaxLiveCameraViews),
outError) || !ibl.Initialize(frameContext, outError)) return false;
gbuffer.SetKeepAlive(false);
// SSGI가 AO를 실제로 소비하므로 SSAO를 별도 루트로 살릴 필요가 없다.
ssao.SetKeepAlive(false);
for (Slot& slot : slots)
{
if (!resources.CreateReadback(width, height,
EnhancedPostChainPass::kLDRFormat, 1, slot.readback, outError))
return false;
}
return true;
}
void Shutdown()
{
if (resources.IsInitialized())
{
std::string lifecycleError;
if (!resources.DrainForLifecycle(
RHILifecycleCommand::BackendShutdown, lifecycleError))
{
OutputDebugStringA(("[Vulkan live] backend shutdown drain 실패: " +
lifecycleError + "\n").c_str());
std::string abandonError;
resources.DrainForLifecycle(
RHILifecycleCommand::UnrecoverableDeviceError,
abandonError);
}
}
for (Slot& slot : slots)
{
slot.graph.reset();
resources.ReleaseReadback(slot.readback);
slot.pending = false;
}
desc.ShutdownAll(
static_cast<uint32_t>(EnhancedSceneRenderer::kMaxLiveCameraViews));
desc.Clear();
ibl.Shutdown();
commandPool.Shutdown();
textureCache.Shutdown();
meshCache.Shutdown();
pipelines.Shutdown();
resources.Shutdown();
}
int FindOrAssignView(const EnhancedLiveViewPacket& requested,
const EnhancedLiveFramePacket& frame)
{
std::lock_guard<std::mutex> lock(viewMutex);
for (uint32_t i = 0; i < EnhancedSceneRenderer::kMaxLiveCameraViews; ++i)
{
if (views[i].key == requested.key)
{
views[i].displayTarget = requested.displayTarget;
views[i].viewFlags = requested.viewFlags;
return static_cast<int>(i);
}
}
uint32_t selected = EnhancedSceneRenderer::kMaxLiveCameraViews;
for (uint32_t i = 0; i < EnhancedSceneRenderer::kMaxLiveCameraViews; ++i)
{
if (!views[i].key.IsValid()) { selected = i; break; }
}
if (EnhancedSceneRenderer::kMaxLiveCameraViews == selected)
{
for (uint32_t i = 0; i < EnhancedSceneRenderer::kMaxLiveCameraViews; ++i)
{
bool stillVisible = false;
for (uint32_t j = 0; j < frame.viewCount; ++j)
{
if (views[i].key == frame.views[j].key)
{
stillVisible = true;
break;
}
}
if (!stillVisible) { selected = i; break; }
}
}
if (EnhancedSceneRenderer::kMaxLiveCameraViews == selected) return -1;
views[selected].key = requested.key;
views[selected].displayTarget = requested.displayTarget;
views[selected].viewFlags = requested.viewFlags;
views[selected].ready = false;
views[selected].promotionCount = 0;
views[selected].promotedSlotMask = 0;
views[selected].completedFrameId = 0;
views[selected].ssgi.ResetHistory();
return static_cast<int>(selected);
}
uint32_t PendingCount() const
{
uint32_t count = 0;
for (const Slot& slot : slots) if (slot.pending) ++count;
return count;
}
void PromoteCompleted(
const std::shared_ptr<IDisplayPresentationSink>& presentationSink,
uint64_t& outPromoted, std::string& outValidation)
{
const uint64_t completed = resources.GetCompletedFenceValue();
textureCache.SweepGraveyard(completed);
meshCache.SweepGraveyard(completed);
for (uint32_t slotIndex = 0; slotIndex < kSlotCount; ++slotIndex)
{
Slot& slot = slots[slotIndex];
if (!slot.pending || completed < slot.fenceValue) continue;
RHIReadbackImage image{};
std::string readbackError;
if (resources.MapReadback(slot.readback, image, readbackError) && image.IsValid())
{
bool belongsToView = false;
{
std::lock_guard<std::mutex> lock(viewMutex);
View& view = views[slot.viewIndex];
belongsToView = view.key == slot.key;
if (belongsToView)
{
view.ready = true;
++view.promotionCount;
view.promotedSlotMask |= (1u << slotIndex);
view.completedFrameId = slot.frameId;
}
}
if (belongsToView)
{
std::vector<uint8_t> rgba = TonemapToRgba8(image);
// Host가 설치한 표시 sink로 게시한다(E4-6a). 미설치면
// 프레임은 버려진다 — 표시할 곳이 없는 상태다.
if (presentationSink)
{
presentationSink->SubmitCpuFrame(
kDisplayKeyBase + slot.viewIndex + 1u,
image.width, image.height, rgba.data(),
image.width * 4u);
}
++outPromoted;
}
}
else if (!readbackError.empty())
{
outValidation += "Vulkan 라이브 리드백 실패: " + readbackError + "\n";
}
slot.graph.reset();
slot.pending = false;
}
#if defined(_DEBUG)
std::string validation;
if (0 != resources.DrainDebugMessages(validation) && !validation.empty())
outValidation += validation;
#endif
}
bool Render(uint32_t viewIndex, const EnhancedLiveViewPacket& viewPacket,
uint64_t sourceFrameId, uint64_t backendGeneration,
const std::function<bool(std::string&)>& prepareFrame,
std::string& outError, EnhancedPbrCapture* capture)
{
Slot* slot = nullptr;
for (Slot& candidate : slots)
{
if (!candidate.pending) { slot = &candidate; break; }
}
if (nullptr == slot) { outError = "Vulkan 라이브 리드백 슬롯이 모두 사용 중"; return false; }
if (!resources.BeginFrame(outError))
{
if (capture) capture->Fail(outError);
return false;
}
bool committed = false;
struct FrameGuard
{
VulkanDeviceResources& resources;
const bool& committed;
EnhancedPbrCapture* capture;
std::string& error;
~FrameGuard()
{
if (!committed) resources.AbortFrame();
if (capture)
{
resources.WaitForGpu();
capture->Release(resources);
if (capture->result.state == EnhancedPbrCaptureState::Recording)
capture->Fail(error);
}
}
} frameGuard{ resources, committed, capture, outError };
const uint32_t frameIndex = static_cast<uint32_t>(frameCounter++);
commandPool.BeginFrame(commandPoolFrame);
textureCache.BeginFrame(frameIndex);
meshCache.BeginFrame(frameIndex);
const RHIDeviceMemoryPressureInfo pressureInfo = resources
.GetPersistentMemoryBudgetCoordinator().GetMemoryPressureInfo();
RHIAssetEvictionPass evictionPass = BeginRHIAssetEvictionPass(
pressureInfo.memoryPressure, pressureInfo.targetReleaseBytes);
textureCache.RetireUnused(resources.GetLastSignaledFenceValue(),
&evictionPass);
meshCache.RetireUnused(resources.GetLastSignaledFenceValue(),
&evictionPass);
if (!prepareFrame(outError)) return false;
slot->graph = std::make_shared<EnhancedRenderGraph>(
static_cast<IRenderDeviceServices&>(resources));
EnhancedRenderGraph& graph = *slot->graph;
graph.SetTransientPool(&transientPool);
blackboard.Reset();
LiveFrameBinding binding{};
binding.viewIndex = viewIndex;
binding.readbackTarget = slot->readback;
binding.viewFlags = HasViewFlag(viewPacket.viewFlags,
EnhancedLiveViewFlags::SceneOverlay)
? LiveViewFlags::kSceneOverlay : LiveViewFlags::kScreenSpaceUI;
desc.DeclareAll(blackboard, graph, frameContext, binding);
if (capture && !capture->Declare(resources, graph, blackboard,
width, height, outError)) return false;
if (!blackboard.Get(LiveSlots::kDisplayLdr).IsValid())
{
outError = "Vulkan 라이브 공통 scene graph의 표시 출력이 없다";
return false;
}
RHIRecordedBatchDesc batchDesc{};
batchDesc.frameId = sourceFrameId;
batchDesc.backendGeneration = backendGeneration;
batchDesc.displayToken = kDisplayKeyBase + viewIndex + 1u;
batchDesc.lifetimeToken = slot->graph;
RHIRecordedBatch batch;
RHISubmissionTicket batchTicket;
if (!graph.Compile(outError)) return false;
LiveStopwatch recordWatch;
recordWatch.Start();
if (!graph.RecordParallel(commandPool, 4, batchDesc, batch, outError))
return false;
lastNativeRecordMs = recordWatch.ElapsedMs();
if (!GetRHISubmissionThread().EnqueueRecordedBatch(&resources,
resources, std::move(batch), batchTicket, outError)) return false;
lastGraphStats = graph.GetStats();
if (!resources.EndFrame(outError)) return false;
committed = true;
commandPoolFrame = (commandPoolFrame + 1u) % VulkanDeviceResources::kFrameCount;
slot->fenceValue = resources.GetLastSignaledFenceValue();
slot->viewIndex = viewIndex;
slot->key = viewPacket.key;
slot->frameId = sourceFrameId;
slot->pending = true;
if (capture)
{
resources.WaitForGpu();
std::string validation;
const uint32_t validationCount = resources.DrainDebugMessages(validation);
if (!capture->Save(resources, graph.GetStats(), outError,
validationCount, validation)) return false;
}
return true;
}
};
struct LiveState
{
std::unique_ptr<EnhancedPbrCapture> pbrCapture;
EnhancedPbrCapture* BeginPbrCapture(const EnhancedLiveFramePacket& frame,
const EnhancedLiveViewPacket& view)
{
if (!pbrCapture || pbrCapture->result.state != EnhancedPbrCaptureState::Pending
|| pbrCapture->target != view.displayTarget
|| frame.frameId <= pbrCapture->afterFrameId) return nullptr;
try { pbrCapture->Begin(frame, view, backend, draws, forwardDraws, lights, skyBoxPath); }
catch (const std::exception& error)
{
pbrCapture->Fail(error.what());
return nullptr;
}
return pbrCapture.get();
}
std::atomic_bool enabled{ false };
bool runtimeInitialized{ false };
EnhancedLiveBackend backend{ EnhancedLiveBackend::DX12 };
EnhancedSceneRendererLiveDX12Adapter dx12;
// SceneRenderer(DX11)에서 이관한 메인 런타임 소유권. 에디터 카메라는
// 여기 없다(E4-5) — Editor 세션이 소유하고 뷰 요청으로 넘어온다.
std::shared_ptr<RenderScene> renderScene;
// Editor Host가 주입하는 presentation 입력. GT가 packet마다 shared_ptr을
// snapshot하고 packet이 RT까지 수명을 운반한다.
mutable std::mutex gizmoIconMutex;
std::shared_ptr<const EnhancedGizmoIconTextures> gizmoIconTextures;
// Host가 주입하는 파이프라인 기여자(E4-2). 조립은 RenderThread에서
// 일어나므로 설치·해제와 mutex로 격리한다. 기여 노드는 기여자가 아니라
// 자기 패스 묶음을 붙들므로, 해제 뒤에도 살아 있는 파이프라인은 안전하다.
mutable std::mutex featureContributorMutex;
std::shared_ptr<IRenderFeatureContributor> featureContributor;
// Host가 주입하는 표시 sink(E4-6a). RT의 CPU 프레임 push와 CE의
// 표시 ID 해석이 소비하므로 mutex 아래 shared_ptr 복사로 격리한다.
// 미설치면 표시 ID는 0이다 — Core는 ImGui 셸을 모른다.
mutable std::mutex presentationSinkMutex;
std::shared_ptr<IDisplayPresentationSink> presentationSink;
std::shared_ptr<IDisplayPresentationSink> CopyPresentationSink() const
{
std::lock_guard<std::mutex> lock(presentationSinkMutex);
return presentationSink;
}
// 3-2E: GT는 immutable packet과 delta batch를 발행하고, 전용 RT만
// TickLive 및 아래 render-owned 상태를 소비한다. CE는 완료 display만
// displayLifetimeMutex 경계에서 조회한다.
std::thread::id frameProducerThread{};
std::thread::id frameConsumerThread{};
std::atomic_ullong publishedFrameId{ 0 };
std::atomic_ullong consumedFrameId{ 0 };
std::atomic_ullong sceneEpoch{ 1 };
std::atomic_ullong resizeGeneration{ 0 };
uint32_t publishedWidth{ 0 };
uint32_t publishedHeight{ 0 };
float publishedTotalSeconds{ 0.f };
struct FrameSubmission
{
EnhancedLiveFramePacket frame;
ProxyCommandQueueController::Batch deltas;
};
static constexpr uint32_t kRenderQueueCapacity = 2;
static constexpr size_t kMaxDeltasPerSubmission = 65536;
mutable std::mutex renderQueueMutex;
std::condition_variable renderQueueWake;
std::deque<FrameSubmission> renderQueue;
std::thread renderThread;
bool renderThreadStarted{ false };
bool renderThreadStartFailed{ false };
bool renderThreadRunning{ false };
bool renderThreadAccepting{ false };
bool renderThreadStopRequested{ false };
uint32_t renderThreadTestDelayMs{ 0 };
uint32_t renderQueueHighWatermark{ 0 };
uint32_t renderInProgress{ 0 };
uint64_t renderPublished{ 0 };
uint64_t renderConsumed{ 0 };
uint64_t renderOverflowEvents{ 0 };
uint64_t renderCoalescedFrames{ 0 };
uint64_t renderCoalescedDeltas{ 0 };
uint64_t renderBackPressureWaits{ 0 };
uint64_t renderShutdownDrains{ 0 };
uint64_t renderShutdownDiscardedDeltas{ 0 };
// status/검증/PIX wait가 RT의 pipeline 포인터와 통계를 직접 읽을 때만
// 잡는다. 일반 CE display 조회는 더 좁은 displayLifetimeMutex를 쓴다.
mutable std::mutex renderStateMutex;
ProxyCommandQueueController::Batch activeDeltaBatch; // RT 전용
bool StartRenderThread(std::string& outError);
bool PublishFrame(FrameSubmission submission);
void StopRenderThread();
EnhancedRenderThreadStats GetRenderThreadStats() const;
bool WaitForRenderThreadIdle(uint32_t timeoutMilliseconds);
// 원본 HDR는 기존 자산 로더가 만든 DX11 Texture지만 소유자는 이쪽이다.
// DX12TextureCache가 프레임 시작에 같은 어댑터의 리소스로 올린 뒤 IBL
// 생성기가 큐브맵·조도·프리필터·BRDF LUT를 만든다.
std::string skyBoxPath;
std::unique_ptr<Texture> skyEquirect;
bool skyBoxDirty{ true };
// ── 볼류메트릭 포그 입력 ──
//
// 기본이 꺼짐이라 처음 켜질 때 만든다(EnsureFogInputs).
//
// ★ 둘 다 포그 전용으로 둔다. textureCache의 흰색 폴백을 그대로 쓰면
// 그것은 재질이 텍스처 없을 때 GBuffer가 디스크립터로 직접 묶는
// 리소스라, 그래프가 상태를 옮기면 다음 프레임 GBuffer가 어긋난
// 상태로 읽는다. 그래프는 임포트한 리소스를 원래 상태로 되돌려
// 주지 않는다(stateWriteback은 '어디로 남았는지'만 알려 준다).
//
// ★ 끝 상태를 ALL_SHADER_RESOURCE로 맞춘다. RHIResourceState에는
// PIXEL 전용 값이 없어 ShaderResource가 곧 ALL인데, textureCache는
// 업로드를 PIXEL로 끝내므로 그대로 임포트하면 배리어의 before가
// 실제와 어긋난다(검증 레이어가 잡는다).
std::unique_ptr<Texture> fogBlueNoise;
// ★ 핸들을 옆에 든다(V3). 예전에는 프레임마다 ImportTexture 의 포인터
// 오버로드를 타서 표에 등록하고 그래프가 죽을 때 놓기를 반복했다 —
// 그림은 같고 비용만 드는 왕복이다. 한 번 등록해 두면 프레임마다
// 핸들만 넘긴다.
bool fogInputsReady{ false };
// 블루 노이즈를 PIXEL에서 ALL_SHADER_RESOURCE로 한 번 넓혔는가.
// 텍스처 캐시 수명에 묶인다(캐시가 파이프라인과 함께 죽으면 리소스도
// 새로 올라가므로 다시 넓혀야 한다). 포그를 껐다 켜는 것으로는
// 리셋하지 않는다 — 이미 넓힌 리소스에 또 배리어를 걸면 before가
// 실제와 어긋나 검증 레이어가 잡는다.
bool fogNoiseStateWidened{ false };
// 창이 넣은 켬/끔. 꺼져 있으면 포그 패스를 아예 세우지 않는다.
bool fogEnabled{ false };
// 켬 → 끔 전환을 봤다. 자원 해제는 락 밖(TickLive)에서 한다.
bool fogTeardownPending{ false };
uint64_t fogRetireFence{ 0 };
// 렌더 backend가 소유하는 카메라별 뷰. CE/UI는 이 파이프라인을 직접
// 순회하지 않고 아래 display snapshot의 Editor/Game 대상만 소비한다.
std::unique_ptr<LivePipeline> pipeline;
std::unique_ptr<VulkanLivePipeline> vulkanPipeline;
// RenderThread는 완료된 슬롯을 아래 값 스냅샷으로 승격하고, CE는 그
// 스냅샷과 불투명 presentation key만 읽는다. resize 해체와 DX12 공유
// 핸들 open의 수명도 같은 락으로 직렬화한다. 실제 기록/제출에는 잡지 않는다.
mutable std::mutex displayLifetimeMutex;
EnhancedLiveDisplaySnapshot displaySnapshot{};
std::array<uint64_t, kEnhancedLiveDisplayTargetCount>
displayPresentationKeys{};
static uint32_t DisplayTargetIndex(EnhancedLiveDisplayTarget target)
{
return static_cast<uint32_t>(target);
}
void BeginDisplaySnapshot(const EnhancedLiveFramePacket& frame)
{
std::lock_guard<std::mutex> displayLock(displayLifetimeMutex);
displaySnapshot.backend = backend;
displaySnapshot.sourceFrameId = frame.frameId;
displaySnapshot.resizeGeneration = frame.resizeGeneration;
displaySnapshot.width = frame.width;
displaySnapshot.height = frame.height;
std::array<bool, kEnhancedLiveDisplayTargetCount> active{};
const uint32_t viewCount = (std::min)(frame.viewCount,
EnhancedSceneRenderer::kMaxLiveCameraViews);
for (uint32_t i = 0; i < viewCount; ++i)
{
const EnhancedLiveViewPacket& view = frame.views[i];
const uint32_t targetIndex = DisplayTargetIndex(view.displayTarget);
active[targetIndex] = true;
EnhancedLiveDisplayEntrySnapshot& entry =
displaySnapshot.targets[targetIndex];
if (entry.key != view.key)
{
entry = {};
displayPresentationKeys[targetIndex] = 0;
}
entry.key = view.key;
entry.active = true;
}
for (uint32_t i = 0; i < kEnhancedLiveDisplayTargetCount; ++i)
{
if (active[i]) continue;
displaySnapshot.targets[i] = {};
displayPresentationKeys[i] = 0;
}
++displaySnapshot.revision;
}
void InvalidateDisplayResultsLocked()
{
for (uint32_t i = 0; i < kEnhancedLiveDisplayTargetCount; ++i)
{
EnhancedLiveDisplayEntrySnapshot& entry = displaySnapshot.targets[i];
entry.ready = false;
entry.completedFrameId = 0;
entry.promotionCount = 0;
entry.promotedSlotMask = 0;
displayPresentationKeys[i] = 0;
}
++displaySnapshot.revision;
}
void ResetDisplaySnapshot()
{
std::lock_guard<std::mutex> displayLock(displayLifetimeMutex);
const uint64_t nextRevision = displaySnapshot.revision + 1u;
displaySnapshot = {};
displaySnapshot.backend = backend;
displaySnapshot.revision = nextRevision;
displayPresentationKeys.fill(0);
}
void PublishDisplayResultLocked(EnhancedLiveDisplayTarget displayTarget,
const EnhancedLiveViewKey& key, uint64_t presentationKey,
uint64_t completedFrameId, uint64_t promotionCount,
uint32_t promotedSlotMask)
{
const uint32_t targetIndex = DisplayTargetIndex(displayTarget);
EnhancedLiveDisplayEntrySnapshot& entry =
displaySnapshot.targets[targetIndex];
if (!entry.active || entry.key != key) return;
entry.ready = 0 != presentationKey;
entry.completedFrameId = completedFrameId;
entry.promotionCount = promotionCount;
entry.promotedSlotMask = promotedSlotMask;
displayPresentationKeys[targetIndex] = presentationKey;
++displaySnapshot.revision;
}
void PublishVulkanDisplayResults()
{
if (!vulkanPipeline) return;