Check device features (only build non solid pipelines if feature available), use phyton script for Android build
This commit is contained in:
parent
a57c8931e5
commit
fea23c2b7b
4 changed files with 103 additions and 103 deletions
|
|
@ -15,6 +15,7 @@
|
||||||
android:label="Pipelines"
|
android:label="Pipelines"
|
||||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||||
<meta-data android:name="android.app.lib_name" android:value="vulkanPipelines" />
|
<meta-data android:name="android.app.lib_name" android:value="vulkanPipelines" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
cd jni
|
|
||||||
call ndk-build
|
|
||||||
if %ERRORLEVEL% EQU 0 (
|
|
||||||
echo ndk-build has failed, build cancelled
|
|
||||||
cd..
|
|
||||||
|
|
||||||
mkdir "assets\shaders\base"
|
|
||||||
xcopy "..\..\data\shaders\base\*.spv" "assets\shaders\base" /Y
|
|
||||||
|
|
||||||
mkdir "assets\shaders\pipelines"
|
|
||||||
xcopy "..\..\data\shaders\pipelines\*.spv" "assets\shaders\pipelines" /Y
|
|
||||||
|
|
||||||
mkdir "assets\models"
|
|
||||||
xcopy "..\..\data\models\treasure_smooth.dae" "assets\models" /Y
|
|
||||||
|
|
||||||
mkdir "res\drawable"
|
|
||||||
xcopy "..\..\android\images\icon.png" "res\drawable" /Y
|
|
||||||
|
|
||||||
call ant debug -Dout.final.file=vulkanPipelines.apk
|
|
||||||
) ELSE (
|
|
||||||
echo error : ndk-build failed with errors!
|
|
||||||
cd..
|
|
||||||
)
|
|
||||||
48
android/pipelines/build.py
Normal file
48
android/pipelines/build.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import glob
|
||||||
|
|
||||||
|
APK_NAME = "vulkanPipelines"
|
||||||
|
SHADER_DIR = "pipelines"
|
||||||
|
ASSETS_MODELS = ["treasure_smooth.dae"]
|
||||||
|
|
||||||
|
if subprocess.call("ndk-build", shell=True) == 0:
|
||||||
|
print("Build successful")
|
||||||
|
|
||||||
|
# Assets
|
||||||
|
if not os.path.exists("./assets"):
|
||||||
|
os.makedirs("./assets")
|
||||||
|
|
||||||
|
# Shaders
|
||||||
|
# Base
|
||||||
|
if not os.path.exists("./assets/shaders/base"):
|
||||||
|
os.makedirs("./assets/shaders/base")
|
||||||
|
for file in glob.glob("../../data/shaders/base/*.spv"):
|
||||||
|
shutil.copy(file, "./assets/shaders/base")
|
||||||
|
# Sample
|
||||||
|
if not os.path.exists("./assets/shaders/%s" % SHADER_DIR):
|
||||||
|
os.makedirs("./assets/shaders/%s" % SHADER_DIR)
|
||||||
|
for file in glob.glob("../../data/shaders/%s/*.spv" %SHADER_DIR):
|
||||||
|
shutil.copy(file, "./assets/shaders/%s" % SHADER_DIR)
|
||||||
|
# Models
|
||||||
|
if not os.path.exists("./assets/models"):
|
||||||
|
os.makedirs("./assets/models")
|
||||||
|
for file in ASSETS_MODELS:
|
||||||
|
shutil.copy("../../data/models/%s" % file, "./assets/models")
|
||||||
|
|
||||||
|
# Icon
|
||||||
|
if not os.path.exists("./res/drawable"):
|
||||||
|
os.makedirs("./res/drawable")
|
||||||
|
shutil.copy("../../android/images/icon.png", "./res/drawable")
|
||||||
|
|
||||||
|
if subprocess.call("ant debug -Dout.final.file=%s.apk" % APK_NAME, shell=True) == 0:
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
if sys.argv[1] == "-deploy":
|
||||||
|
if subprocess.call("adb install -r %s.apk" % APK_NAME, shell=True) != 0:
|
||||||
|
print("Could not deploy to device!")
|
||||||
|
else:
|
||||||
|
print("Error during build process!")
|
||||||
|
else:
|
||||||
|
print("Error building project!")
|
||||||
|
|
@ -28,12 +28,6 @@
|
||||||
class VulkanExample: public VulkanExampleBase
|
class VulkanExample: public VulkanExampleBase
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
struct {
|
|
||||||
VkPipelineVertexInputStateCreateInfo inputState;
|
|
||||||
std::vector<VkVertexInputBindingDescription> bindingDescriptions;
|
|
||||||
std::vector<VkVertexInputAttributeDescription> attributeDescriptions;
|
|
||||||
} vertices;
|
|
||||||
|
|
||||||
// Vertex layout for the models
|
// Vertex layout for the models
|
||||||
vks::VertexLayout vertexLayout = vks::VertexLayout({
|
vks::VertexLayout vertexLayout = vks::VertexLayout({
|
||||||
vks::VERTEX_COMPONENT_POSITION,
|
vks::VERTEX_COMPONENT_POSITION,
|
||||||
|
|
@ -71,9 +65,6 @@ public:
|
||||||
rotation = glm::vec3(-25.0f, 15.0f, 0.0f);
|
rotation = glm::vec3(-25.0f, 15.0f, 0.0f);
|
||||||
enableTextOverlay = true;
|
enableTextOverlay = true;
|
||||||
title = "Vulkan Example - Pipeline state objects";
|
title = "Vulkan Example - Pipeline state objects";
|
||||||
// Enable features for wireframe rendering and line width setting
|
|
||||||
enabledFeatures.fillModeNonSolid = VK_TRUE;
|
|
||||||
enabledFeatures.wideLines = VK_TRUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
~VulkanExample()
|
~VulkanExample()
|
||||||
|
|
@ -94,6 +85,19 @@ public:
|
||||||
uniformBuffer.destroy();
|
uniformBuffer.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable physical device features required for this example
|
||||||
|
virtual void getEnabledFeatures()
|
||||||
|
{
|
||||||
|
// Fill mode non solid is required for wireframe display
|
||||||
|
if (deviceFeatures.fillModeNonSolid) {
|
||||||
|
enabledFeatures.fillModeNonSolid = VK_TRUE;
|
||||||
|
// Wide lines must be present for line width > 1.0f
|
||||||
|
if (deviceFeatures.wideLines) {
|
||||||
|
enabledFeatures.wideLines = VK_TRUE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void buildCommandBuffers()
|
void buildCommandBuffers()
|
||||||
{
|
{
|
||||||
VkCommandBufferBeginInfo cmdBufInfo = vks::initializers::commandBufferBeginInfo();
|
VkCommandBufferBeginInfo cmdBufInfo = vks::initializers::commandBufferBeginInfo();
|
||||||
|
|
@ -143,7 +147,10 @@ public:
|
||||||
viewport.x = (float)width / 3.0;
|
viewport.x = (float)width / 3.0;
|
||||||
vkCmdSetViewport(drawCmdBuffers[i], 0, 1, &viewport);
|
vkCmdSetViewport(drawCmdBuffers[i], 0, 1, &viewport);
|
||||||
vkCmdBindPipeline(drawCmdBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelines.toon);
|
vkCmdBindPipeline(drawCmdBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelines.toon);
|
||||||
vkCmdSetLineWidth(drawCmdBuffers[i], 2.0f);
|
// Line width > 1.0f only if wide lines feature is supported
|
||||||
|
if (deviceFeatures.wideLines) {
|
||||||
|
vkCmdSetLineWidth(drawCmdBuffers[i], 2.0f);
|
||||||
|
}
|
||||||
vkCmdDrawIndexed(drawCmdBuffers[i], models.cube.indexCount, 1, 0, 0, 0);
|
vkCmdDrawIndexed(drawCmdBuffers[i], models.cube.indexCount, 1, 0, 0, 0);
|
||||||
|
|
||||||
if (deviceFeatures.fillModeNonSolid)
|
if (deviceFeatures.fillModeNonSolid)
|
||||||
|
|
@ -166,55 +173,6 @@ public:
|
||||||
models.cube.loadFromFile(getAssetPath() + "models/treasure_smooth.dae", vertexLayout, 1.0f, vulkanDevice, queue);
|
models.cube.loadFromFile(getAssetPath() + "models/treasure_smooth.dae", vertexLayout, 1.0f, vulkanDevice, queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupVertexDescriptions()
|
|
||||||
{
|
|
||||||
// Binding description
|
|
||||||
vertices.bindingDescriptions.resize(1);
|
|
||||||
vertices.bindingDescriptions[0] =
|
|
||||||
vks::initializers::vertexInputBindingDescription(
|
|
||||||
VERTEX_BUFFER_BIND_ID,
|
|
||||||
vertexLayout.stride(),
|
|
||||||
VK_VERTEX_INPUT_RATE_VERTEX);
|
|
||||||
|
|
||||||
// Attribute descriptions
|
|
||||||
// Describes memory layout and shader positions
|
|
||||||
vertices.attributeDescriptions.resize(4);
|
|
||||||
// Location 0 : Position
|
|
||||||
vertices.attributeDescriptions[0] =
|
|
||||||
vks::initializers::vertexInputAttributeDescription(
|
|
||||||
VERTEX_BUFFER_BIND_ID,
|
|
||||||
0,
|
|
||||||
VK_FORMAT_R32G32B32_SFLOAT,
|
|
||||||
0);
|
|
||||||
// Location 1 : Color
|
|
||||||
vertices.attributeDescriptions[1] =
|
|
||||||
vks::initializers::vertexInputAttributeDescription(
|
|
||||||
VERTEX_BUFFER_BIND_ID,
|
|
||||||
1,
|
|
||||||
VK_FORMAT_R32G32B32_SFLOAT,
|
|
||||||
sizeof(float) * 3);
|
|
||||||
// Location 3 : Texture coordinates
|
|
||||||
vertices.attributeDescriptions[2] =
|
|
||||||
vks::initializers::vertexInputAttributeDescription(
|
|
||||||
VERTEX_BUFFER_BIND_ID,
|
|
||||||
2,
|
|
||||||
VK_FORMAT_R32G32_SFLOAT,
|
|
||||||
sizeof(float) * 6);
|
|
||||||
// Location 2 : Normal
|
|
||||||
vertices.attributeDescriptions[3] =
|
|
||||||
vks::initializers::vertexInputAttributeDescription(
|
|
||||||
VERTEX_BUFFER_BIND_ID,
|
|
||||||
3,
|
|
||||||
VK_FORMAT_R32G32B32_SFLOAT,
|
|
||||||
sizeof(float) * 8);
|
|
||||||
|
|
||||||
vertices.inputState = vks::initializers::pipelineVertexInputStateCreateInfo();
|
|
||||||
vertices.inputState.vertexBindingDescriptionCount = vertices.bindingDescriptions.size();
|
|
||||||
vertices.inputState.pVertexBindingDescriptions = vertices.bindingDescriptions.data();
|
|
||||||
vertices.inputState.vertexAttributeDescriptionCount = vertices.attributeDescriptions.size();
|
|
||||||
vertices.inputState.pVertexAttributeDescriptions = vertices.attributeDescriptions.data();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setupDescriptorPool()
|
void setupDescriptorPool()
|
||||||
{
|
{
|
||||||
std::vector<VkDescriptorPoolSize> poolSizes =
|
std::vector<VkDescriptorPoolSize> poolSizes =
|
||||||
|
|
@ -315,9 +273,7 @@ public:
|
||||||
vks::initializers::pipelineViewportStateCreateInfo(1, 1, 0);
|
vks::initializers::pipelineViewportStateCreateInfo(1, 1, 0);
|
||||||
|
|
||||||
VkPipelineMultisampleStateCreateInfo multisampleState =
|
VkPipelineMultisampleStateCreateInfo multisampleState =
|
||||||
vks::initializers::pipelineMultisampleStateCreateInfo(
|
vks::initializers::pipelineMultisampleStateCreateInfo(VK_SAMPLE_COUNT_1_BIT);
|
||||||
VK_SAMPLE_COUNT_1_BIT,
|
|
||||||
0);
|
|
||||||
|
|
||||||
std::vector<VkDynamicState> dynamicStateEnables = {
|
std::vector<VkDynamicState> dynamicStateEnables = {
|
||||||
VK_DYNAMIC_STATE_VIEWPORT,
|
VK_DYNAMIC_STATE_VIEWPORT,
|
||||||
|
|
@ -325,24 +281,13 @@ public:
|
||||||
VK_DYNAMIC_STATE_LINE_WIDTH,
|
VK_DYNAMIC_STATE_LINE_WIDTH,
|
||||||
};
|
};
|
||||||
VkPipelineDynamicStateCreateInfo dynamicState =
|
VkPipelineDynamicStateCreateInfo dynamicState =
|
||||||
vks::initializers::pipelineDynamicStateCreateInfo(
|
vks::initializers::pipelineDynamicStateCreateInfo(dynamicStateEnables);
|
||||||
dynamicStateEnables.data(),
|
|
||||||
dynamicStateEnables.size(),
|
VkGraphicsPipelineCreateInfo pipelineCreateInfo =
|
||||||
0);
|
vks::initializers::pipelineCreateInfo(pipelineLayout, renderPass);
|
||||||
|
|
||||||
std::array<VkPipelineShaderStageCreateInfo, 2> shaderStages;
|
std::array<VkPipelineShaderStageCreateInfo, 2> shaderStages;
|
||||||
|
|
||||||
// Phong shading pipeline
|
|
||||||
shaderStages[0] = loadShader(getAssetPath() + "shaders/pipelines/phong.vert.spv", VK_SHADER_STAGE_VERTEX_BIT);
|
|
||||||
shaderStages[1] = loadShader(getAssetPath() + "shaders/pipelines/phong.frag.spv", VK_SHADER_STAGE_FRAGMENT_BIT);
|
|
||||||
|
|
||||||
VkGraphicsPipelineCreateInfo pipelineCreateInfo =
|
|
||||||
vks::initializers::pipelineCreateInfo(
|
|
||||||
pipelineLayout,
|
|
||||||
renderPass,
|
|
||||||
0);
|
|
||||||
|
|
||||||
pipelineCreateInfo.pVertexInputState = &vertices.inputState;
|
|
||||||
pipelineCreateInfo.pInputAssemblyState = &inputAssemblyState;
|
pipelineCreateInfo.pInputAssemblyState = &inputAssemblyState;
|
||||||
pipelineCreateInfo.pRasterizationState = &rasterizationState;
|
pipelineCreateInfo.pRasterizationState = &rasterizationState;
|
||||||
pipelineCreateInfo.pColorBlendState = &colorBlendState;
|
pipelineCreateInfo.pColorBlendState = &colorBlendState;
|
||||||
|
|
@ -353,6 +298,31 @@ public:
|
||||||
pipelineCreateInfo.stageCount = shaderStages.size();
|
pipelineCreateInfo.stageCount = shaderStages.size();
|
||||||
pipelineCreateInfo.pStages = shaderStages.data();
|
pipelineCreateInfo.pStages = shaderStages.data();
|
||||||
|
|
||||||
|
// Shared vertex bindings and attributes used by all pipelines
|
||||||
|
|
||||||
|
// Binding description
|
||||||
|
std::vector<VkVertexInputBindingDescription> vertexInputBindings = {
|
||||||
|
vks::initializers::vertexInputBindingDescription(VERTEX_BUFFER_BIND_ID, vertexLayout.stride(), VK_VERTEX_INPUT_RATE_VERTEX),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attribute descriptions
|
||||||
|
std::vector<VkVertexInputAttributeDescription> vertexInputAttributes = {
|
||||||
|
vks::initializers::vertexInputAttributeDescription(VERTEX_BUFFER_BIND_ID, 0, VK_FORMAT_R32G32B32_SFLOAT, 0), // Location 0: Position
|
||||||
|
vks::initializers::vertexInputAttributeDescription(VERTEX_BUFFER_BIND_ID, 1, VK_FORMAT_R32G32B32_SFLOAT, sizeof(float) * 3), // Location 1: Color
|
||||||
|
vks::initializers::vertexInputAttributeDescription(VERTEX_BUFFER_BIND_ID, 2, VK_FORMAT_R32G32_SFLOAT, sizeof(float) * 6), // Location 2 : Texture coordinates
|
||||||
|
vks::initializers::vertexInputAttributeDescription(VERTEX_BUFFER_BIND_ID, 3, VK_FORMAT_R32G32B32_SFLOAT, sizeof(float) * 8), // Location 3 : Normal
|
||||||
|
};
|
||||||
|
|
||||||
|
VkPipelineVertexInputStateCreateInfo vertexInputState = vks::initializers::pipelineVertexInputStateCreateInfo();
|
||||||
|
vertexInputState.vertexBindingDescriptionCount = static_cast<uint32_t>(vertexInputBindings.size());
|
||||||
|
vertexInputState.pVertexBindingDescriptions = vertexInputBindings.data();
|
||||||
|
vertexInputState.vertexAttributeDescriptionCount = static_cast<uint32_t>(vertexInputAttributes.size());
|
||||||
|
vertexInputState.pVertexAttributeDescriptions = vertexInputAttributes.data();
|
||||||
|
|
||||||
|
pipelineCreateInfo.pVertexInputState = &vertexInputState;
|
||||||
|
|
||||||
|
// Create the graphics pipeline state objects
|
||||||
|
|
||||||
// We are using this pipeline as the base for the other pipelines (derivatives)
|
// We are using this pipeline as the base for the other pipelines (derivatives)
|
||||||
// Pipeline derivatives can be used for pipelines that share most of their state
|
// Pipeline derivatives can be used for pipelines that share most of their state
|
||||||
// Depending on the implementation this may result in better performance for pipeline
|
// Depending on the implementation this may result in better performance for pipeline
|
||||||
|
|
@ -360,6 +330,9 @@ public:
|
||||||
pipelineCreateInfo.flags = VK_PIPELINE_CREATE_ALLOW_DERIVATIVES_BIT;
|
pipelineCreateInfo.flags = VK_PIPELINE_CREATE_ALLOW_DERIVATIVES_BIT;
|
||||||
|
|
||||||
// Textured pipeline
|
// Textured pipeline
|
||||||
|
// Phong shading pipeline
|
||||||
|
shaderStages[0] = loadShader(getAssetPath() + "shaders/pipelines/phong.vert.spv", VK_SHADER_STAGE_VERTEX_BIT);
|
||||||
|
shaderStages[1] = loadShader(getAssetPath() + "shaders/pipelines/phong.frag.spv", VK_SHADER_STAGE_FRAGMENT_BIT);
|
||||||
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.phong));
|
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.phong));
|
||||||
|
|
||||||
// All pipelines created after the base pipeline will be derivatives
|
// All pipelines created after the base pipeline will be derivatives
|
||||||
|
|
@ -373,13 +346,12 @@ public:
|
||||||
// Toon shading pipeline
|
// Toon shading pipeline
|
||||||
shaderStages[0] = loadShader(getAssetPath() + "shaders/pipelines/toon.vert.spv", VK_SHADER_STAGE_VERTEX_BIT);
|
shaderStages[0] = loadShader(getAssetPath() + "shaders/pipelines/toon.vert.spv", VK_SHADER_STAGE_VERTEX_BIT);
|
||||||
shaderStages[1] = loadShader(getAssetPath() + "shaders/pipelines/toon.frag.spv", VK_SHADER_STAGE_FRAGMENT_BIT);
|
shaderStages[1] = loadShader(getAssetPath() + "shaders/pipelines/toon.frag.spv", VK_SHADER_STAGE_FRAGMENT_BIT);
|
||||||
|
|
||||||
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.toon));
|
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.toon));
|
||||||
|
|
||||||
|
// Pipeline for wire frame rendering
|
||||||
// Non solid rendering is not a mandatory Vulkan feature
|
// Non solid rendering is not a mandatory Vulkan feature
|
||||||
if (deviceFeatures.fillModeNonSolid)
|
if (deviceFeatures.fillModeNonSolid)
|
||||||
{
|
{
|
||||||
// Pipeline for wire frame rendering
|
|
||||||
rasterizationState.polygonMode = VK_POLYGON_MODE_LINE;
|
rasterizationState.polygonMode = VK_POLYGON_MODE_LINE;
|
||||||
shaderStages[0] = loadShader(getAssetPath() + "shaders/pipelines/wireframe.vert.spv", VK_SHADER_STAGE_VERTEX_BIT);
|
shaderStages[0] = loadShader(getAssetPath() + "shaders/pipelines/wireframe.vert.spv", VK_SHADER_STAGE_VERTEX_BIT);
|
||||||
shaderStages[1] = loadShader(getAssetPath() + "shaders/pipelines/wireframe.frag.spv", VK_SHADER_STAGE_FRAGMENT_BIT);
|
shaderStages[1] = loadShader(getAssetPath() + "shaders/pipelines/wireframe.frag.spv", VK_SHADER_STAGE_FRAGMENT_BIT);
|
||||||
|
|
@ -432,7 +404,6 @@ public:
|
||||||
{
|
{
|
||||||
VulkanExampleBase::prepare();
|
VulkanExampleBase::prepare();
|
||||||
loadAssets();
|
loadAssets();
|
||||||
setupVertexDescriptions();
|
|
||||||
prepareUniformBuffers();
|
prepareUniformBuffers();
|
||||||
setupDescriptorSetLayout();
|
setupDescriptorSetLayout();
|
||||||
preparePipelines();
|
preparePipelines();
|
||||||
|
|
@ -459,6 +430,9 @@ public:
|
||||||
textOverlay->addText("Phong shading pipeline",(float)width / 6.0f, height - 35.0f, VulkanTextOverlay::alignCenter);
|
textOverlay->addText("Phong shading pipeline",(float)width / 6.0f, height - 35.0f, VulkanTextOverlay::alignCenter);
|
||||||
textOverlay->addText("Toon shading pipeline", (float)width / 2.0f, height - 35.0f, VulkanTextOverlay::alignCenter);
|
textOverlay->addText("Toon shading pipeline", (float)width / 2.0f, height - 35.0f, VulkanTextOverlay::alignCenter);
|
||||||
textOverlay->addText("Wireframe pipeline", width - (float)width / 6.5f, height - 35.0f, VulkanTextOverlay::alignCenter);
|
textOverlay->addText("Wireframe pipeline", width - (float)width / 6.5f, height - 35.0f, VulkanTextOverlay::alignCenter);
|
||||||
|
if (!deviceFeatures.fillModeNonSolid) {
|
||||||
|
textOverlay->addText("Non solid fill modes not supported!", width - (float)width / 6.5f, (float)height / 2.0f - 7.5f, VulkanTextOverlay::alignCenter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue