文章目录
1.实现目标
上篇文章记录了在UE中加载单张Dicom数据生成2D纹理并显示,本篇文章加载本地文件夹内一个序列的所有Dicom数据,在UE中生成
VolumeTexture
,并实现自动翻页
的功能
显示一个序列内的Dicom数据 ,可以每次单独加载解析单个Dicom数据,或者生成2D纹理数组 都可以实现本文的功能,但为了后续VolumeRendering 的方便,所以这里直接使用VolumeTexture
才实现功能。
在UE中实现的功能GIF动图效果如下:

2.实现过程
包括本地文件夹内多个dicom数据 的读取,在UE中生成
VolumeTexture
,按照dicom标准转换展示,和自动翻页功能
2.1 基本原理
(1)多个Dicom文件读取
本文默认当前文件夹内的所有Dicom文件都属于同一个序列
,对于属于不同序列,或者需要拆体的数据,本文暂不考虑。每张Dicom数据的顺序
按照InstanceNumber Tag的值为依据
①获取本地特定文件夹下的所有.dcm
后缀的文件

②基于dcmtk 库解析dicom数据,并初始化VolumeDataLoader
组件(本文新建的)中的SeriesData
和DicomData
数据结构,使用dcmtk三方库解析具体的解析流程与上篇文章相同。

(2)VolumeTexture
生成
①创建VolumeTexture
,本文这里只考虑无符号的16位格式,其余格式类型类似,这里暂不考虑

②遍历DicomData的pixelData
数据,并拷贝到VolumeTexture
的BulkData
中。需要注意的是UpdateResource
需要在GameThread
主线程中更新

(3)按Dicom标准
展示
包括Modality转化和VOI转换等,将Dicom数据中的PixelData 进行转换得到P-Values,以实现正确的显示效果。
与上篇的内容一致,包括基于斜率截距反算窗宽窗位,在Shader中进行处理和Gamma矫正等,这里不过多赘述。
(4)自动翻页
在Tick中每帧计算当前页,设置动态材质实例中的参数即可。

2.2 C++源码
VolumeDataLoader
组件的完整C++代码如下所示:
(1)VolumeDataLoader.h
cpp
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Engine/VolumeTexture.h"
#include <memory>
// DCMTK uses their own verify and check macros.
// Also, they include some effed up windows headers which for example include min and max macros for that
// extra bit of _screw you_
#pragma push_macro("verify")
#pragma push_macro("check")
#undef verify
#undef check
#include "dcmtk/dcmdata/dcdatset.h"
#include "dcmtk/dcmdata/dcdeftag.h"
#include "dcmtk/dcmdata/dcfilefo.h"
#include "dcmtk/dcmdata/dcpixel.h"
#include "dcmtk/dcmimgle/dcmimage.h"
#pragma pop_macro("verify")
#pragma pop_macro("check")
#include "Engine/StaticMeshActor.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "VolumeDataLoader.generated.h"
#define UpdateResource UpdateResource
/**
* Dicom data type enum.
*/
UENUM()
enum EDicomDataType
{
E_UShort UMETA(DisplayName = "16bit unsigned"),
E_Short UMETA(DisplayName = "16bit signed"),
E_Byte UMETA(DisplayName = "8bit unsigned"),
E_SByte UMETA(DisplayName = "8bit signed")
};
USTRUCT()
struct FDicomData
{
GENERATED_USTRUCT_BODY()
// Dicom file study uid
FString studyUid;
// Series uid
FString seriesUid;
// Single dicom file instance number
uint16 instanceNumber = -1;
// Pixel data
BYTE* pixelData;
// Data length
unsigned long length = 0;
// Data type
EDicomDataType dataType;
// Dicom width
uint16 width = 0;
// Dicom height
uint16 height = 0;
};
UCLASS()
class DICOMVIS_API USeriesData : public UObject
{
GENERATED_BODY()
public:
#pragma region DicomParam
// Dicom file study uid
FString studyUid;
// Series uid
FString seriesUid;
// Dicom data width
uint16 width = 0;
// Dicom data heigth
uint16 height = 0;
// This dicom data count
int count = 0;
// Data type
EDicomDataType dataType;
// Window center of this series all dicom
float windowCenter = 0;
// Window width of this series all dicom
float windowWidth = 1;
// Data range 16 bit is 65535, 8 bit is 255
float dataRange = 1;
// Slope
float slope = 1;
// Intercept
float intercept = 0;
#pragma endregion
private:
// Dicom data map
TMap<int, TSharedPtr<FDicomData>> dicomDataMap;
public:
// Constructor
USeriesData();
FCriticalSection dicomDataMapLock;
// Add dicom data
void AddDicomData(TSharedPtr<FDicomData> dicomData);
// Get all dicom Data
TMap<int, TSharedPtr<FDicomData>> GetAllDicomData();
// Clear
void ClearDicomData();
};
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class DICOMVIS_API UVolumeDataLoader : public UActorComponent
{
GENERATED_BODY()
public:
// Sets default values for this component's properties
UVolumeDataLoader();
virtual void BeginDestroy() override;
// The dicom data dir path, that is the reletive path of ue game project directory.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
FString TargetDirPath = "Data/DicomData";
// Plane static mesh component
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
UStaticMeshComponent* pPlaneMesh = nullptr;
// Material
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
UMaterial* pMaterial = nullptr;
// The dicom pixel data uVolumeTexture
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
UVolumeTexture* pVolumeTexture = nullptr;
// Study uid
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
FString StudyUid;
// Series uid
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
FString SeriesUid;
// Current page number.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
int CurrentPageNum = 0;
// Window Center of dicom
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
float WindowCenter;
// Window width of dicom
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dicom")
float WindowWidth;
// The range of dicom data, range = max - min
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
float Range = 1;
// Slope value of dicom
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
float Slope = 0;
// Intercept value of dicom
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
float Intercept = 0;
// Dicom image width
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
int Width = 0;
// Dicom image height
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
int Height = 0;
// Total slice number.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
int SliceNum = 0;
// Dicom image depth
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dicom")
int Depth = 0;
// Load dicom data, parse pixel data to volumeTexture and visualize
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Dicom")
void LoadVolumeDicom();
// Begin dicom auto page
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Dicom")
void BeginAutoPage();
// End dicom auto page
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Dicom")
void EndAutoPage();
private:
// Dicom series data
USeriesData* pSeriesData = nullptr;
// Material instance dynamic
UMaterialInstanceDynamic* pMaterialInsDy = nullptr;
// Flag of is auto page.
bool isAutoPage = false;
// Parse all dicom files to a series data
void ParseDicomFilesToSeriesData(TArray<FString>& dicomFilePaths, FString dirPath, USeriesData* pSeriesDicomData);
// Update Volume texture
UVolumeTexture* UpdateVolumeTexture(USeriesData* pSeriesDicomData, UVolumeTexture* pTargetVolumeTexture);
// Set current page number
void SetCurrentPageNumber(uint16 pageNumber, USeriesData* pSeriesDicomData);
// Create or update material instance dynamic and it's parameters.
void UpdateMaterialInstanceDynamic();
// Check is 16 bit
bool Is16Bit(USeriesData*& pSeriesDicomData);
protected:
// Called when the game starts
virtual void BeginPlay() override;
// Get all dicom files in directory.
TArray<FString> GetFilesInFolder(FString directory, FString extension);
// Get dicom pixel data type
EDicomDataType GetDicomPixelDataType(DcmDataset*& pDcmDataset);
public:
// Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
};
(2)VolumeDataLoader.cpp
cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "VolumeDataLoader.h"
#include <Kismet/KismetSystemLibrary.h>
#include "HAL/FileManagerGeneric.h"
// Sets default values for this component's properties
UVolumeDataLoader::UVolumeDataLoader()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = true;
// Create sub plane mesh for target layout
pPlaneMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("TargetPlaneMesh"));
static ConstructorHelpers::FObjectFinder<UStaticMesh> planeAsset(TEXT("/Engine/BasicShapes/Plane.Plane"));
if (planeAsset.Succeeded())
{
pPlaneMesh->SetStaticMesh(planeAsset.Object);
}
}
void UVolumeDataLoader::BeginDestroy()
{
if (pSeriesData != nullptr)
{
pSeriesData->RemoveFromRoot();
pSeriesData->ClearDicomData();
pSeriesData = nullptr;
}
if (pVolumeTexture != nullptr)
{
pVolumeTexture->RemoveFromRoot();
pVolumeTexture = nullptr;
}
if (pPlaneMesh)
{
pPlaneMesh->SetMaterial(0, nullptr);
}
if (pMaterialInsDy)
{
pMaterialInsDy->ConditionalBeginDestroy();
pMaterialInsDy = nullptr;
}
Super::BeginDestroy();
}
void UVolumeDataLoader::LoadVolumeDicom()
{
FString dicomDir = UKismetSystemLibrary::GetProjectDirectory() + TargetDirPath;
if (!FPaths::DirectoryExists(dicomDir))
{
UE_LOG(LogTemp, Error, TEXT("dicom dir path is not exist, please check!"));
return;
}
// Load dicom data and parse in other thread process
AsyncTask(ENamedThreads::AnyThread, [=, this]() {
//
TArray<FString> dicomFilePaths = GetFilesInFolder(dicomDir, FString("*.dcm"));
if (pSeriesData == nullptr)
{
pSeriesData = NewObject<USeriesData>();
// Avoid gc auto collect and destroy.
pSeriesData->AddToRoot();
}
else pSeriesData->ClearDicomData();
// Assume all dicom file in the this dir with same series uid.
// ToDo: Use series uid and other tag to spilt dicom files into different series data.
ParseDicomFilesToSeriesData(dicomFilePaths, dicomDir, pSeriesData);
// Set property
this->StudyUid = pSeriesData->studyUid;
this->SeriesUid = pSeriesData->seriesUid;
this->WindowCenter = pSeriesData->windowCenter;
this->WindowWidth = pSeriesData->windowWidth;
this->Slope = pSeriesData->slope;
this->Intercept = pSeriesData->intercept;
this->Range = pSeriesData->dataRange;
this->Width = pSeriesData->width;
this->Height = pSeriesData->height;
this->SliceNum = pSeriesData->count;
this->Depth = Is16Bit(pSeriesData) ? 16 : 8;
pVolumeTexture = UpdateVolumeTexture(pSeriesData, pVolumeTexture);
UpdateMaterialInstanceDynamic();
if (pPlaneMesh)
{
pPlaneMesh->SetMaterial(0, pMaterialInsDy);
}
});
}
void UVolumeDataLoader::BeginAutoPage()
{
this->isAutoPage = true;
UE_LOG(LogTemp, Log, TEXT("Auto page begin!"));
}
void UVolumeDataLoader::EndAutoPage()
{
this->isAutoPage = false;
UE_LOG(LogTemp, Log, TEXT("Auto page end!"));
}
void UVolumeDataLoader::ParseDicomFilesToSeriesData(TArray<FString>& dicomFilePaths, FString dirPath, USeriesData* pSeriesDicomData)
{
// Check dicomFilePath is empty
if (dicomFilePaths.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("the dicom file paths array is empty, please check!"));
return;
}
UE_LOG(LogTemp, Log, TEXT("ParseDicomFilesToSeriesData begin!"));
DcmFileFormat fileFormat;
// Set series data study uid by the first dicom data
FString firstPath = FPaths::Combine(dirPath, dicomFilePaths[0]);
if (fileFormat.loadFile(TCHAR_TO_ANSI(*firstPath)).good())
{
DcmDataset* dataset = fileFormat.getDataset();
pSeriesDicomData->dataType = GetDicomPixelDataType(dataset);
pSeriesDicomData->dataRange = Is16Bit(pSeriesDicomData) ? 65535 : 255;
pSeriesDicomData->count = dicomFilePaths.Num();
OFString ofStudyUid;
dataset->findAndGetOFString(DCM_StudyInstanceUID, ofStudyUid);
pSeriesDicomData->studyUid = ofStudyUid.c_str();
OFString ofSeriesUid;
dataset->findAndGetOFString(DCM_SeriesInstanceUID, ofSeriesUid);
pSeriesDicomData->seriesUid = ofSeriesUid.c_str();
Float64 tagValue;
dataset->findAndGetFloat64(DCM_WindowWidth, tagValue);
pSeriesDicomData->windowWidth = tagValue;
dataset->findAndGetFloat64(DCM_WindowCenter, tagValue);
pSeriesDicomData->windowCenter = tagValue;
dataset->findAndGetFloat64(DCM_RescaleSlope, tagValue);
pSeriesDicomData->slope = tagValue;
dataset->findAndGetFloat64(DCM_RescaleIntercept, tagValue);
pSeriesDicomData->intercept = tagValue;
dataset->findAndGetUint16(DCM_Columns, pSeriesDicomData->width);
dataset->findAndGetUint16(DCM_Rows, pSeriesDicomData->height);
dataset->clear();
fileFormat.clear();
}
for (int i = 0; i < dicomFilePaths.Num(); i++)
{
FString path = dicomFilePaths[i];
FString dicomFilePath = FPaths::Combine(dirPath, path);
// Use dcmtk lib to parse dicom file
if (fileFormat.loadFile(TCHAR_TO_ANSI(*dicomFilePath)).good())
{
TSharedPtr<FDicomData> dicomData = MakeShareable(new FDicomData());
DcmDataset* dataset = fileFormat.getDataset();
dicomData->dataType = GetDicomPixelDataType(dataset);
// Instance number tag may US or IS type, current use IS type.
if (!dataset->findAndGetUint16(DCM_InstanceNumber, dicomData->instanceNumber).good())
{
OFString ofInstanceNumber;
dataset->findAndGetOFString(DCM_InstanceNumber, ofInstanceNumber);
dicomData->instanceNumber = OFStandard::atof(ofInstanceNumber.c_str());
}
OFString ofStudyUid;
dataset->findAndGetOFString(DCM_StudyInstanceUID, ofStudyUid);
dicomData->studyUid = ofStudyUid.c_str();
OFString ofSeriesUid;
dataset->findAndGetOFString(DCM_SeriesInstanceUID, ofSeriesUid);
dicomData->seriesUid = ofSeriesUid.c_str();
uint8* pixelData;
DcmElement* pixelDataElement;
dataset->findAndGetElement(DCM_PixelData, pixelDataElement);
dicomData->length = pixelDataElement->getLength();
pixelDataElement->getUint8Array(pixelData);
dicomData->pixelData = new uint8[dicomData->length];
memmove(dicomData->pixelData, pixelData, dicomData->length);
dataset->findAndGetUint16(DCM_Columns, dicomData->width);
dataset->findAndGetUint16(DCM_Rows, dicomData->height);
pSeriesDicomData->AddDicomData(dicomData);
fileFormat.clear();
dataset->clear();
UE_LOG(LogTemp, Log, TEXT("ParseDicomFilesToSeriesData, currentNum: %d, totalNum: %d"), i + 1, dicomFilePaths.Num());
}
}
UE_LOG(LogTemp, Log, TEXT("ParseDicomFilesToSeriesData end!"));
}
UVolumeTexture* UVolumeDataLoader::UpdateVolumeTexture(USeriesData* pSeriesDicomData, UVolumeTexture* pTargetVolumeTexture)
{
UE_LOG(LogTemp, Log, TEXT("Update volume texture begin!"));
if (pSeriesDicomData == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("the input USeriesData is nullptr, please check!"));
return pTargetVolumeTexture;
}
// Check create or update volume texture.
if (pTargetVolumeTexture == nullptr)
{
// ToDo: current just support unsigned r16 format, support the other format in feature
EPixelFormat pixelFormat = Is16Bit(pSeriesDicomData) ? EPixelFormat::PF_G16 : EPixelFormat::PF_R8;
pTargetVolumeTexture = UVolumeTexture::CreateTransient(pSeriesDicomData->width, pSeriesDicomData->height, pSeriesDicomData->count, pixelFormat);
pTargetVolumeTexture->AddToRoot();
pTargetVolumeTexture->MipGenSettings = TMGS_NoMipmaps;
pTargetVolumeTexture->CompressionSettings = TC_Grayscale;
// srgb may not effect for 16 bit
pTargetVolumeTexture->SRGB = true;
pTargetVolumeTexture->NeverStream = true;
pTargetVolumeTexture->Filter = TextureFilter::TF_Nearest;
pTargetVolumeTexture->AddressMode = TextureAddress::TA_Clamp;
UE_LOG(LogTemp, Log, TEXT("Create volume texture successful!"));
}
// Update volume texture pixel data
uint8* pixelData = static_cast<uint8*>(pTargetVolumeTexture->GetPlatformData()->Mips[0].BulkData.Lock(LOCK_READ_WRITE));
auto dicomDataMap = pSeriesDicomData->GetAllDicomData();
for (int i = 1; i <= dicomDataMap.Num(); i++)
{
if (!dicomDataMap.Contains(i))
{
UE_LOG(LogTemp, Warning, TEXT("InstanceNumber of %d is not exist!"), i);
return pTargetVolumeTexture;
}
auto itemDicomData = dicomDataMap[i].Get();
uint8* targetData = pixelData + (i - 1) * itemDicomData->length;
FMemory::Memcpy(targetData, itemDicomData->pixelData, itemDicomData->length);
}
pTargetVolumeTexture->GetPlatformData()->Mips[0].BulkData.Unlock();
// Update Resource must in game thread
AsyncTask(ENamedThreads::GameThread, [=, this]() {
pTargetVolumeTexture->UpdateResource();
});
UE_LOG(LogTemp, Log, TEXT("Update volume texture end!"));
return pTargetVolumeTexture;
}
void UVolumeDataLoader::SetCurrentPageNumber(uint16 pageNumber, USeriesData* pSeriesDicomData)
{
// Check input page number is valid
if (pageNumber < 0 || pageNumber >= pSeriesDicomData->count)
{
UE_LOG(LogTemp, Error, TEXT("Current input number is not valid!"));
return;
}
float calPagrNumber = (pageNumber + 0.5) / pSeriesDicomData->count;
// Set material parameter
pMaterialInsDy->SetScalarParameterValue(FName("PageNum"), calPagrNumber);
if (pPlaneMesh)
{
pPlaneMesh->SetMaterial(0, pMaterialInsDy);
}
}
void UVolumeDataLoader::UpdateMaterialInstanceDynamic()
{
if (pMaterial == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("The target volume material is nullptr!"));
return;
}
if (pMaterialInsDy == nullptr)
{
pMaterialInsDy = UMaterialInstanceDynamic::Create(pMaterial, nullptr);
}
// Update material parameters in game thread.
AsyncTask(ENamedThreads::GameThread, [=, this]() {
pMaterialInsDy->SetTextureParameterValue(FName("VolumeTex"), pVolumeTexture);
// inverset tranform window center and width by slope and intercept;
FFloat16 transWL = (this->WindowCenter - this->Intercept) / this->Slope * 1 / 65535.0;
FFloat16 transWW = (this->WindowWidth) / this->Slope * 1 / 65535.0;
pMaterialInsDy->SetScalarParameterValue(FName("WindowCenter"), transWL);
pMaterialInsDy->SetScalarParameterValue(FName("WindowWidth"), transWW);
pMaterialInsDy->SetScalarParameterValue(FName("DataRange"), 65535.0);
SetCurrentPageNumber(this->CurrentPageNum, pSeriesData);
});
}
bool UVolumeDataLoader::Is16Bit(USeriesData*& pSeriesDicomData)
{
return pSeriesDicomData->dataType == E_UShort || pSeriesDicomData->dataType == E_Short;
}
// Called when the game starts
void UVolumeDataLoader::BeginPlay()
{
Super::BeginPlay();
// ...
}
TArray<FString> UVolumeDataLoader::GetFilesInFolder(FString directory, FString extension)
{
TArray<FString> resFilePaths;
resFilePaths.Empty();
if (FPaths::DirectoryExists(directory))
{
FFileManagerGeneric::Get().FindFiles(resFilePaths, *directory, *extension);
}
return resFilePaths;
}
EDicomDataType UVolumeDataLoader::GetDicomPixelDataType(DcmDataset*& pDcmDataset)
{
EDicomDataType eDataType = EDicomDataType::E_UShort;
Uint16 bitsAllocated;
pDcmDataset->findAndGetUint16(DCM_BitsAllocated, bitsAllocated);
Uint8 pixelRepresentation = 0;
pDcmDataset->findAndGetUint8(DCM_PixelRepresentation, pixelRepresentation);
if (bitsAllocated == 8) eDataType = pixelRepresentation == 1 ? EDicomDataType::E_SByte : EDicomDataType::E_Byte;
else eDataType = pixelRepresentation == 1 ? EDicomDataType::E_Short : EDicomDataType::E_UShort;
return eDataType;
}
// Called every frame
void UVolumeDataLoader::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// Update page
if (this->isAutoPage && pSeriesData != nullptr)
{
int pageNum = (this->CurrentPageNum + 1) >= pSeriesData->count ? 0 : (this->CurrentPageNum + 1);
this->CurrentPageNum = pageNum;
SetCurrentPageNumber(pageNum, pSeriesData);
}
}
USeriesData::USeriesData()
{
dicomDataMap = TMap<int, TSharedPtr<FDicomData>>();
}
void USeriesData::AddDicomData(TSharedPtr<FDicomData> dicomData)
{
FScopeLock Lock(&dicomDataMapLock);
dicomDataMap.Emplace(dicomData->instanceNumber, dicomData);
}
TMap<int, TSharedPtr<FDicomData>> USeriesData::GetAllDicomData()
{
FScopeLock Lock(&dicomDataMapLock);
return dicomDataMap;
}
void USeriesData::ClearDicomData()
{
FScopeLock Lock(&dicomDataMapLock);
for (auto& ele : dicomDataMap)
{
delete ele.Value.Get();
ele.Value.Reset();
}
dicomDataMap.Empty();
}
2.3 功能实现
2.3.1 材质
(1)创建Surface 材质,用于最终在StaticMeshComponent (本文这里使用的是UE自带的PlaneMesh
)上显示Dicom
其中PageNum
参数表示当前从VolumeTexture 中采样的Z坐标,范围是[0, 1]
WindowWidth
和WindowCenter
表示当前的窗宽窗位,DataRange
参数表示当前数据的范围,如果是16位 ,则是65535 ,否则8位 是255

(2)VOI兴趣区变换,和上篇文章中的处理方式相同,这里不赘述

2.3.2 具体过程
(1)在场景中的Actor(可以随便找一个空的Actor)下添加VolumeDataLoader
组件

(2)测试数据,共272张Dicom数据,放在项目工程的Data文件夹下

(3)配置Dicom数据存放的文件夹的相对路径以及材质

(4)运行游戏后,点击LoadVolumeDicom
按钮,即会加载和解析Dicom数据,并生成VolumeTexture
,并默认使用首张Dicom
数据进行展示

显示首张Dicom时的截图:

(5)自动翻页的开始与暂停
