mirror of
https://github.com/ZDoom/gzdoom.git
synced 2024-11-10 23:01:50 +00:00
- split shadowmap AABB tree into two parts. one for static and one for dynamic
- upload only the dynamic AABB subtree using glBufferSubData - change internal raytracing stack limit from 16 to 32 - update shadowmap AABB tree after R_SetupFrame for proper frame interpolation
This commit is contained in:
parent
4f4d10ce5f
commit
f10ded756e
9 changed files with 123 additions and 58 deletions
|
@ -273,7 +273,6 @@ sector_t *FGLRenderer::RenderView(player_t* player)
|
||||||
fovratio = ratio;
|
fovratio = ratio;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateShadowMap();
|
|
||||||
retsec = RenderViewpoint(r_viewpoint, player->camera, NULL, r_viewpoint.FieldOfView.Degrees, ratio, fovratio, true, true);
|
retsec = RenderViewpoint(r_viewpoint, player->camera, NULL, r_viewpoint.FieldOfView.Degrees, ratio, fovratio, true, true);
|
||||||
}
|
}
|
||||||
All.Unclock();
|
All.Unclock();
|
||||||
|
|
|
@ -152,6 +152,8 @@ sector_t * FGLRenderer::RenderViewpoint (FRenderViewpoint &mainvp, AActor * came
|
||||||
{
|
{
|
||||||
R_SetupFrame (mainvp, r_viewwindow, camera);
|
R_SetupFrame (mainvp, r_viewwindow, camera);
|
||||||
|
|
||||||
|
UpdateShadowMap();
|
||||||
|
|
||||||
// Render (potentially) multiple views for stereo 3d
|
// Render (potentially) multiple views for stereo 3d
|
||||||
// Fixme. The view offsetting should be done with a static table and not require setup of the entire render state for the mode.
|
// Fixme. The view offsetting should be done with a static table and not require setup of the entire render state for the mode.
|
||||||
auto vrmode = VRMode::GetVRMode(mainview && toscreen);
|
auto vrmode = VRMode::GetVRMode(mainview && toscreen);
|
||||||
|
|
|
@ -65,7 +65,7 @@ void GLBuffer::Bind()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void GLBuffer::SetData(size_t size, void *data, bool staticdata)
|
void GLBuffer::SetData(size_t size, const void *data, bool staticdata)
|
||||||
{
|
{
|
||||||
assert(nomap); // once it's mappable, it cannot be recreated anymore.
|
assert(nomap); // once it's mappable, it cannot be recreated anymore.
|
||||||
Bind();
|
Bind();
|
||||||
|
@ -92,6 +92,12 @@ void GLBuffer::SetData(size_t size, void *data, bool staticdata)
|
||||||
InvalidateBufferState();
|
InvalidateBufferState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GLBuffer::SetSubData(size_t offset, size_t size, const void *data)
|
||||||
|
{
|
||||||
|
Bind();
|
||||||
|
glBufferSubData(mUseType, offset, size, data);
|
||||||
|
}
|
||||||
|
|
||||||
void GLBuffer::Map()
|
void GLBuffer::Map()
|
||||||
{
|
{
|
||||||
assert(nomap == false); // do not allow mapping of static buffers. Vulkan cannot do that so it should be blocked in OpenGL, too.
|
assert(nomap == false); // do not allow mapping of static buffers. Vulkan cannot do that so it should be blocked in OpenGL, too.
|
||||||
|
|
|
@ -22,7 +22,8 @@ protected:
|
||||||
|
|
||||||
GLBuffer(int usetype);
|
GLBuffer(int usetype);
|
||||||
~GLBuffer();
|
~GLBuffer();
|
||||||
void SetData(size_t size, void *data, bool staticdata) override;
|
void SetData(size_t size, const void *data, bool staticdata) override;
|
||||||
|
void SetSubData(size_t offset, size_t size, const void *data) override;
|
||||||
void Map() override;
|
void Map() override;
|
||||||
void Unmap() override;
|
void Unmap() override;
|
||||||
void Resize(size_t newsize) override;
|
void Resize(size_t newsize) override;
|
||||||
|
|
|
@ -47,7 +47,8 @@ public:
|
||||||
IBuffer &operator=(const IBuffer &) = delete;
|
IBuffer &operator=(const IBuffer &) = delete;
|
||||||
virtual ~IBuffer() = default;
|
virtual ~IBuffer() = default;
|
||||||
|
|
||||||
virtual void SetData(size_t size, void *data, bool staticdata = true) = 0;
|
virtual void SetData(size_t size, const void *data, bool staticdata = true) = 0;
|
||||||
|
virtual void SetSubData(size_t offset, size_t size, const void *data) = 0;
|
||||||
virtual void *Lock(unsigned int size) = 0;
|
virtual void *Lock(unsigned int size) = 0;
|
||||||
virtual void Unlock() = 0;
|
virtual void Unlock() = 0;
|
||||||
virtual void Resize(size_t newsize) = 0;
|
virtual void Resize(size_t newsize) = 0;
|
||||||
|
|
|
@ -38,35 +38,36 @@ LevelAABBTree::LevelAABBTree()
|
||||||
centroids.Push((v1 + v2) * 0.5f);
|
centroids.Push((v1 + v2) * 0.5f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a list of level lines we want to add:
|
// Create the static subtree
|
||||||
TArray<int> line_elements;
|
if (!GenerateTree(¢roids[0], false))
|
||||||
for (unsigned int i = 0; i < level.lines.Size(); i++)
|
return;
|
||||||
|
|
||||||
|
int staticroot = nodes.Size() - 1;
|
||||||
|
|
||||||
|
dynamicStartNode = nodes.Size();
|
||||||
|
dynamicStartLine = lines.Size();
|
||||||
|
|
||||||
|
// Create the dynamic subtree
|
||||||
|
if (GenerateTree(¢roids[0], true))
|
||||||
{
|
{
|
||||||
if (!level.lines[i].backsector)
|
int dynamicroot = nodes.Size() - 1;
|
||||||
{
|
|
||||||
#ifdef USE_POLYOBJ_SHADOWS
|
// Create a shared root node
|
||||||
if (level.lines[i].sidedef[0] && (level.lines[i].sidedef[0]->Flags & WALLF_POLYOBJ))
|
FVector2 aabb_min, aabb_max;
|
||||||
polylines.Push(i);
|
const auto &left = nodes[staticroot];
|
||||||
line_elements.Push(i);
|
const auto &right = nodes[dynamicroot];
|
||||||
#else
|
aabb_min.X = MIN(left.aabb_left, right.aabb_left);
|
||||||
if (!level.lines[i].sidedef[0] || !(level.lines[i].sidedef[0]->Flags & WALLF_POLYOBJ))
|
aabb_min.Y = MIN(left.aabb_top, right.aabb_top);
|
||||||
line_elements.Push(i);
|
aabb_max.X = MAX(left.aabb_right, right.aabb_right);
|
||||||
#endif
|
aabb_max.Y = MAX(left.aabb_bottom, right.aabb_bottom);
|
||||||
}
|
nodes.Push({ aabb_min, aabb_max, staticroot, dynamicroot });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateTreeNode needs a buffer where it can store line indices temporarily when sorting lines into the left and right child AABB buckets
|
|
||||||
TArray<int> work_buffer;
|
|
||||||
work_buffer.Resize(line_elements.Size() * 2);
|
|
||||||
|
|
||||||
// Generate the AABB tree
|
|
||||||
GenerateTreeNode(&line_elements[0], (int)line_elements.Size(), ¢roids[0], &work_buffer[0]);
|
|
||||||
|
|
||||||
// Add the lines referenced by the leaf nodes
|
// Add the lines referenced by the leaf nodes
|
||||||
lines.Resize(level.lines.Size());
|
lines.Resize(mapLines.Size());
|
||||||
for (unsigned int i = 0; i < level.lines.Size(); i++)
|
for (unsigned int i = 0; i < mapLines.Size(); i++)
|
||||||
{
|
{
|
||||||
const auto &line = level.lines[i];
|
const auto &line = level.lines[mapLines[i]];
|
||||||
auto &treeline = lines[i];
|
auto &treeline = lines[i];
|
||||||
|
|
||||||
treeline.x = (float)line.v1->fX();
|
treeline.x = (float)line.v1->fX();
|
||||||
|
@ -76,13 +77,46 @@ LevelAABBTree::LevelAABBTree()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LevelAABBTree::GenerateTree(const FVector2 *centroids, bool dynamicsubtree)
|
||||||
|
{
|
||||||
|
// Create a list of level lines we want to add:
|
||||||
|
TArray<int> line_elements;
|
||||||
|
for (unsigned int i = 0; i < level.lines.Size(); i++)
|
||||||
|
{
|
||||||
|
if (!level.lines[i].backsector)
|
||||||
|
{
|
||||||
|
bool isPolyLine = level.lines[i].sidedef[0] && (level.lines[i].sidedef[0]->Flags & WALLF_POLYOBJ);
|
||||||
|
if (isPolyLine && dynamicsubtree)
|
||||||
|
{
|
||||||
|
line_elements.Push(mapLines.Size());
|
||||||
|
mapLines.Push(i);
|
||||||
|
}
|
||||||
|
else if (!isPolyLine && !dynamicsubtree)
|
||||||
|
{
|
||||||
|
line_elements.Push(mapLines.Size());
|
||||||
|
mapLines.Push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line_elements.Size() == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// GenerateTreeNode needs a buffer where it can store line indices temporarily when sorting lines into the left and right child AABB buckets
|
||||||
|
TArray<int> work_buffer;
|
||||||
|
work_buffer.Resize(line_elements.Size() * 2);
|
||||||
|
|
||||||
|
// Generate the AABB tree
|
||||||
|
GenerateTreeNode(&line_elements[0], (int)line_elements.Size(), centroids, &work_buffer[0]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool LevelAABBTree::Update()
|
bool LevelAABBTree::Update()
|
||||||
{
|
{
|
||||||
bool modified = false;
|
bool modified = false;
|
||||||
for (unsigned int ii = 0; ii < polylines.Size(); ii++)
|
for (unsigned int i = dynamicStartLine; i < mapLines.Size(); i++)
|
||||||
{
|
{
|
||||||
int i = polylines[ii];
|
const auto &line = level.lines[mapLines[i]];
|
||||||
const auto &line = level.lines[i];
|
|
||||||
|
|
||||||
AABBTreeLine treeline;
|
AABBTreeLine treeline;
|
||||||
treeline.x = (float)line.v1->fX();
|
treeline.x = (float)line.v1->fX();
|
||||||
|
@ -95,10 +129,10 @@ bool LevelAABBTree::Update()
|
||||||
TArray<int> path = FindNodePath(i, nodes.Size() - 1);
|
TArray<int> path = FindNodePath(i, nodes.Size() - 1);
|
||||||
if (path.Size())
|
if (path.Size())
|
||||||
{
|
{
|
||||||
float x1 = (float)level.lines[i].v1->fX();
|
float x1 = (float)line.v1->fX();
|
||||||
float y1 = (float)level.lines[i].v1->fY();
|
float y1 = (float)line.v1->fY();
|
||||||
float x2 = (float)level.lines[i].v2->fX();
|
float x2 = (float)line.v2->fX();
|
||||||
float y2 = (float)level.lines[i].v2->fY();
|
float y2 = (float)line.v2->fY();
|
||||||
|
|
||||||
int nodeIndex = path[0];
|
int nodeIndex = path[0];
|
||||||
nodes[nodeIndex].aabb_left = MIN(x1, x2);
|
nodes[nodeIndex].aabb_left = MIN(x1, x2);
|
||||||
|
@ -122,7 +156,7 @@ bool LevelAABBTree::Update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return !modified;
|
return modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
TArray<int> LevelAABBTree::FindNodePath(unsigned int line, unsigned int node)
|
TArray<int> LevelAABBTree::FindNodePath(unsigned int line, unsigned int node)
|
||||||
|
@ -165,7 +199,7 @@ double LevelAABBTree::RayTest(const DVector3 &ray_start, const DVector3 &ray_end
|
||||||
double hit_fraction = 1.0;
|
double hit_fraction = 1.0;
|
||||||
|
|
||||||
// Walk the tree nodes
|
// Walk the tree nodes
|
||||||
int stack[16];
|
int stack[32];
|
||||||
int stack_pos = 1;
|
int stack_pos = 1;
|
||||||
stack[0] = nodes.Size() - 1; // root node is the last node in the list
|
stack[0] = nodes.Size() - 1; // root node is the last node in the list
|
||||||
while (stack_pos > 0)
|
while (stack_pos > 0)
|
||||||
|
@ -183,7 +217,7 @@ double LevelAABBTree::RayTest(const DVector3 &ray_start, const DVector3 &ray_end
|
||||||
hit_fraction = MIN(IntersectRayLine(ray_start, ray_end, nodes[node_index].line_index, raydelta, rayd, raydist2), hit_fraction);
|
hit_fraction = MIN(IntersectRayLine(ray_start, ray_end, nodes[node_index].line_index, raydelta, rayd, raydist2), hit_fraction);
|
||||||
stack_pos--;
|
stack_pos--;
|
||||||
}
|
}
|
||||||
else if (stack_pos == 16)
|
else if (stack_pos == 32)
|
||||||
{
|
{
|
||||||
stack_pos--; // stack overflow - tree is too deep!
|
stack_pos--; // stack overflow - tree is too deep!
|
||||||
}
|
}
|
||||||
|
@ -269,15 +303,15 @@ int LevelAABBTree::GenerateTreeNode(int *lines, int num_lines, const FVector2 *c
|
||||||
// Find bounding box and median of the lines
|
// Find bounding box and median of the lines
|
||||||
FVector2 median = FVector2(0.0f, 0.0f);
|
FVector2 median = FVector2(0.0f, 0.0f);
|
||||||
FVector2 aabb_min, aabb_max;
|
FVector2 aabb_min, aabb_max;
|
||||||
aabb_min.X = (float)level.lines[lines[0]].v1->fX();
|
aabb_min.X = (float)level.lines[mapLines[lines[0]]].v1->fX();
|
||||||
aabb_min.Y = (float)level.lines[lines[0]].v1->fY();
|
aabb_min.Y = (float)level.lines[mapLines[lines[0]]].v1->fY();
|
||||||
aabb_max = aabb_min;
|
aabb_max = aabb_min;
|
||||||
for (int i = 0; i < num_lines; i++)
|
for (int i = 0; i < num_lines; i++)
|
||||||
{
|
{
|
||||||
float x1 = (float)level.lines[lines[i]].v1->fX();
|
float x1 = (float)level.lines[mapLines[lines[i]]].v1->fX();
|
||||||
float y1 = (float)level.lines[lines[i]].v1->fY();
|
float y1 = (float)level.lines[mapLines[lines[i]]].v1->fY();
|
||||||
float x2 = (float)level.lines[lines[i]].v2->fX();
|
float x2 = (float)level.lines[mapLines[lines[i]]].v2->fX();
|
||||||
float y2 = (float)level.lines[lines[i]].v2->fY();
|
float y2 = (float)level.lines[mapLines[lines[i]]].v2->fY();
|
||||||
|
|
||||||
aabb_min.X = MIN(aabb_min.X, x1);
|
aabb_min.X = MIN(aabb_min.X, x1);
|
||||||
aabb_min.X = MIN(aabb_min.X, x2);
|
aabb_min.X = MIN(aabb_min.X, x2);
|
||||||
|
@ -288,7 +322,7 @@ int LevelAABBTree::GenerateTreeNode(int *lines, int num_lines, const FVector2 *c
|
||||||
aabb_max.Y = MAX(aabb_max.Y, y1);
|
aabb_max.Y = MAX(aabb_max.Y, y1);
|
||||||
aabb_max.Y = MAX(aabb_max.Y, y2);
|
aabb_max.Y = MAX(aabb_max.Y, y2);
|
||||||
|
|
||||||
median += centroids[lines[i]];
|
median += centroids[mapLines[lines[i]]];
|
||||||
}
|
}
|
||||||
median /= (float)num_lines;
|
median /= (float)num_lines;
|
||||||
|
|
||||||
|
@ -324,7 +358,7 @@ int LevelAABBTree::GenerateTreeNode(int *lines, int num_lines, const FVector2 *c
|
||||||
{
|
{
|
||||||
int line_index = lines[i];
|
int line_index = lines[i];
|
||||||
|
|
||||||
float side = FVector3(centroids[lines[i]], 1.0f) | plane;
|
float side = FVector3(centroids[mapLines[lines[i]]], 1.0f) | plane;
|
||||||
if (side >= 0.0f)
|
if (side >= 0.0f)
|
||||||
{
|
{
|
||||||
work_buffer[left_count] = line_index;
|
work_buffer[left_count] = line_index;
|
||||||
|
|
|
@ -42,18 +42,26 @@ public:
|
||||||
// Constructs a tree for the current level
|
// Constructs a tree for the current level
|
||||||
LevelAABBTree();
|
LevelAABBTree();
|
||||||
|
|
||||||
// Nodes in the AABB tree. Last node is the root node.
|
|
||||||
TArray<AABBTreeNode> nodes;
|
|
||||||
|
|
||||||
// Line segments for the leaf nodes in the tree.
|
|
||||||
TArray<AABBTreeLine> lines;
|
|
||||||
|
|
||||||
// Shoot a ray from ray_start to ray_end and return the closest hit as a fractional value between 0 and 1. Returns 1 if no line was hit.
|
// Shoot a ray from ray_start to ray_end and return the closest hit as a fractional value between 0 and 1. Returns 1 if no line was hit.
|
||||||
double RayTest(const DVector3 &ray_start, const DVector3 &ray_end);
|
double RayTest(const DVector3 &ray_start, const DVector3 &ray_end);
|
||||||
|
|
||||||
bool Update();
|
bool Update();
|
||||||
|
|
||||||
|
const void *Nodes() const { return nodes.Data(); }
|
||||||
|
const void *Lines() const { return lines.Data(); }
|
||||||
|
size_t NodesSize() const { return nodes.Size() * sizeof(AABBTreeNode); }
|
||||||
|
size_t LinesSize() const { return lines.Size() * sizeof(AABBTreeLine); }
|
||||||
|
|
||||||
|
const void *DynamicNodes() const { return nodes.Data() + dynamicStartNode; }
|
||||||
|
const void *DynamicLines() const { return lines.Data() + dynamicStartLine; }
|
||||||
|
size_t DynamicNodesSize() const { return (nodes.Size() - dynamicStartNode) * sizeof(AABBTreeNode); }
|
||||||
|
size_t DynamicLinesSize() const { return (lines.Size() - dynamicStartLine) * sizeof(AABBTreeLine); }
|
||||||
|
size_t DynamicNodesOffset() const { return dynamicStartNode * sizeof(AABBTreeNode); }
|
||||||
|
size_t DynamicLinesOffset() const { return dynamicStartLine * sizeof(AABBTreeLine); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
bool GenerateTree(const FVector2 *centroids, bool dynamicsubtree);
|
||||||
|
|
||||||
// Test if a ray overlaps an AABB node or not
|
// Test if a ray overlaps an AABB node or not
|
||||||
bool OverlapRayAABB(const DVector2 &ray_start2d, const DVector2 &ray_end2d, const AABBTreeNode &node);
|
bool OverlapRayAABB(const DVector2 &ray_start2d, const DVector2 &ray_end2d, const AABBTreeNode &node);
|
||||||
|
|
||||||
|
@ -65,7 +73,16 @@ private:
|
||||||
|
|
||||||
TArray<int> FindNodePath(unsigned int line, unsigned int node);
|
TArray<int> FindNodePath(unsigned int line, unsigned int node);
|
||||||
|
|
||||||
TArray<int> polylines;
|
// Nodes in the AABB tree. Last node is the root node.
|
||||||
|
TArray<AABBTreeNode> nodes;
|
||||||
|
|
||||||
|
// Line segments for the leaf nodes in the tree.
|
||||||
|
TArray<AABBTreeLine> lines;
|
||||||
|
|
||||||
|
int dynamicStartNode = 0;
|
||||||
|
int dynamicStartLine = 0;
|
||||||
|
|
||||||
|
TArray<int> mapLines;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
|
@ -155,7 +155,7 @@ bool IShadowMap::ValidateAABBTree()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mAABBTree)
|
if (mAABBTree)
|
||||||
return mAABBTree->Update();
|
return true;
|
||||||
|
|
||||||
mAABBTree.reset(new hwrenderer::LevelAABBTree());
|
mAABBTree.reset(new hwrenderer::LevelAABBTree());
|
||||||
return false;
|
return false;
|
||||||
|
@ -198,11 +198,16 @@ void IShadowMap::UploadAABBTree()
|
||||||
{
|
{
|
||||||
if (!mNodesBuffer)
|
if (!mNodesBuffer)
|
||||||
mNodesBuffer = screen->CreateDataBuffer(2, true);
|
mNodesBuffer = screen->CreateDataBuffer(2, true);
|
||||||
mNodesBuffer->SetData(sizeof(hwrenderer::AABBTreeNode) * mAABBTree->nodes.Size(), &mAABBTree->nodes[0]);
|
mNodesBuffer->SetData(mAABBTree->NodesSize(), mAABBTree->Nodes());
|
||||||
|
|
||||||
if (!mLinesBuffer)
|
if (!mLinesBuffer)
|
||||||
mLinesBuffer = screen->CreateDataBuffer(3, true);
|
mLinesBuffer = screen->CreateDataBuffer(3, true);
|
||||||
mLinesBuffer->SetData(sizeof(hwrenderer::AABBTreeLine) * mAABBTree->lines.Size(), &mAABBTree->lines[0]);
|
mLinesBuffer->SetData(mAABBTree->LinesSize(), mAABBTree->Lines());
|
||||||
|
}
|
||||||
|
else if (mAABBTree->Update())
|
||||||
|
{
|
||||||
|
mNodesBuffer->SetSubData(mAABBTree->DynamicNodesOffset(), mAABBTree->DynamicNodesSize(), mAABBTree->DynamicNodes());
|
||||||
|
mLinesBuffer->SetSubData(mAABBTree->DynamicLinesOffset(), mAABBTree->DynamicLinesSize(), mAABBTree->DynamicLines());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -109,7 +109,7 @@ float rayTest(vec2 ray_start, vec2 ray_end)
|
||||||
// Walk the AABB binary tree searching for nodes touching the ray line segment's AABB box.
|
// Walk the AABB binary tree searching for nodes touching the ray line segment's AABB box.
|
||||||
// When it reaches a leaf node, use a line segment intersection test to see if we got a hit.
|
// When it reaches a leaf node, use a line segment intersection test to see if we got a hit.
|
||||||
|
|
||||||
int stack[16];
|
int stack[32];
|
||||||
int stack_pos = 1;
|
int stack_pos = 1;
|
||||||
stack[0] = nodes.length() - 1;
|
stack[0] = nodes.length() - 1;
|
||||||
while (stack_pos > 0)
|
while (stack_pos > 0)
|
||||||
|
@ -125,7 +125,7 @@ float rayTest(vec2 ray_start, vec2 ray_end)
|
||||||
t = min(intersectRayLine(ray_start, ray_end, nodes[node_index].line_index, raydelta, rayd, raydist2), t);
|
t = min(intersectRayLine(ray_start, ray_end, nodes[node_index].line_index, raydelta, rayd, raydist2), t);
|
||||||
stack_pos--;
|
stack_pos--;
|
||||||
}
|
}
|
||||||
else if (stack_pos == 16)
|
else if (stack_pos == 32)
|
||||||
{
|
{
|
||||||
stack_pos--; // stack overflow
|
stack_pos--; // stack overflow
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue