Update HzCulling

This commit is contained in:
2025-02-22 03:04:15 +09:00
parent c25ff0dd61
commit f0dbf8e581
8 changed files with 277 additions and 100 deletions

View File

@@ -1,4 +1,5 @@
using Misaki.AoVolume; using Misaki.AoVolume;
using System.Buffers;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Unity.Collections; using Unity.Collections;
using UnityEngine; using UnityEngine;
@@ -10,12 +11,27 @@ internal class AoVolumePass : CustomPass
{ {
private bool _initialized; private bool _initialized;
private NativeArray<VolumeData> _aoVolumeDatas; private NativeArray<OrientedBoundingBox> _volumeBounds;
private ComputeBuffer _aoVolumeDataBuffer; private ComputeBuffer _volumeBoundsBuffer;
private RTHandle _aoVolumeBuffer; private ComputeBuffer _visibleIndicesBuffer;
private ComputeBuffer _visibleVolumeCountBuffer;
private ComputeBuffer _volumeDataBuffer;
private RTHandle _volumeBuffer;
[ResourcePath("Packages/com.misaki.ao-volume/Runtime/Shader/HzCulling.compute")]
public ComputeShader cullingShader;
[ResourcePath("Packages/com.misaki.ao-volume/Runtime/Shader/AoVolume.compute")] [ResourcePath("Packages/com.misaki.ao-volume/Runtime/Shader/AoVolume.compute")]
public ComputeShader shader; public ComputeShader renderingShader;
private void ClearVisibleVolumeCounter()
{
var zeroBuffer = new NativeArray<uint>(1, Allocator.Temp);
zeroBuffer[0] = 0;
_visibleVolumeCountBuffer.SetData(zeroBuffer);
zeroBuffer.Dispose();
}
// It can be used to configure render targets and their clear state. Also to create temporary render target textures. // It can be used to configure render targets and their clear state. Also to create temporary render target textures.
// When empty this render pass will render to the active camera render target. // When empty this render pass will render to the active camera render target.
@@ -23,9 +39,21 @@ internal class AoVolumePass : CustomPass
// The render pipeline will ensure target setup and clearing happens in an performance manner. // The render pipeline will ensure target setup and clearing happens in an performance manner.
protected override void Setup(ScriptableRenderContext renderContext, CommandBuffer cmd) protected override void Setup(ScriptableRenderContext renderContext, CommandBuffer cmd)
{ {
_aoVolumeDatas = new NativeArray<VolumeData>(64, Allocator.Persistent); if (_initialized)
_aoVolumeDataBuffer = new ComputeBuffer(64, Marshal.SizeOf<VolumeData>()); {
_aoVolumeBuffer = RTHandles.Alloc(Vector2.one, useDynamicScale: true, dimension: TextureXR.dimension, enableRandomWrite: true, format: GraphicsFormat.R8_UNorm, name: "AO Volume Buffer"); return;
}
_volumeBounds = new NativeArray<OrientedBoundingBox>(64, Allocator.Persistent);
_volumeBoundsBuffer = new ComputeBuffer(64, Marshal.SizeOf<OrientedBoundingBox>());
_visibleIndicesBuffer = new ComputeBuffer(64, Marshal.SizeOf<uint>(), ComputeBufferType.Raw);
_visibleVolumeCountBuffer = new ComputeBuffer(1, Marshal.SizeOf<uint>(), ComputeBufferType.Counter);
_volumeDataBuffer = new ComputeBuffer(64, Marshal.SizeOf<VolumeData>());
_volumeBuffer = RTHandles.Alloc(Vector2.one, useDynamicScale: true, dimension: TextureXR.dimension, enableRandomWrite: true, format: GraphicsFormat.R8_UNorm, name: "AO Volume Buffer");
ClearVisibleVolumeCounter();
_initialized = true; _initialized = true;
} }
@@ -37,49 +65,88 @@ internal class AoVolumePass : CustomPass
return; return;
} }
if (shader == null) if (cullingShader == null || renderingShader == null)
{ {
return; return;
} }
var volumeCount = VolumeDatabase.Instance.volumeObjects.Count;
if (volumeCount <= 0)
{
return;
}
for (var i = 0; i < volumeCount; i++)
{
_aoVolumeDatas[i] = VolumeDatabase.Instance.volumeObjects[i].data;
}
_aoVolumeDataBuffer.SetData(_aoVolumeDatas);
// Worth it to allocate a new buffer? // Worth it to allocate a new buffer?
//if (Shader.GetGlobalTexture("_AmbientOcclusionTexture") is not RenderTexture gtaoBuffer) //if (Shader.GetGlobalTexture("_AmbientOcclusionTexture") is not RenderTexture gtaoBuffer)
//{ //{
// return; // return;
//} //}
var kernelIndex = shader.FindKernel("CSMain"); var volumeCount = VolumeDatabase.Instance.EntityCount;
ctx.cmd.SetComputeBufferParam(shader, kernelIndex, "_VolumeDatas", _aoVolumeDataBuffer); if (volumeCount <= 0)
ctx.cmd.SetComputeIntParam(shader, "_VolumeCount", volumeCount); {
ctx.cmd.SetComputeTextureParam(shader, kernelIndex, "_AOVolumeBuffer", _aoVolumeBuffer); return;
}
const int groupSizeX = 8; const int groupSizeX = 8;
const int groupSizeY = 8; const int groupSizeY = 8;
var threadGroupX = (ctx.hdCamera.actualWidth + (groupSizeX - 1)) / groupSizeX; var threadGroupX = (ctx.hdCamera.actualWidth + (groupSizeX - 1)) / groupSizeX;
var threadGroupY = (ctx.hdCamera.actualHeight + (groupSizeY - 1)) / groupSizeY; var threadGroupY = (ctx.hdCamera.actualHeight + (groupSizeY - 1)) / groupSizeY;
ctx.cmd.DispatchCompute(shader, kernelIndex, threadGroupX, threadGroupY, _aoVolumeBuffer.rt.volumeDepth); for (var i = 0; i < volumeCount; i++)
{
_volumeBounds[i] = new OrientedBoundingBox(VolumeDatabase.Instance.VolumeDatas[i].worldMatrix);
}
_volumeBoundsBuffer.SetData(_volumeBounds);
ctx.cmd.SetGlobalTexture("_AmbientOcclusionTexture", _aoVolumeBuffer); ctx.cmd.SetComputeBufferParam(cullingShader, 0, "_VolumeBounds", _volumeBoundsBuffer);
ctx.cmd.SetComputeIntParam(cullingShader, "_FullVolumeCount", volumeCount);
ctx.cmd.SetComputeIntParam(cullingShader, "_DepthPyramidMaxMip", ctx.cameraDepthBuffer.rt.mipmapCount - 1);
ctx.cmd.SetComputeBufferParam(cullingShader, 0, "_VisibleVolumeIndices", _visibleIndicesBuffer);
ctx.cmd.SetComputeBufferParam(cullingShader, 0, "_VisibleVolumeCount", _visibleVolumeCountBuffer);
const int groupSize = 64;
var threadGroup = (volumeCount + (groupSize - 1)) / groupSize;
ctx.cmd.DispatchCompute(cullingShader, 0, threadGroup, 1, 1);
// Debug
var visibleVolumeCount = new int[1];
_visibleVolumeCountBuffer.GetData(visibleVolumeCount);
Debug.Log($"Visible Volume Count: {visibleVolumeCount[0]}");
var visibleVolumeIndices = ArrayPool<uint>.Shared.Rent(64);
_visibleIndicesBuffer.GetData(visibleVolumeIndices);
for (var i = 0; i < visibleVolumeCount[0]; i++)
{
if (i >= 64)
{
break;
}
Debug.Log($"Visible Volume Index: {visibleVolumeIndices[i]}");
}
Debug.Log("End");
ArrayPool<uint>.Shared.Return(visibleVolumeIndices);
ClearVisibleVolumeCounter();
//return;
//_volumeDataBuffer.SetData(VolumeDatabase.Instance.VolumeDatas);
//ctx.cmd.SetComputeBufferParam(renderingShader, 0, "_VolumeDatas", _volumeDataBuffer);
//ctx.cmd.SetComputeIntParam(renderingShader, "_VolumeCount", volumeCount);
//ctx.cmd.SetComputeTextureParam(renderingShader, 0, "_AOVolumeBuffer", _volumeBuffer);
//ctx.cmd.DispatchCompute(renderingShader, 0, threadGroupX, threadGroupY, _volumeBuffer.rt.volumeDepth);
//ctx.cmd.SetGlobalTexture("_AmbientOcclusionTexture", _volumeBuffer);
} }
protected override void Cleanup() protected override void Cleanup()
{ {
_aoVolumeBuffer?.Release(); _volumeBounds.Dispose();
_aoVolumeDatas.Dispose(); _volumeBoundsBuffer?.Dispose();
_aoVolumeDataBuffer?.Release(); _visibleIndicesBuffer?.Dispose();
_visibleVolumeCountBuffer?.Dispose();
_volumeDataBuffer?.Dispose();
_volumeBuffer?.Release();
_initialized = false; _initialized = false;
} }

View File

@@ -9,7 +9,7 @@
], ],
"includePlatforms": [], "includePlatforms": [],
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false, "allowUnsafeCode": true,
"overrideReferences": false, "overrideReferences": false,
"precompiledReferences": [], "precompiledReferences": [],
"autoReferenced": true, "autoReferenced": true,

View File

@@ -5,34 +5,43 @@ namespace Misaki.AoVolume
[ExecuteInEditMode] [ExecuteInEditMode]
internal class AoVolume : MonoBehaviour internal class AoVolume : MonoBehaviour
{ {
private VolumeEntity _entity = VolumeEntity.InvalidEntity; private VolumeEntity _entity = VolumeEntity.Invalid;
public bool dynamicVolume;
public VolumeData data; public VolumeData data;
private void InitializeEntity() private void UpdateMatrixData()
{
data.worldMatrix = transform.localToWorldMatrix;
data.inverseWorldMatrix = transform.worldToLocalMatrix;
}
private void OnEnable()
{ {
if (_entity.IsValid) if (_entity.IsValid)
{ {
return; return;
} }
_entity = VolumeDatabase.Instance.CreateEntity(this); UpdateMatrixData();
} _entity = VolumeDatabase.Instance.CreateEntity(data);
private void OnEnable()
{
InitializeEntity();
} }
private void OnDisable() private void OnDisable()
{ {
VolumeDatabase.Instance.DestroyEntity(_entity); VolumeDatabase.Instance.DestroyEntity(ref _entity);
} }
private void Update() private void Update()
{ {
data.worldMatrix = transform.localToWorldMatrix; if (dynamicVolume && transform.hasChanged)
data.inverseWorldMatrix = data.worldMatrix.inverse; {
UpdateMatrixData();
ref var oldData = ref VolumeDatabase.Instance.GetDataRef(_entity);
oldData = data;
transform.hasChanged = false;
}
} }
private void OnDrawGizmos() private void OnDrawGizmos()

View File

@@ -7,11 +7,28 @@ namespace Misaki.AoVolume
public readonly int entityIndex; public readonly int entityIndex;
public readonly bool IsValid => entityIndex != _INVALID_INDEX; public readonly bool IsValid => entityIndex != _INVALID_INDEX;
public static readonly VolumeEntity InvalidEntity = new(_INVALID_INDEX); public static readonly VolumeEntity Invalid = new(_INVALID_INDEX);
public VolumeEntity(int index) public VolumeEntity(int index)
{ {
entityIndex = index; this.entityIndex = index;
}
}
// Intermediate struct which holds the data index of an entity and other information.
internal readonly struct VolumeEntityInfo
{
private const int _INVALID_INDEX = -1;
public readonly int dataIndex;
public static readonly VolumeEntityInfo Invalid = new VolumeEntityInfo(_INVALID_INDEX);
public bool IsValid => dataIndex != _INVALID_INDEX;
public VolumeEntityInfo(int dataIndex)
{
this.dataIndex = dataIndex;
} }
} }
} }

