大家好,接下来将为大家介绍Vulkan 交换链详解。

在这一章节,我们了解一下将渲染图像提交到屏幕的基本机制。这种机制称为交换链,并且需要在Vulkan上下文中被明确创建。从屏幕的角度观察,交换链本质上是一个图像队列。应用程序作为生产者会获取图像进行绘制,然后将其返还给交换链图像队列,等待屏幕消费。交换链的具体配置信息决定了应用程序提交绘制图像到队列的条件以及图像队列表现的效果,但交换链的通常使用目的是使绘制图像的最终呈现与屏幕的刷新频率同步。可以简单将交换链理解为一个队列,同步从生产者,即应用程序绘制图像,到消费者,屏幕刷新的Produce-Consume关系。在深入内容前看一下官方给出的整体交换链示例图。

一、检查交换链支持

并不是所有的图形卡具备能力将绘制的图像直接显示到屏幕上。比如一个GPU卡是为服务器设计的,那就不会具备任何有关显示的输出。其次,图像呈现是与surface打交道,而surface又与具体的窗体系统强关联,从这个角度,我们可以认为它不是Vulkan核心的部分。在查询图形卡是否支持后,需要启用VK_KHR_swapchain设备级别的扩展。

所以呢,我们首先扩展之前的isDeviceSuitable函数,确认设备是否支持。之前我们已经了解如何列出VkPhysicalDevice支持的扩展列表,在此就不展开具体细节了。请注意的是,Vulkan头文件提供给了一个方便的宏VK_KHR_SWAPCHAIN_EXTENSION_NAME,该宏定义为VK_KHR_swapchain。使用宏的优点就是避免拼写错误。

首先声明需要的设备扩展清单,与之前开启validation layers的列表是相似的。

const std::vector<const char*> deviceExtensions = {VK_KHR_SWAPCHAIN_EXTENSION_NAME
};

接下来,创建一个从isDeviceSuitable调用的新函数checkDeviceExtensionSupport作为额外的检查逻辑。

修改函数体以便于枚举设备所有集合,并检测是否所有需要的扩展在其中。

bool checkDeviceExtensionSupport(VkPhysicalDevice device) {uint32_t extensionCount;vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);std::vector<VkExtensionProperties> availableExtensions(extensionCount);vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());std::set<std::string> requiredExtensions(deviceExtensions.begin(), deviceExtensions.end());for (const auto& extension : availableExtensions) {requiredExtensions.erase(extension.extensionName);}return requiredExtensions.empty();
}

选择一组字符串来表示未经确认过的扩展名。这样做可以比较容易的进行增删及遍历的次序。当然也可以像CheckValidationLayerSupport函数那样做嵌套的循环。性能的差异在这里是不关紧要的。现在运行代码验证图形卡是否能够顺利创建一个交换链。需要注意的是前一个章节中验证过的presentation队列有效性,并没有明确指出交换链扩展也必须有效支持。好在扩展必须明确的开启。

启用扩展需要对逻辑设备的创建结构体做一些小的改动:

createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
createInfo.ppEnabledExtensionNames = deviceExtensions.data();

二、查询交换链支持的详情

如果仅仅是为了测试交换链的有效性是远远不够的,因为它还不能很好的与窗体surface兼容。创建交换链同样也需要很多设置,所以我们需要了解一些有关设置的细节。

基本上有三大类属性需要设置:

  1. 基本的surface功能属性(min/max number of images in swap chain, min/max width and height of images)
  2. Surface格式(pixel format, color space)
  3. 有效的presentation模式

findQueueFamilies类似,我们使用结构体一次性的传递详细的信息。三类属性封装在如下结构体中:

现在创建新的函数querySwapChainSupport填充该结构体。

SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) {SwapChainSupportDetails details;return details;
}

本小节涉及如何查询包含此信息的结构体,这些结构体的含义及包含的数据将在下一节讨论。

我们现在开始基本的surface功能设置部分。这些属性可以通过简单的函数调用查询,并返回到单个VkSurfaceCapabilitiesKHR结构体中。

vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &details.capabilities);

这个函数需要VkPhysicalDeviceVkSurfaceKHR窗体surface决定支持哪些具体功能。所有用于查看支持功能的函数都需要这两个参数,因为它们是交换链的核心组件。

下一步查询支持的surface格式。因为获取到的是一个结构体列表,具体应用形式如下

确保集合对于所有有效的格式可扩充。最后查询支持的presentation模式,同样的方式,使用vkGetPhysicalDeviceSurfacePresentModesKHR:

uint32_t presentModeCount;
vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);if (presentModeCount != 0) {details.presentModes.resize(presentModeCount);vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, details.presentModes.data());
}

现在结构体的相关细节介绍完毕,让我们扩充isDeviceSuitable函数,从而利用该函数验证交换链足够的支持。在本章节中交换链的支持是足够的,因为对于给定的窗体surface,它至少支持一个图像格式,一个presentaion模式。

bool swapChainAdequate = false;
if (extensionsSupported) {SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device);swapChainAdequate = !swapChainSupport.formats.empty() && !swapChainSupport.presentModes.empty();
}

比较重要的是尝试查询交换链的支持是在验证完扩展有效性之后进行。函数的最后一行代码修改为:

return indices.isComplete() && extensionsSupported && swapChainAdequate;

三、为交换链选择正确的设置

如果swapChainAdequate条件足够,那么对应的支持的足够的,但是根据不同的模式仍然有不同的最佳选择。我们编写一组函数,通过进一步的设置查找最匹配的交换链。这里有三种类型的设置去确定:

  1. Surface格式 (color depth)
  2. Presentation mode (conditions for “swapping” image to the screen)
  3. Swap extent (resolution of images in swap chain)

首先在脑海中对每一个设置都有一个理想的数值,如果达成一致我们就使用,否则我们一起创建一些逻辑去找到更好的规则、数值。

四、Surface 格式

这个函数用来设置surface格式。我们传递formats作为函数的参数,类型为SwapChainSupportDetails

VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {}

每个VkSurfaceFormatKHR结构都包含一个format和一个colorSpace成员。format成员变量指定色彩通道和类型。比如,VK_FORMAT_B8G8R8A8_UNORM代表了我们使用B,G,R和alpha次序的通道,且每一个通道为无符号8bit整数,每个像素总计32bits。colorSpace成员描述SRGB颜色空间是否通过VK_COLOR_SPACE_SRGB_NONLINEAR_KHR标志支持。需要注意的是在较早版本的规范中,这个标志名为VK_COLORSPACE_SRGB_NONLINEAR_KHR

如果可以我们尽可能使用SRGB(彩色语言协议),因为它会得到更容易感知的、精确的色彩。直接与SRGB颜色打交道是比较有挑战的,所以我们使用标准的RGB作为颜色格式,这也是通常使用的一个格式VK_FORMAT_B8G8R8A8_UNORM

最理想的情况是surface没有设置任何偏向性的格式,这个时候Vulkan会通过仅返回一个VkSurfaceFormatKHR结构表示,且该结构的format成员设置为VK_FORMAT_UNDEFINED

if (availableFormats.size() == 1 && availableFormats[0].format == VK_FORMAT_UNDEFINED) {return {VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR};
}

如果不能自由的设置格式,那么我们可以通过遍历列表设置具有偏向性的组合:

