Culling masks improvements:
- Use a 64-bit mask instead of 8-bit - Fix face separation when a custom mesh is used - Automatically generate culling masks using raster approximationmaster
parent
b2f7d23f7f
commit
7aa33267a8
|
@ -218,31 +218,32 @@ void Voxel::set_custom_mesh(Ref<Mesh> mesh) {
|
||||||
ERR_FAIL_COND(normals.size() == 0);
|
ERR_FAIL_COND(normals.size() == 0);
|
||||||
|
|
||||||
struct L {
|
struct L {
|
||||||
static bool get_side(Vector3 pos, Cube::SideAxis &out_side) {
|
static uint8_t get_sides(Vector3 pos) {
|
||||||
if (Math::is_equal_approx(pos.x, 0.0)) {
|
uint8_t mask = 0;
|
||||||
out_side = Cube::SIDE_NEGATIVE_X;
|
const real_t tolerance = 0.001;
|
||||||
|
mask |= Math::is_equal_approx(pos.x, 0.0, tolerance) << Cube::SIDE_NEGATIVE_X;
|
||||||
|
mask |= Math::is_equal_approx(pos.x, 1.0, tolerance) << Cube::SIDE_POSITIVE_X;
|
||||||
|
mask |= Math::is_equal_approx(pos.y, 0.0, tolerance) << Cube::SIDE_NEGATIVE_Y;
|
||||||
|
mask |= Math::is_equal_approx(pos.y, 1.0, tolerance) << Cube::SIDE_POSITIVE_Y;
|
||||||
|
mask |= Math::is_equal_approx(pos.z, 0.0, tolerance) << Cube::SIDE_NEGATIVE_Z;
|
||||||
|
mask |= Math::is_equal_approx(pos.z, 1.0, tolerance) << Cube::SIDE_POSITIVE_Z;
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get_triangle_side(const Vector3 &a, const Vector3 &b, const Vector3 &c, Cube::SideAxis &out_side) {
|
||||||
|
const uint8_t m = get_sides(a) & get_sides(b) & get_sides(c);
|
||||||
|
if (m == 0) {
|
||||||
|
// At least one of the points doesn't belong to a face
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (unsigned int side = 0; side < Cube::SIDE_COUNT; ++side) {
|
||||||
|
if (m == (1 << side)) {
|
||||||
|
// All points belong to the same face
|
||||||
|
out_side = (Cube::SideAxis)side;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (Math::is_equal_approx(pos.x, 1.0)) {
|
|
||||||
out_side = Cube::SIDE_POSITIVE_X;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Math::is_equal_approx(pos.y, 0.0)) {
|
|
||||||
out_side = Cube::SIDE_NEGATIVE_Y;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Math::is_equal_approx(pos.y, 1.0)) {
|
|
||||||
out_side = Cube::SIDE_POSITIVE_Y;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Math::is_equal_approx(pos.z, 0.0)) {
|
|
||||||
out_side = Cube::SIDE_NEGATIVE_Z;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Math::is_equal_approx(pos.z, 1.0)) {
|
|
||||||
out_side = Cube::SIDE_POSITIVE_Z;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
// The triangle isn't in one face
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -265,40 +266,39 @@ void Voxel::set_custom_mesh(Ref<Mesh> mesh) {
|
||||||
|
|
||||||
FixedArray<HashMap<int, int>, Cube::SIDE_COUNT> added_side_indices;
|
FixedArray<HashMap<int, int>, Cube::SIDE_COUNT> added_side_indices;
|
||||||
HashMap<int, int> added_regular_indices;
|
HashMap<int, int> added_regular_indices;
|
||||||
|
FixedArray<Vector3, 3> tri_positions;
|
||||||
|
|
||||||
for (int i = 0; i < indices.size(); i += 3) {
|
for (int i = 0; i < indices.size(); i += 3) {
|
||||||
|
|
||||||
Cube::SideAxis side0;
|
Cube::SideAxis side;
|
||||||
Cube::SideAxis side1;
|
|
||||||
Cube::SideAxis side2;
|
|
||||||
|
|
||||||
if (L::get_side(positions_read[indices_read[i]], side0) &&
|
tri_positions[0] = positions_read[indices_read[i]];
|
||||||
L::get_side(positions_read[indices_read[i + 1]], side1) &&
|
tri_positions[1] = positions_read[indices_read[i + 1]];
|
||||||
L::get_side(positions_read[indices_read[i + 2]], side2) &&
|
tri_positions[2] = positions_read[indices_read[i + 2]];
|
||||||
side0 == side1 &&
|
|
||||||
side1 == side2) {
|
if (L::get_triangle_side(tri_positions[0], tri_positions[1], tri_positions[2], side)) {
|
||||||
|
|
||||||
// That triangle is on the face
|
// That triangle is on the face
|
||||||
|
|
||||||
int next_side_index = _model_side_positions[side0].size();
|
int next_side_index = _model_side_positions[side].size();
|
||||||
|
|
||||||
for (int j = 0; j < 3; ++j) {
|
for (int j = 0; j < 3; ++j) {
|
||||||
int src_index = indices_read[i + j];
|
int src_index = indices_read[i + j];
|
||||||
const int *existing_dst_index = added_side_indices[side0].getptr(src_index);
|
const int *existing_dst_index = added_side_indices[side].getptr(src_index);
|
||||||
|
|
||||||
if (existing_dst_index == nullptr) {
|
if (existing_dst_index == nullptr) {
|
||||||
// Add new vertex
|
// Add new vertex
|
||||||
|
|
||||||
_model_side_indices[side0].push_back(next_side_index);
|
_model_side_indices[side].push_back(next_side_index);
|
||||||
_model_side_positions[side0].push_back(positions_read[indices_read[i + j]]);
|
_model_side_positions[side].push_back(tri_positions[j]);
|
||||||
_model_side_uvs[side0].push_back(uvs_read[indices_read[i + j]]);
|
_model_side_uvs[side].push_back(uvs_read[indices_read[i + j]]);
|
||||||
|
|
||||||
added_side_indices[side0].set(src_index, next_side_index);
|
added_side_indices[side].set(src_index, next_side_index);
|
||||||
++next_side_index;
|
++next_side_index;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Vertex was already added, just add index referencing it
|
// Vertex was already added, just add index referencing it
|
||||||
_model_side_indices[side0].push_back(*existing_dst_index);
|
_model_side_indices[side].push_back(*existing_dst_index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -314,7 +314,7 @@ void Voxel::set_custom_mesh(Ref<Mesh> mesh) {
|
||||||
if (existing_dst_index == nullptr) {
|
if (existing_dst_index == nullptr) {
|
||||||
|
|
||||||
_model_indices.push_back(next_regular_index);
|
_model_indices.push_back(next_regular_index);
|
||||||
_model_positions.push_back(positions_read[indices_read[i + j]]);
|
_model_positions.push_back(tri_positions[j]);
|
||||||
_model_normals.push_back(normals_read[indices_read[i + j]]);
|
_model_normals.push_back(normals_read[indices_read[i + j]]);
|
||||||
_model_uvs.push_back(uvs_read[indices_read[i + j]]);
|
_model_uvs.push_back(uvs_read[indices_read[i + j]]);
|
||||||
|
|
||||||
|
@ -329,10 +329,7 @@ void Voxel::set_custom_mesh(Ref<Mesh> mesh) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Expose side masks in the inspector, somehow
|
generate_side_culling_masks();
|
||||||
for (unsigned int side = 0; side < Cube::SIDE_COUNT; ++side) {
|
|
||||||
_side_culling_masks[side] = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ref<Voxel> Voxel::set_cube_geometry(float sy) {
|
Ref<Voxel> Voxel::set_cube_geometry(float sy) {
|
||||||
|
@ -356,15 +353,13 @@ Ref<Voxel> Voxel::set_cube_geometry(float sy) {
|
||||||
for (unsigned int i = 0; i < 6; ++i) {
|
for (unsigned int i = 0; i < 6; ++i) {
|
||||||
indices[i] = Cube::g_side_quad_triangles[side][i];
|
indices[i] = Cube::g_side_quad_triangles[side][i];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (side != Cube::SIDE_POSITIVE_Y || sy == 1.0) {
|
|
||||||
_side_culling_masks[side] = 0xff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_collision_aabbs.clear();
|
_collision_aabbs.clear();
|
||||||
_collision_aabbs.push_back(AABB(Vector3(0, 0, 0), Vector3(1, 1, 1)));
|
_collision_aabbs.push_back(AABB(Vector3(0, 0, 0), Vector3(1, 1, 1)));
|
||||||
|
|
||||||
|
generate_side_culling_masks();
|
||||||
|
|
||||||
return Ref<Voxel>(this);
|
return Ref<Voxel>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -407,6 +402,105 @@ void Voxel::update_cube_uv_sides() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Voxel::set_side_culling_mask(int side, uint64_t mask) {
|
||||||
|
_side_culling_masks[side] = mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename F>
|
||||||
|
static void rasterize_triangle_barycentric(Vector2 a, Vector2 b, Vector2 c, F output_func) {
|
||||||
|
// Slower than scanline method, but looks better
|
||||||
|
|
||||||
|
// Grow the triangle a tiny bit, to help against floating point error
|
||||||
|
Vector2 m = 0.333333 * (a + b + c);
|
||||||
|
a += 0.001 * (a - m);
|
||||||
|
b += 0.001 * (b - m);
|
||||||
|
c += 0.001 * (c - m);
|
||||||
|
|
||||||
|
int min_x = (int)Math::floor(min(min(a.x, b.x), c.x));
|
||||||
|
int min_y = (int)Math::floor(min(min(a.y, b.y), c.y));
|
||||||
|
int max_x = (int)Math::ceil(max(max(a.x, b.x), c.x));
|
||||||
|
int max_y = (int)Math::ceil(max(max(a.y, b.y), c.y));
|
||||||
|
|
||||||
|
// We test against points centered on grid cells
|
||||||
|
Vector2 offset(0.5, 0.5);
|
||||||
|
|
||||||
|
for (int y = min_y; y < max_y; ++y) {
|
||||||
|
for (int x = min_x; x < max_x; ++x) {
|
||||||
|
if (Geometry::is_point_in_triangle(Vector2(x, y) + offset, a, b, c)) {
|
||||||
|
output_func(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Voxel::generate_side_culling_masks() {
|
||||||
|
|
||||||
|
// When two blocky voxels are next to each other, they share a side.
|
||||||
|
// Geometry of either side can be culled away if covered by the other,
|
||||||
|
// but it's very expensive to do a full polygon check when we build the mesh.
|
||||||
|
// So instead, we compute which sides occlude which for every voxel type,
|
||||||
|
// and generate culling masks ahead of time, using an approximation.
|
||||||
|
// It may have a limitation of the number of different side types,
|
||||||
|
// so it's a tradeoff to take when designing the models.
|
||||||
|
|
||||||
|
for (uint16_t side = 0; side < Cube::SIDE_COUNT; ++side) {
|
||||||
|
const std::vector<Vector3> &positions = get_model_side_positions(side);
|
||||||
|
const std::vector<int> &indices = get_model_side_indices(side);
|
||||||
|
ERR_FAIL_COND(indices.size() % 3 != 0);
|
||||||
|
|
||||||
|
uint64_t raster_mask = 0;
|
||||||
|
|
||||||
|
for (unsigned int j = 0; j < indices.size(); j += 3) {
|
||||||
|
|
||||||
|
Vector3 va = positions[indices[j]];
|
||||||
|
Vector3 vb = positions[indices[j + 1]];
|
||||||
|
Vector3 vc = positions[indices[j + 2]];
|
||||||
|
|
||||||
|
// Convert 3D vertices into 2D
|
||||||
|
Vector2 a, b, c;
|
||||||
|
switch (side) {
|
||||||
|
case Cube::SIDE_NEGATIVE_X:
|
||||||
|
case Cube::SIDE_POSITIVE_X:
|
||||||
|
a = Vector2(va.y, va.z);
|
||||||
|
b = Vector2(vb.y, vb.z);
|
||||||
|
c = Vector2(vc.y, vc.z);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Cube::SIDE_NEGATIVE_Y:
|
||||||
|
case Cube::SIDE_POSITIVE_Y:
|
||||||
|
a = Vector2(va.x, va.z);
|
||||||
|
b = Vector2(vb.x, vb.z);
|
||||||
|
c = Vector2(vc.x, vc.z);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Cube::SIDE_NEGATIVE_Z:
|
||||||
|
case Cube::SIDE_POSITIVE_Z:
|
||||||
|
a = Vector2(va.x, va.y);
|
||||||
|
b = Vector2(vb.x, vb.y);
|
||||||
|
c = Vector2(vc.x, vc.y);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
CRASH_NOW();
|
||||||
|
}
|
||||||
|
|
||||||
|
a *= 8;
|
||||||
|
b *= 8;
|
||||||
|
c *= 8;
|
||||||
|
|
||||||
|
// Rasterize triangles into an 8x8 grid
|
||||||
|
rasterize_triangle_barycentric(a, b, c, [&raster_mask](uint64_t x, uint64_t y) {
|
||||||
|
if (x >= 8 || y >= 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raster_mask |= (uint64_t(1) << (x + uint64_t(8) * y));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
set_side_culling_mask(side, raster_mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//Ref<Voxel> Voxel::set_xquad_geometry(Vector2 atlas_pos) {
|
//Ref<Voxel> Voxel::set_xquad_geometry(Vector2 atlas_pos) {
|
||||||
// // TODO
|
// // TODO
|
||||||
// return Ref<Voxel>(this);
|
// return Ref<Voxel>(this);
|
||||||
|
@ -438,8 +532,9 @@ void Voxel::_bind_methods() {
|
||||||
ClassDB::bind_method(D_METHOD("set_collision_aabbs", "aabbs"), &Voxel::_b_set_collision_aabbs);
|
ClassDB::bind_method(D_METHOD("set_collision_aabbs", "aabbs"), &Voxel::_b_set_collision_aabbs);
|
||||||
ClassDB::bind_method(D_METHOD("get_collision_aabbs"), &Voxel::_b_get_collision_aabbs);
|
ClassDB::bind_method(D_METHOD("get_collision_aabbs"), &Voxel::_b_get_collision_aabbs);
|
||||||
|
|
||||||
ClassDB::bind_method(D_METHOD("set_face_culling_mask", "side", "mask"), &Voxel::_b_set_face_culling_mask);
|
ClassDB::bind_method(D_METHOD("set_side_culling_mask", "side", "mask"), &Voxel::_b_set_side_culling_mask);
|
||||||
ClassDB::bind_method(D_METHOD("get_face_culling_mask"), &Voxel::_b_get_face_culling_mask);
|
ClassDB::bind_method(D_METHOD("get_side_culling_mask"), &Voxel::_b_get_side_culling_mask);
|
||||||
|
//ClassDB::bind_method(D_METHOD("generate_side_culling_masks"), &Voxel::generate_side_culling_masks);
|
||||||
|
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "voxel_name"), "set_voxel_name", "get_voxel_name");
|
ADD_PROPERTY(PropertyInfo(Variant::STRING, "voxel_name"), "set_voxel_name", "get_voxel_name");
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::COLOR, "color"), "set_color", "get_color");
|
ADD_PROPERTY(PropertyInfo(Variant::COLOR, "color"), "set_color", "get_color");
|
||||||
|
@ -484,12 +579,12 @@ void Voxel::_b_set_collision_aabbs(Array array) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Voxel::_b_set_face_culling_mask(Side face_id, uint8_t mask) {
|
void Voxel::_b_set_side_culling_mask(Side face_id, uint64_t mask) {
|
||||||
ERR_FAIL_INDEX(face_id, SIDE_COUNT);
|
ERR_FAIL_INDEX(face_id, SIDE_COUNT);
|
||||||
_side_culling_masks[face_id] = mask;
|
set_side_culling_mask(face_id, mask);
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t Voxel::_b_get_face_culling_mask(int face_id) const {
|
uint64_t Voxel::_b_get_side_culling_mask(int face_id) const {
|
||||||
ERR_FAIL_INDEX_V(face_id, SIDE_COUNT, 0);
|
ERR_FAIL_INDEX_V(face_id, SIDE_COUNT, 0);
|
||||||
return _side_culling_masks[face_id];
|
return get_side_culling_mask(face_id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,6 +47,11 @@ public:
|
||||||
void set_custom_mesh(Ref<Mesh> mesh);
|
void set_custom_mesh(Ref<Mesh> mesh);
|
||||||
Ref<Mesh> get_custom_mesh() const { return _custom_mesh; }
|
Ref<Mesh> get_custom_mesh() const { return _custom_mesh; }
|
||||||
|
|
||||||
|
void set_side_culling_mask(int side, uint64_t mask);
|
||||||
|
inline uint64_t get_side_culling_mask(int side) const { return _side_culling_masks[side]; }
|
||||||
|
|
||||||
|
void generate_side_culling_masks();
|
||||||
|
|
||||||
//-------------------------------------------
|
//-------------------------------------------
|
||||||
// Built-in geometry generators
|
// Built-in geometry generators
|
||||||
|
|
||||||
|
@ -75,8 +80,6 @@ public:
|
||||||
|
|
||||||
void set_library(Ref<VoxelLibrary> lib);
|
void set_library(Ref<VoxelLibrary> lib);
|
||||||
|
|
||||||
inline uint8_t get_face_culling_mask(int side) const { return _side_culling_masks[side]; }
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool _set(const StringName &p_name, const Variant &p_value);
|
bool _set(const StringName &p_name, const Variant &p_value);
|
||||||
bool _get(const StringName &p_name, Variant &r_ret) const;
|
bool _get(const StringName &p_name, Variant &r_ret) const;
|
||||||
|
@ -96,8 +99,8 @@ private:
|
||||||
Array _b_get_collision_aabbs() const;
|
Array _b_get_collision_aabbs() const;
|
||||||
void _b_set_collision_aabbs(Array array);
|
void _b_set_collision_aabbs(Array array);
|
||||||
|
|
||||||
void _b_set_face_culling_mask(Side face_id, uint8_t mask);
|
void _b_set_side_culling_mask(Side face_id, uint64_t mask);
|
||||||
uint8_t _b_get_face_culling_mask(int face_id) const;
|
uint64_t _b_get_side_culling_mask(int face_id) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ObjectID _library;
|
ObjectID _library;
|
||||||
|
@ -118,7 +121,7 @@ private:
|
||||||
|
|
||||||
// If a face touches a neighbor face, this decides if it gets culled.
|
// If a face touches a neighbor face, this decides if it gets culled.
|
||||||
// If the neighbor's face culling mask has all bits of the current face's mask, the face will be culled.
|
// If the neighbor's face culling mask has all bits of the current face's mask, the face will be culled.
|
||||||
FixedArray<uint8_t, Cube::SIDE_COUNT> _side_culling_masks;
|
FixedArray<uint64_t, Cube::SIDE_COUNT> _side_culling_masks;
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
std::vector<Vector3> _model_positions;
|
std::vector<Vector3> _model_positions;
|
||||||
|
|
|
@ -116,11 +116,6 @@ Ref<Voxel> VoxelLibrary::create_voxel(unsigned int id, String name) {
|
||||||
return voxel;
|
return voxel;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ref<Voxel> VoxelLibrary::_b_get_voxel(unsigned int id) {
|
|
||||||
ERR_FAIL_COND_V(id >= _voxel_types.size(), Ref<Voxel>());
|
|
||||||
return _voxel_types[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
void VoxelLibrary::_bind_methods() {
|
void VoxelLibrary::_bind_methods() {
|
||||||
|
|
||||||
ClassDB::bind_method(D_METHOD("create_voxel", "id", "name"), &VoxelLibrary::create_voxel);
|
ClassDB::bind_method(D_METHOD("create_voxel", "id", "name"), &VoxelLibrary::create_voxel);
|
||||||
|
@ -137,3 +132,8 @@ void VoxelLibrary::_bind_methods() {
|
||||||
|
|
||||||
BIND_CONSTANT(MAX_VOXEL_TYPES);
|
BIND_CONSTANT(MAX_VOXEL_TYPES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ref<Voxel> VoxelLibrary::_b_get_voxel(unsigned int id) {
|
||||||
|
ERR_FAIL_COND_V(id >= _voxel_types.size(), Ref<Voxel>());
|
||||||
|
return _voxel_types[id];
|
||||||
|
}
|
||||||
|
|
|
@ -44,10 +44,11 @@ protected:
|
||||||
|
|
||||||
Ref<Voxel> _b_get_voxel(unsigned int id);
|
Ref<Voxel> _b_get_voxel(unsigned int id);
|
||||||
|
|
||||||
|
void generate_side_culling_masks();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector<Ref<Voxel> > _voxel_types;
|
std::vector<Ref<Voxel> > _voxel_types;
|
||||||
int _atlas_size;
|
int _atlas_size;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // VOXEL_LIBRARY_H
|
#endif // VOXEL_LIBRARY_H
|
||||||
|
|
||||||
|
|
|
@ -31,9 +31,9 @@ inline bool is_face_visible(const VoxelLibrary &lib, const Voxel &vt, int other_
|
||||||
if (other_vt.is_transparent() && vt.get_id() != other_voxel_id) {
|
if (other_vt.is_transparent() && vt.get_id() != other_voxel_id) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
const uint8_t m = vt.get_face_culling_mask(side);
|
const uint64_t m = vt.get_side_culling_mask(side);
|
||||||
const int opposite_side = g_opposite_side[side];
|
const int opposite_side = g_opposite_side[side];
|
||||||
return (m & other_vt.get_face_culling_mask(opposite_side)) != m;
|
return (m & other_vt.get_side_culling_mask(opposite_side)) != m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
Loading…
Reference in New Issue