View File

@@ -5,11 +5,12 @@
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl" #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"
#include "Packages/com.misaki.ao-volume/Runtime/Shader/Includes/GeometryData.cs.hlsl" #include "Packages/com.misaki.ao-volume/Runtime/Shader/Includes/GeometryData.cs.hlsl"
#define FLT_MIN 1.175494351e-38 // Minimum representable positive floating-point number #define FLT_MIN 1.175494351e-38 // Minimum representable positive floating-point number
#define FLT_MAX 3.402823466e+38 // Maximum representable floating-point number #define FLT_MAX 3.402823466e+38 // Maximum representable floating-point number
StructuredBuffer<OrientedBoundingBox> _VolumeBounds; StructuredBuffer<OrientedBoundingBox> _VolumeBounds;
StructuredBuffer<CullingData> _CullingData; StructuredBuffer<int2> _DepthPyramidMipLevelOffsets;
uint _FullVolumeCount; uint _FullVolumeCount;
uint _DepthPyramidMaxMip; uint _DepthPyramidMaxMip;
@@ -17,16 +18,34 @@ uint _DepthPyramidMaxMip;
RWByteAddressBuffer _VisibleVolumeIndices : register(u0); RWByteAddressBuffer _VisibleVolumeIndices : register(u0);
RWByteAddressBuffer _VisibleVolumeCount : register(u1); RWByteAddressBuffer _VisibleVolumeCount : register(u1);
RW_TEXTURE2D_X(float, _DebugTexture);
float4 ComputeScreenPos(float4 pos, float projectionSign)
{
float4 o = pos * 0.5f;
o.xy = float2(o.x, o.y * projectionSign) + o.w;
o.zw = pos.zw;
return o;
}
float SampleDepthLod(int2 uv, int lod)
{
int2 mipCoord = uv >> lod;
int2 mipOffset = _DepthPyramidMipLevelOffsets[lod];
float deviceDepth = LOAD_TEXTURE2D_X(_CameraDepthTexture, mipOffset + mipCoord).r;
return deviceDepth;
}
[numthreads(64,1,1)] [numthreads(64,1,1)]
void CSMain(uint3 dispatchThreadId : SV_DispatchThreadID) void CSMain(uint3 dispatchThreadId : SV_DispatchThreadID)
{ {
uint i = dispatchThreadId.x; if (dispatchThreadId.x >= _FullVolumeCount)
if (i >= _FullVolumeCount)
{ {
return; // early exit if outside our range return; // early exit if outside our range
} }
OrientedBoundingBox box = _VolumeBounds[i]; OrientedBoundingBox box = _VolumeBounds[dispatchThreadId.x];
// Compute the 8 corners of the OBB. // Compute the 8 corners of the OBB.
// The box is defined by its center, two axes (right & up) and its extents. // The box is defined by its center, two axes (right & up) and its extents.
@@ -49,50 +68,44 @@ void CSMain(uint3 dispatchThreadId : SV_DispatchThreadID)
// Compute screen-space bounding rectangle and find the minimum depth (closest point) // Compute screen-space bounding rectangle and find the minimum depth (closest point)
float2 screenMin = float2(FLT_MAX, FLT_MAX); float2 screenMin = float2(FLT_MAX, FLT_MAX);
float2 screenMax = float2(-FLT_MAX, -FLT_MAX); float2 screenMax = float2(-FLT_MAX, -FLT_MAX);
float boxMinDepth = 1.0f; float boxMaxDepth = -FLT_MAX;
[unroll] [unroll]
for (int j = 0; j < 8; ++j) for (int j = 0; j < 8; ++j)
{ {
// Transform to clip space float3 cornerRWS = GetCameraRelativePositionWS(corners[j]);
float4 clipPos = mul(UNITY_MATRIX_VP, float4(corners[j], 1.0)); float4 positionCS = TransformWorldToHClip(cornerRWS);
clipPos /= clipPos.w; // Perspective divide (now in NDC) positionCS /= positionCS.w;
float2 ndc = clipPos.xy * 0.5 + 0.5; float2 screenPos = ComputeScreenPos(positionCS, _ProjectionParams.x).xy * _ScreenSize.xy;
float2 screenPos = ndc * _ScreenSize.xy;
screenMin = min(screenMin, screenPos); screenMin = min(screenMin, screenPos);
screenMax = max(screenMax, screenPos); screenMax = max(screenMax, screenPos);
boxMinDepth = min(boxMinDepth, clipPos.z); // clipPos.z in [0,1] boxMaxDepth = max(boxMaxDepth, positionCS.z);
} }
// Compute the UV bounding rectangle from screen-space coordinates.
float2 uvMin = screenMin / _ScreenSize.xy;
float2 uvMax = screenMax / _ScreenSize.xy;
// For HZ culling we need to sample the proper mip level. // For HZ culling we need to sample the proper mip level.
// We estimate the rectangle size in pixels. // We estimate the rectangle size in pixels.
float rectWidth = (uvMax.x - uvMin.x) * _ScreenSize.x; float rectWidth = (screenMax.x - screenMin.x);
float rectHeight = (uvMax.y - uvMin.y) * _ScreenSize.y; float rectHeight = (screenMax.y - screenMin.y);
float rectSize = max(rectWidth, rectHeight); float rectSize = max(rectWidth, rectHeight);
int mipLevel = (int)clamp(floor(log2(rectSize)), 0.0, (float)_DepthPyramidMaxMip); int mipLevel = (int)clamp(floor(log2(rectSize)), 0.0, (float)_DepthPyramidMaxMip);
// Sample the hierarchical depth texture. // Sample the hierarchical depth texture.
// Here we simply sample at the center of the rectangle. // Here we simply sample at the center of the rectangle.
// TODO: Use a more sophisticated method to sample the depth pyramid. // TODO: Use a more sophisticated method to sample the depth pyramid.
float2 uvCenter = (uvMin + uvMax) * 0.5; int2 uvCenter = (screenMin + screenMax) * 0.5;
float occluderDepth = SAMPLE_TEXTURE2D_X_LOD(_CameraDepthTexture, s_linear_clamp_sampler, uvCenter, mipLevel).r; float occluderDepth = SampleDepthLod(uvCenter, mipLevel);
// Perform the occlusion test: // Perform the occlusion test:
// If the closest point of the box (boxMinDepth) is behind the occluder, // If the closest point of the box (boxMaxDepth) is behind the occluder,
// then the box is completely occluded. // then the box is completely occluded.
if (boxMinDepth <= occluderDepth) // TODO: pack 16 bits index to save memory
if (occluderDepth <= boxMaxDepth)
{ {
uint index; uint index;
_VisibleVolumeCount.InterlockedAdd(0, 2, index); // 2 bytes per index _VisibleVolumeCount.InterlockedAdd(0, 1, index);
_VisibleVolumeIndices.Store(index << 2, dispatchThreadId.x);
// Store the visible index as uint16_t (lower 16 bits of uint)
_VisibleVolumeIndices.Store(index, i & 0xFFFF);
} }
} }