for (const auto& availableFormat : availableFormats) {if (availableFormat.format == VK_FORMAT_B8G8R8A8_UNORM && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {return availableFormat;}
}

如果以上两种方式都失效了,这个时候我们可以通过“优良”进行打分排序,但是大多数情况下会选择第一个格式作为理想的选择。

VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) {if (availableFormats.size() == 1 && availableFormats[0].format == VK_FORMAT_UNDEFINED) {return {VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR};}for (const auto& availableFormat : availableFormats) {if (availableFormat.format == VK_FORMAT_B8G8R8A8_UNORM && availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {return availableFormat;}}return availableFormats[0];
}

五、演示模式

presentation模式对于交换链是非常重要的,因为它代表了在屏幕呈现图像的条件。在Vulkan中有四个模式可以使用:

  1. VK_PRESENT_MODE_IMMEDIATE_KHR: 应用程序提交的图像被立即传输到屏幕呈现,这种模式可能会造成撕裂效果。
  2. VK_PRESENT_MODE_FIFO_KHR: 交换链被看作一个队列,当显示内容需要刷新的时候,显示设备从队列的前面获取图像,并且程序将渲染完成的图像插入队列的后面。如果队列是满的程序会等待。这种规模与视频游戏的垂直同步很类似。显示设备的刷新时刻被成为“垂直中断”。
  3. VK_PRESENT_MODE_FIFO_RELAXED_KHR: 该模式与上一个模式略有不同的地方为,如果应用程序存在延迟,即接受最后一个垂直同步信号时队列空了,将不会等待下一个垂直同步信号,而是将图像直接传送。这样做可能导致可见的撕裂效果。
  4. VK_PRESENT_MODE_MAILBOX_KHR: 这是第二种模式的变种。当交换链队列满的时候,选择新的替换旧的图像,从而替代阻塞应用程序的情形。这种模式通常用来实现三重缓冲区,与标准的垂直同步双缓冲相比,它可以有效避免延迟带来的撕裂效果。

逻辑上看仅仅VR_PRESENT_MODE_FIFO_KHR模式保证可用性,所以我们再次增加一个函数查找最佳的模式:

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR> availablePresentModes) {return VK_PRESENT_MODE_FIFO_KHR;
}

我个人认为三级缓冲是一个非常好的策略。它允许我们避免撕裂,同时仍然保持相对低的延迟,通过渲染尽可能新的图像,直到接受垂直同步信号。所以我们看一下列表,它是否可用:

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR> availablePresentModes) {for (const auto& availablePresentMode : availablePresentModes) {if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) {return availablePresentMode;}}return VK_PRESENT_MODE_FIFO_KHR;
}

遗憾的是,一些驱动程序目前并不支持VK_PRESENT_MODE_FIFO_KHR,除此之外如果VK_PRESENT_MODE_MAILBOX_KHR也不可用,我们更倾向使用VK_PRESENT_MODE_IMMEDIATE_KHR:

VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR> availablePresentModes) {VkPresentModeKHR bestMode = VK_PRESENT_MODE_FIFO_KHR;for (const auto& availablePresentMode : availablePresentModes) {if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) {return availablePresentMode;} else if (availablePresentMode == VK_PRESENT_MODE_IMMEDIATE_KHR) {bestMode = availablePresentMode;}}return bestMode;
}

六、交换范围

还剩下一个属性,为此我们添加一个函数:

VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {}

交换范围是指交换链图像的分辨率,它几乎总是等于我们绘制窗体的分辨率。分辨率的范围被定义在VkSurfaceCapabilitiesKHR结构体中。Vulkan告诉我们通过设置currentExtent成员的widthheight来匹配窗体的分辨率。然而,一些窗体管理器允许不同的设置,意味着将currentExtent的width和height设置为特殊的数值表示:uint32_t的最大值。在这种情况下,我们参考窗体minImageExtentmaxImageExtent选择最匹配的分辨率。

VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) {if (capabilities.currentExtent.width != std::numeric_limits<uint32_t>::max()) {return capabilities.currentExtent;} else {VkExtent2D actualExtent = {WIDTH, HEIGHT};actualExtent.width = std::max(capabilities.minImageExtent.width, std::min(capabilities.maxImageExtent.width, actualExtent.width));actualExtent.height = std::max(capabilities.minImageExtent.height, std::min(capabilities.maxImageExtent.height, actualExtent.height));return actualExtent;}
}

maxmin函数用于将WIDTHHEIGHT收敛在实际支持的minimummaximum范围中。在这里确认包含<algorithm>头文件。

七、创建交换链

现在我们已经有了这些辅助函数,用以在运行时帮助我们做出明智的选择,最终获得有了创建交换链所需要的所有信息。

创建一个函数createSwapChain,在initVulkan函数中,该函数会在创建逻辑设备之后调用。

void initVulkan() {createInstance();setupDebugCallback();createSurface();pickPhysicalDevice();createLogicalDevice();createSwapChain();
}void createSwapChain() {SwapChainSupportDetails swapChainSupport = querySwapChainSupport(physicalDevice);VkSurfaceFormatKHR surfaceFormat = chooseSwapSurfaceFormat(swapChainSupport.formats);VkPresentModeKHR presentMode = chooseSwapPresentMode(swapChainSupport.presentModes);VkExtent2D extent = chooseSwapExtent(swapChainSupport.capabilities);
}

实际上还有一些小事情需要确定,但是比较简单,所以没有单独创建函数。第一个是交换链中的图像数量,可以理解为队列的长度。它指定运行时图像的最小数量,我们将尝试大于1的图像数量,以实现三重缓冲。

uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1;
if (swapChainSupport.capabilities.maxImageCount > 0 && imageCount > swapChainSupport.capabilities.maxImageCount) {imageCount = swapChainSupport.capabilities.maxImageCount;
}

对于maxImageCount数值为0代表除了内存之外没有限制,这就是为什么我们需要检查。

与Vulkan其他对象的创建过程一样,创建交换链也需要填充大量的结构体:

VkSwapchainCreateInfoKHR createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
createInfo.surface = surface;

在指定交换链绑定到具体的surface之后,需要指定交换链图像有关的详细信息:

createInfo.minImageCount = imageCount;
createInfo.imageFormat = surfaceFormat.format;
createInfo.imageColorSpace = surfaceFormat.colorSpace;
createInfo.imageExtent = extent;
createInfo.imageArrayLayers = 1;
createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;

imageArrayLayers指定每个图像组成的层数。除非我们开发3D应用程序,否则始终为1。imageUsage位字段指定在交换链中对图像进行的具体操作。在本小节中,我们将直接对它们进行渲染,这意味着它们作为颜色附件。也可以首先将图像渲染为单独的图像,进行后处理操作。在这种情况下可以使用像VK_IMAGE_USAGE_TRANSFER_DST_BIT这样的值,并使用内存操作将渲染的图像传输到交换链图像队列。

QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
uint32_t queueFamilyIndices[] = {(uint32_t) indices.graphicsFamily, (uint32_t) indices.presentFamily};if (indices.graphicsFamily != indices.presentFamily) {createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT;createInfo.queueFamilyIndexCount = 2;createInfo.pQueueFamilyIndices = queueFamilyIndices;
} else {createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;createInfo.queueFamilyIndexCount = 0; // OptionalcreateInfo.pQueueFamilyIndices = nullptr; // Optional
}

接下来,我们需要指定如何处理跨多个队列簇的交换链图像。如果graphics队列簇与presentation队列簇不同,会出现如下情形。我们将从graphics队列中绘制交换链的图像,然后在另一个presentation队列中提交他们。多队列处理图像有两种方法:

  1. VK_SHARING_MODE_EXCLUSIVE: 同一时间图像只能被一个队列簇占用,如果其他队列簇需要其所有权需要明确指定。这种方式提供了最好的性能。
  2. VK_SHARING_MODE_CONCURRENT: 图像可以被多个队列簇访问,不需要明确所有权从属关系。

在本小节中,如果队列簇不同,将会使用concurrent模式,避免处理图像所有权从属关系的内容,因为这些会涉及不少概念,建议后续的章节讨论。Concurrent模式需要预先指定队列簇所有权从属关系,通过queueFamilyIndexCountpQueueFamilyIndices参数进行共享。如果graphics队列簇和presentation队列簇相同,我们需要使用exclusive模式,因为concurrent模式需要至少两个不同的队列簇。