View File

@@ -9,13 +9,21 @@ namespace Misaki.AoVolume
internal struct VolumeData internal struct VolumeData
{ {
[HideInInspector] [HideInInspector]
[NonSerialized]
public Matrix4x4 worldMatrix; public Matrix4x4 worldMatrix;
[HideInInspector] [HideInInspector]
[NonSerialized]
public Matrix4x4 inverseWorldMatrix; public Matrix4x4 inverseWorldMatrix;
public Vector3 size; public Vector3 size;
public float intensity; public float intensity;
public float falloff; public float falloff;
public float normalFalloff; public float normalFalloff;
} }
internal unsafe static class VolumeDataPtr
{
public static VolumeData* Zero => (VolumeData*)0;
}
} }

View File

@@ -1,68 +1,131 @@
using System; using System;
using System.Collections.Generic;
using Unity.Collections; using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine; using UnityEngine;
using UnityEngine.Rendering;
namespace Misaki.AoVolume namespace Misaki.AoVolume
{ {
internal class VolumeDatabase : IDisposable internal class VolumeDatabase : IDisposable
{ {
private const int _INITIAL_CAPACITY = 32; private const int _INITIAL_CAPACITY = 16;
private static VolumeDatabase _instance; private static VolumeDatabase _instance;
public static VolumeDatabase Instance => _instance ??= new VolumeDatabase(); public static VolumeDatabase Instance => _instance ??= new VolumeDatabase();
private bool _disposed; private NativeList<VolumeEntityInfo> _entitiesInfo = new(_INITIAL_CAPACITY, Allocator.Persistent);
private NativeArray<VolumeEntity> _entities = new(_INITIAL_CAPACITY, Allocator.Persistent);
private NativeQueue<int> _freeIndices = new(Allocator.Persistent);
// TODO: Use a native array to store the actual VolumeData and use ptr to modify values when necessary. private int _capacity = _INITIAL_CAPACITY;
// Storing the actual values instead of the reference to the object will significantly enhance performance during each frame's rendering process.
// Since AO volumes are mostly static, the need for ref access is minimal.
public readonly List<AoVolume> volumeObjects = new();
public VolumeDatabase() private int _entityCount;
{ public int EntityCount => _entityCount;
}
private NativeArray<VolumeData> _volumeDatas = new(_INITIAL_CAPACITY, Allocator.Persistent);
public NativeArray<VolumeData> VolumeDatas => _volumeDatas;
~VolumeDatabase() ~VolumeDatabase()
{ {
Dispose(); Dispose();
} }
public VolumeEntity CreateEntity(AoVolume data) public unsafe VolumeData* GetDataPtr(VolumeEntity entity)
{ {
var newIndex = volumeObjects.Count; if (!entity.IsValid)
var entity = new VolumeEntity(newIndex);
if (volumeObjects.Capacity <= newIndex)
{ {
volumeObjects.Capacity = Mathf.Max(Mathf.Max(newIndex * 2, newIndex), _INITIAL_CAPACITY); return VolumeDataPtr.Zero;
} }
volumeObjects.Add(data); return (VolumeData*)_volumeDatas.GetUnsafePtr() + _entitiesInfo[entity.entityIndex].dataIndex;
return entity;
} }
public void DestroyEntity(VolumeEntity entity) public ref VolumeData GetDataRef(VolumeEntity entity)
{ {
if (!entity.IsValid || volumeObjects.Count <= entity.entityIndex) unsafe
{
return ref UnsafeUtility.AsRef<VolumeData>(GetDataPtr(entity));
}
}
private VolumeEntityInfo AllocateNewEntityInfo()
{
if (_entityCount >= _capacity)
{
var newCapacity = _capacity + _capacity / 2;
_volumeDatas.ResizeArray(newCapacity);
_entities.ResizeArray(newCapacity);
_entitiesInfo.Capacity = newCapacity;
_capacity = newCapacity;
}
var newIndex = _entityCount++;
return new VolumeEntityInfo(newIndex);
}
private void RemoveAtSwapBackArrays(int removeIndexAt)
{
var lastIndex = _entityCount - 1;
_volumeDatas[removeIndexAt] = _volumeDatas[lastIndex];
_entities[removeIndexAt] = _entities[lastIndex];
_entityCount--;
}
public VolumeEntity CreateEntity(VolumeData data)
{
var newEntityInfo = AllocateNewEntityInfo();
var newEntity = VolumeEntity.Invalid;
if (_freeIndices.TryDequeue(out var newEntityIndex))
{
newEntity = new VolumeEntity(newEntityIndex);
_entitiesInfo[newEntityIndex] = newEntityInfo;
}
else
{
newEntity = new VolumeEntity(_entitiesInfo.Length);
_entitiesInfo.AddNoResize(newEntityInfo);
}
_entities[newEntityInfo.dataIndex] = newEntity;
_volumeDatas[newEntityInfo.dataIndex] = data;
Debug.Log($"Entity created at index {newEntityInfo.dataIndex}");
return newEntity;
}
public void DestroyEntity(ref VolumeEntity entity)
{
if (!entity.IsValid)
{ {
return; return;
} }
volumeObjects.RemoveAtSwapBack(entity.entityIndex); _freeIndices.Enqueue(entity.entityIndex);
var entityInfo = _entitiesInfo[entity.entityIndex];
RemoveAtSwapBackArrays(entityInfo.dataIndex);
if (_entityCount != 0)
{
var entityToUpdate = _entities[entityInfo.dataIndex];
_entitiesInfo[entityToUpdate.entityIndex] = entityInfo;
}
Debug.Log($"Entity destroyed at index {entityInfo.dataIndex}");
Debug.Log($"Free indices: {_freeIndices.Count}");
entity = VolumeEntity.Invalid;
} }
public void Dispose() public void Dispose()
{ {
if (_disposed) _entitiesInfo.Dispose();
{ _entities.Dispose();
return; _freeIndices.Dequeue();
} _volumeDatas.Dispose();
volumeObjects.Clear();
_disposed = true;
} }
} }
} }