createInfo.preTransform = swapChainSupport.capabilities.currentTransform;

如果交换链支持(supportedTransforms in capabilities),我们可以为交换链图像指定某些转换逻辑,比如90度顺时针旋转或者水平反转。如果不需要任何transoform操作,可以简单的设置为currentTransoform

createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;

混合Alpha字段指定alpha通道是否应用与与其他的窗体系统进行混合操作。如果忽略该功能,简单的填VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR

createInfo.presentMode = presentMode;
createInfo.clipped = VK_TRUE;

presentMode指向自己。如果clipped成员设置为VK_TRUE,意味着我们不关心被遮蔽的像素数据,比如由于其他的窗体置于前方时或者渲染的部分内容存在于可是区域之外,除非真的需要读取这些像素获数据进行处理,否则可以开启裁剪获得最佳性能。

createInfo.oldSwapchain = VK_NULL_HANDLE;

最后一个字段oldSwapChain。Vulkan运行时,交换链可能在某些条件下被替换,比如窗口调整大小或者交换链需要重新分配更大的图像队列。在这种情况下,交换链实际上需要重新分配创建,并且必须在此字段中指定对旧的引用,用以回收资源。这是一个比较复杂的话题,我们会在后面的章节中详细介绍。现在假设我们只会创建一个交换链。

现在添加一个类成员变量存储VkSwapchainKHR对象:

VkSwapchainKHR swapChain;

创建交换链只需要简单的调用函数:vkCreateSwapchainKHR:

if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain) != VK_SUCCESS) {throw std::runtime_error("failed to create swap chain!");
}

参数是逻辑设备,交换链创建的信息,可选择的分配器和一个存储交换后的句柄指针。它也需要在设备被清理前,进行销毁操作,通过调用vkDestroySwapchainKHR

void cleanup() {vkDestroySwapchainKHR(device, swapChain, nullptr);...
}

八、获取交换链图像

交换链创建后,需要获取VkImage相关的句柄。它会在后续渲染的章节中引用。添加类成员变量存储该句柄:

std::vector<VkImage> swapChainImages;

图像被交换链创建,也会在交换链销毁的同时自动清理,所以我们不需要添加任何清理代码。

我们在createSwapChain函数下面添加代码获取句柄,在vkCreateSwapchainKHR后调用。获取句柄的操作同之前获取数组集合的操作非常类似。首先通过调用vkGetSwapchainImagesKHR获取交换链中图像的数量,并根据数量设置合适的容器大小保存获取到的句柄集合。

vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr);
swapChainImages.resize(imageCount);
vkGetSwapchainImagesKHR(device, swapChain, &imageCount, swapChainImages.data());

需要注意的是,之前创建交换链步骤中我们传递了期望的图像大小到字段minImageCount。而实际的运行,允许我们创建更多的图像数量,这就解释了为什么需要再一次获取数量。

最后,存储交换链格式和范围到成员变量中。我们会在后续章节使用。

VkSwapchainKHR swapChain;
std::vector<VkImage> swapChainImages;
VkFormat swapChainImageFormat;
VkExtent2D swapChainExtent;...swapChainImageFormat = surfaceFormat.format;
swapChainExtent = extent;

现在我们已经设置了一些图像,这些图像可以被绘制,并呈现到窗体。

Vulkan 交换链详解相关推荐

  1. Token 防盗链详解

    前言 随着互联网的高速发展,无论是移动 APP 还是 WEB 站点,访问的安全问题始终困扰着内容提供商.CDN ( Content Delivery Network,内容分发网络 ) 服务作为当今互联 ...

  2. ARM 编译工具链详解

    ARM 编译工具链详解 GNU Arm Embedded Toolchain 是用于 C/C++ 和汇编编程的即用型开源工具套件.GNU Arm 嵌入式开发工具链适用于 32 位 Arm Cortex ...

  3. 包转发率交换容量详解

    包转发率交换容量详解 交换机的包转发率(吞吐量)指的是交换机转发数据包的能力,单位是pps(包每秒),也就是交换机每秒可以转发多少个数据包. 交换机接口速率:100Mbit/s的以太网接口,学过计算机 ...

  4. JavaScript作用域和作用域链详解

    JavaScript作用域链详解 一.JavaScript作用域 JavaScript作用域是什么? 作用域范围 二.JavaScript作用域链 作用域与执行上下文 总结 一.JavaScript作 ...

  5. 硬核!原型和原型链详解

    前言 我是歌谣 知其然知其所以然 人人都有一个大厂梦 希望通过自己的一个总结分享可以给予大家带来帮助和提升. 本期知识点 原型和原型链 目标 1理解原型和原型链 2理解构造函数 3理解构造函数 原型和 ...

  6. [区块链]详解以太坊标准发行通证Token代码(技术面看待ICO)

    /* This creates a public tradeable fungible token in the Ethereum Blockchain. https://github.com/eth ...

  7. 路由器/交换机/网络类型/数据交换方式详解

    思维导图: 路由器 路由器是连接因特网中各局域网.广域网的设备,它会根据信道的情况自动选择和设定路由,以最佳路径,按前后顺序发送信号的设备.路由器是互联网络的枢纽. 路由器的工作原理:3层设备:当数据 ...

  8. AME_Oracle自带AME审批链详解AME Standard Handler(概念)

    2014-05-30 Created By BaoXinJian Oracle 自带了3大类,13个子类的审批链Action Type, 对应了13个标准的AME Standard Handler 1 ...

  9. Pytorch中改变形状和交换维度详解:view()、reshape()、transpose()、permute()以及contiguous()

    文章目录 view()和reshape() transpose()和permute() contiguous 以后操作基于下述tensor. import torch a=torch.rand(2,2 ...

最新文章

  1. python list列表与array区别
  2. python pandas for循环_高逼格使用Pandas加速代码,向for循环说拜拜!
  3. springboot拦截请求路径_SpringBoot整合Ant Design Pro进行部署
  4. 如何理解拓扑排序算法(转)
  5. H5 移动端 获取腾讯地图计算两经纬度的实际距离(可批量)_多地打卡
  6. winform 让他间隔一段时间 执行事件 且只执行一次_记一次golang定时器引发的诡异错误...
  7. linux怎样使用小米线刷工具,在linux上怎么样线刷小米手机
  8. 分享为小程序添加自动回复消息的5种方法!自动客服功能的微信小程序
  9. 【vs】 试图加载格式不正确的程序
  10. 必须吹吹自己,太厉害了!-简直不敢相信,面试拼多多我只用了15天就成功拿下offer,
  11. eplan php文件夹,EPLAN P8 导入部件库的方法-mdb文件
  12. 2JS-操作BOM对象
  13. 关于pip install numpy
  14. 字体加密-58同城简历信息爬取
  15. 冷门项目玩法思路,小竞争大利润
  16. nextjs如何启动https服务
  17. 联想计算机boss设置,联想电脑如何进行bios设置 联想电脑bios设置教程
  18. PC端聊天机器人界面
  19. 网页设计作业 仿苏宁易购商城网站设计——仿苏宁易购官网商城(1页) HTML+CSS+JavaScript web网页大作业
  20. 运用计算机技术创设英语课堂问题场,多模态PPT在高中英语教学中的应用

热门文章

  1. php 验证身份证号码
  2. 如果让你来设计网络,如何让电脑互联?
  3. 360大安全情报局:网友对摄像头的关注较去年提升201.5%
  4. evaluate函数在python_Pandas探索之高性能函数eval和query解析
  5. 《解剖PetShop》系列之一
  6. c++设计模式-单例模式
  7. uniappH5+springboot微信授权登录获取用户数据(非静默授权)
  8. 怎样成为快速阅读的高手(上)
  9. 钉钉自定义机器人-后台开发
  10. 钉钉(第三方应用)“审批模板唯一编码”(process_code)字段如何获取与配置?