Vulkan多线程渲染底层优化:架构设计与同步策略实战
1. 项目概述为什么我们需要深入Vulkan多线程渲染的“深水区”如果你是一名图形程序员或者正在向这个方向努力那么“Vulkan”和“多线程渲染”这两个词对你来说一定不陌生。前者是Khronos组织推出的新一代跨平台图形与计算API以其极致的性能和灵活的控制著称后者则是榨干现代多核CPU性能、突破渲染瓶颈的必经之路。然而当这两个词组合在一起时事情就变得不那么简单了。市面上大多数关于Vulkan多线程渲染的教程往往停留在“如何创建多个Command Buffer”或者“如何用线程池提交命令”的层面。这就像教你开车只告诉你方向盘、油门、刹车在哪却没告诉你如何在复杂的山路上进行跟趾动作、如何精准地走线过弯。今天我们要聊的正是这片“复杂的山路”——Vulkan 1.4 C多线程渲染的底层优化实战。这不仅仅是关于API调用更是关于如何设计一套能够真正并行起来、且稳定高效的渲染架构。我之所以称其为“业界罕见的底层优化策略”是因为很多策略源于大型引擎或专业中间件的内部实践它们关注的是在Vulkan提供的极细粒度控制下如何规避驱动实现的差异、如何平衡线程间的负载、如何管理那些令人头疼的同步原语最终实现帧时间的稳定和吞吐量的飞跃。无论你是正在自研引擎还是希望优化现有渲染管线理解这些策略都能让你从“会用Vulkan”进阶到“精通Vulkan”。2. 核心架构设计从“能跑”到“跑得快”的思维转变在单线程渲染时代我们的思维是线性的准备数据 - 录制命令 - 提交命令 - 等待GPU。到了多线程时代这个线性流程必须被彻底打碎并重组。一个优秀的多线程渲染架构其核心目标是将CPU端的工作尽可能均匀地分摊到多个核心上同时确保对GPU的命令提交是高效且无冲突的。2.1 线程模型的抉择主从式 vs. 任务图式这是你面临的第一个重大选择。主从式模型简单直观一个主线程负责任务分发和同步多个工作线程执行具体的渲染任务如场景图遍历、可见性计算、命令录制。这种模型易于实现和调试但主线程容易成为瓶颈特别是在任务动态性强的场景中。而任务图式模型则更为先进和灵活。它将整个渲染帧的工作分解为一个个带有依赖关系的任务Task例如“阴影图渲染”、“GBuffer渲染”、“光照计算”、“后处理”。一个独立的调度器Scheduler根据任务间的依赖关系图动态地将任务分配到空闲的线程上执行。Vulkan 1.3引入的VK_KHR_synchronization2扩展和1.4对其的正式纳入为这种模型提供了更清晰的同步语义支持。我的选择与理由对于追求极致且场景复杂的项目我强烈建议向任务图模型靠拢。虽然初期搭建复杂但它能更好地适应动态负载实现更细粒度的并行。我们可以利用C17/20的std::async、std::future或更专业的任务库如EnkiTS、TaskFlow来构建基础。关键在于将每个渲染阶段Pass设计成一个或多个任务并明确定义其输入输出资源如哪些纹理、缓冲区会被读写。2.2 资源与内存管理的多线程化这是多线程渲染中最容易踩坑的地方。Vulkan对象如VkBufferVkImage本身是线程安全的吗答案是创建和销毁通常不是但使用可以是。对象生命周期管理vkCreate*和vkDestroy*调用必须在外部做好同步例如仅在主线程或一个专用管理线程中进行。一种常见的策略是使用“延迟删除队列”。当某个线程确定一个GPU资源如一个临时纹理不再需要时它并不直接销毁而是将其句柄和当前帧号推入一个线程安全的队列。主线程在每一帧结束时检查这个队列安全地销毁那些已过去若干帧确保GPU不再使用的资源。内存分配频繁的vkAllocateMemory是性能杀手。对于多线程我们需要一个线程安全的内存分配器。VulkanMemoryAllocatorVMA库是一个绝佳的选择。它本身提供了线程安全的分配接口。更进一步的优化是为每种类型的资源如Uniform Buffer、动态顶点缓冲区、贴图创建独立的内存池VkMemoryPool并让不同的线程从不同的池中分配这样可以减少分配时的锁竞争。描述符集管理描述符集是绑定资源纹理、缓冲区到着色器的关键。多线程下动态更新描述符集是个挑战。策略之一是“描述符集池化”。预先为每种常用的资源组合分配好描述符集例如“漫反射贴图法线贴图Uniform Buffer”。渲染时线程从池中取出一个空闲的描述符集用vkUpdateDescriptorSets快速更新其内容此命令在外部同步正确的情况下可多线程调用用完后归还。另一种更激进的策略是利用VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC和绑定偏移量避免每帧频繁更新描述符集。注意vkUpdateDescriptorSets的调用本身如果更新的是不同的描述符集对象理论上可以并行。但你必须确保传入的VkWriteDescriptorSet结构体及其指向的数据如VkDescriptorImageInfo在该函数调用期间是有效且稳定的。通常需要为每个线程准备独立的数据副本或使用线程本地存储。3. 命令录制与提交的并行化实战这是多线程渲染的核心战场。目标是将一帧中数以万计的命令录制工作分散到多个CPU核心上。3.1 命令缓冲区的分配策略不要在每个线程里为每一帧都创建新的命令缓冲区VkCommandBuffer那会带来巨大的开销。正确的做法是池化。每线程每帧池为每个工作线程维护一个命令缓冲区池。每一帧开始时线程从自己的池中取出或重置一个命令缓冲区。帧结束后提交并归还。这完全避免了线程间的锁竞争是性能最高的方式。你需要预估每个线程每帧可能需要的命令缓冲区数量例如主场景、阴影、UI各一个。// 简化示例线程本地命令池和缓冲区列表 thread_local VkCommandPool tl_CommandPool; thread_local std::vectorVkCommandBuffer tl_FrameCommandBuffers[FRAMES_IN_FLIGHT]; void WorkerThread::BeginFrame(int frameIndex) { auto buffers tl_FrameCommandBuffers[frameIndex]; if (buffers.empty()) { // 从tl_CommandPool分配新的缓冲区 VkCommandBufferAllocateInfo allocInfo{...}; vkAllocateCommandBuffers(device, allocInfo, newBuffer); buffers.push_back(newBuffer); } VkCommandBuffer cmd buffers.back(); vkResetCommandBuffer(cmd, 0); VkCommandBufferBeginInfo beginInfo{...}; vkBeginCommandBuffer(cmd, beginInfo); // ... 开始录制 }重置策略使用vkResetCommandBuffer显式重置而不是通过VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT标志重置整个池。前者更轻量且允许你对单个缓冲区进行精细控制。3.2 渲染通道与子通道的并行录制Vulkan的渲染通道VkRenderPass和子通道Subpass设计本身隐含了依赖关系。这为并行录制提供了天然的机会。粗粒度并行不同的渲染通道通常是独立的。例如渲染阴影贴图的通道和渲染主场景GBuffer的通道之间没有依赖关系。这两个通道的命令缓冲区完全可以由不同的线程同时录制。细粒度并行子通道内这是更高级的技巧。在一个复杂的渲染通道内例如包含多个几何子通道、一个光照子通道虽然子通道之间有依赖但录制命令本身可以并行。前提是线程间需要精确同步它们对共享资源如Attachment的写入顺序。这通常需要借助VkSubpassDependency在渲染通道定义中声明清晰的屏障并在录制命令时确保线程在录制依赖子通道的命令前通过CPU端的同步原语如条件变量等待前置子通道的录制完成。一个实战策略我将场景中的物体按材质、着色器或管线进行分组。每个分组对应一个绘制调用集合。在录制一个渲染通道时我使用一个并行算法如std::for_each 执行策略遍历这些分组每个工作项负责录制其对应分组的所有vkCmdDraw*命令到同一个命令缓冲区中。但这里有个关键vkCmdBindPipeline,vkCmdBindDescriptorSets,vkCmdBindVertexBuffers等绑定命令如果多个分组共享同一状态则需要谨慎处理避免重复绑定。我通常会实现一个“状态跟踪器”在录制每个分组前检查状态是否已变更只有变更时才录制绑定命令。3.3 命令提交的优化多个命令缓冲区录制好后需要提交到同一个队列VkQueue。vkQueueSubmit是线程安全的但频繁调用会有开销。批量化提交不要每个线程一录完就立即提交自己的命令缓冲区。而是让每个线程将其录好的命令缓冲区放入一个线程安全的列表。在一帧的CPU工作末尾例如在“等待上一帧Present完成”之后由主线程一次性收集所有命令缓冲区通过一次或少数几次vkQueueSubmit调用提交。这减少了驱动层内部的同步开销。信号量与栅栏的同步多线程下信号量VkSemaphore和栅栏VkFence的管理必须格外小心。一个黄金法则是用于同步GPU工作阶段的信号量其提交和等待操作在vkQueueSubmit的pWaitSemaphores和pSignalSemaphores中必须在同一调用中配对管理。不要试图让一个线程提交信号量A另一个线程在未来的提交中等待A这需要极其复杂的CPU端同步来保证顺序正确极易出错。更好的做法是由负责最终批量化提交的线程统一管理这一帧所有跨通道的GPU信号量。4. 同步的艺术规避数据竞争与GPU停顿Vulkan将同步的责任完全交给了开发者。在多线程渲染中同步发生在两个层面CPU线程间同步以及CPU与GPU间/GPU内部的同步。4.1 CPU线程间的同步目标是尽量减少锁的使用。高频锁会成为性能瓶颈。无锁队列用于任务派发、资源删除通知等场景。C11的原子操作足以实现一个简单的多生产者单消费者MPSC或无锁队列也可以使用moodycamel::ConcurrentQueue这类成熟库。线程本地存储TLS大量使用。每个线程持有自己的命令池、描述符集池、临时内存分配器、渲染状态缓存等。这消除了绝大部分线程间共享数据的需要。阶段屏障用于同步渲染流程的不同阶段。例如所有线程必须完成“场景裁剪”阶段后才能进入“命令录制”阶段。可以使用std::barrierC20或自己用条件变量实现。我在实践中会为每一帧维护一个简单的状态机线程通过原子变量来推进和等待状态。4.2 GPU同步的精细控制这是Vulkan多线程优化的精髓也是驱动实现差异最大的地方。屏障Barrier的使用哲学屏障是昂贵的。我们的原则是需要多少屏障就用多少但一个能解决的绝不用两个。布局转换屏障 vs. 内存屏障如果只是为了转换图像布局如UNDEFINED-COLOR_ATTACHMENT_OPTIMAL使用VkImageMemoryBarrier并仅设置oldLayout和newLayout将srcStageMask和dstStageMask设为VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT和VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT这是一种高效的“软”转换。只有当需要保证之前对资源的内存写入对之后的操作可见时才需要设置完整的内存访问掩码srcAccessMask,dstAccessMask。区域化屏障VK_KHR_synchronization2扩展这是Vulkan 1.4的核心优势。它允许你为屏障指定更精细的VkImageSubresourceRange。例如你可以在渲染完阴影贴图的第0层mip后仅为此层mip设置一个屏障然后立即开始渲染第1层mip或许用于级联阴影而无需等待整个贴图渲染完成。这极大地提升了流水线利用率。渲染通道内的隐式同步善用VkSubpassDependency。在渲染通道创建时明确定义子通道之间的依赖关系Vulkan驱动会据此插入最优的隐式屏障。这比你在命令缓冲区里手动插入显式屏障通常更高效。仔细分析你的子通道读写关系定义精确的srcStageMask,dstStageMask,srcAccessMask,dstAccessMask。管线屏障与事件对于复杂的、非渲染通道内的同步VkEvent可能比全局的管线屏障vkCmdPipelineBarrier更高效。你可以在第一个命令缓冲区中设置一个事件vkCmdSetEvent在依赖它的第二个命令缓冲区中等待该事件vkCmdWaitEvents。这允许GPU在更细的粒度上继续执行不相关的工作而不是在屏障处完全停滞整个管线阶段。实操心得不同GPU厂商的驱动对屏障和事件的优化策略不同。在NVIDIA硬件上事件的开销可能很小能带来收益而在某些移动端或AMD的驱动上过于细碎的事件可能反而会带来开销。因此实现一个可配置的同步策略管理器是值得的允许针对不同平台选择“激进”多用事件或“保守”多用屏障的同步模式并通过性能分析工具如RenderDoc, NVIDIA Nsight来验证和调优。5. 性能剖析与调试让优化有的放矢没有测量的优化是盲目的。多线程渲染引入了新的复杂性必须依靠强大的工具。CPU性能分析使用tracy、Superluminal或Intel VTune。你需要关注线程负载均衡各个工作线程的忙闲程度是否均匀是否存在某个线程长期是热点锁竞争查看锁等待的时间。如果你发现某个自旋锁或互斥锁的等待时间很长说明它成了瓶颈。任务调度开销任务图调度器本身的开销是否过大GPU性能分析使用RenderDoc、NVIDIA Nsight Graphics或AMD RGP。GPU时间线查看各个命令缓冲区的执行是否真的在时间上重叠了还是因为同步原语使用不当导致了大量的GPU空闲气泡屏障与事件这些工具可以可视化你插入的每一个屏障和事件看看它们是否导致了不必要的管线停顿。资源转换查看图像布局转换发生的时机和频率是否过于频繁Vulkan验证层Validation Layers在开发阶段务必全程开启。特别是synchronization-2和core_validation层。它们能捕捉到绝大部分错误的屏障使用、资源竞争和内存访问错误。多线程下的错误往往是偶发且难以复现的验证层是你最可靠的防线。自定义统计与调试在代码中嵌入性能计数器。例如统计每一帧每个线程录制的命令数量、三角形数量、提交的缓冲区大小。可以输出到屏幕或日志文件便于实时监控和对比优化前后的效果。实现一个简单的“调试标记”系统使用vkCmdBeginDebugUtilsLabelEXT和vkCmdEndDebugUtilsLabelEXT在GPU分析工具中为不同的渲染阶段或线程任务打上彩色标签让时间线一目了然。6. 进阶策略与未来展望当你掌握了上述基础后可以探索一些更前沿的优化策略。异步计算Async Compute利用独立的计算队列将一些与图形渲染无关或弱相关的工作如粒子模拟、遮挡剔除、图像处理剥离出来与图形渲染并发执行。这需要精心管理图形队列与计算队列之间的资源共享与同步使用信号量。Vulkan 1.4的同步2特性让这变得更清晰。设备生成命令Device-Generated Commands这是Vulkan的一个高级特性包括间接绘制、顶点索引等。其核心思想是由GPU通过计算着色器来决定绘制什么、绘制多少。这可以将CPU从繁重的场景管理和命令录制中进一步解放出来实现所谓的“GPU-Driven Rendering”。这对多线程架构是一个很好的补充CPU线程可以专注于更高层次的任务调度和数据准备。动态渲染Dynamic RenderingVulkan 1.3引入的VK_KHR_dynamic_rendering扩展允许你不使用传统的VkRenderPass对象而是直接在命令缓冲区中指定附件和渲染区域。这简化了渲染流程的创建特别适合需要动态改变附件或渲染过程的现代渲染技术如延迟渲染。在多线程环境下它减少了线程间需要共享的渲染通道对象可能简化架构。最后我想分享一点个人体会。Vulkan多线程渲染的优化是一个从“宏观架构”到“微观指令”都需要深思熟虑的过程。它没有银弹最好的策略永远是针对你的具体应用场景是开放世界游戏还是CAD软件进行度量和调整。开始时可以优先实现一个清晰、正确的多线程框架哪怕它一开始并不是最快的。然后借助强大的剖析工具找到真正的性能热点再运用本文提到的策略进行精准打击。记住可维护性和可调试性同样重要一个为了极致的5%性能提升而变得无法阅读和调试的代码库其长期成本可能远超收益。保持架构的整洁让优化策略模块化、可配置是你能在这条路上走得更远的关键。

相关新闻

小龙虾助手下载安装,OpenClaw中文版一键部署工具

小龙虾助手下载安装,OpenClaw中文版一键部署工具

为什么我需要小龙虾助手?——一个菜鸟的惨痛经历 说起来挺丢人的。前阵子公司项目要用到OpenClaw做模型推理,领导丢给我一个链接说“你搞一下”。我当时心想,不就是装个环境嘛,Python、CUDA、PyTorch我熟啊。结果一上手就傻眼了—…

2026/10/14 15:08:26 阅读更多 →
树莓派3B变身软路由:OpenWrt下WAN/LAN/WiFi接口配置保姆级教程(含UCI命令详解)

树莓派3B变身软路由:OpenWrt下WAN/LAN/WiFi接口配置保姆级教程(含UCI命令详解)

树莓派3B打造高性能软路由:OpenWrt网络配置全解析与实战技巧 家里闲置的树莓派3B除了跑跑小项目,还能做什么?把它改造成一台功能强大的软路由或许是最佳选择。相比动辄上千元的商用路由器,树莓派软路由不仅成本低廉,还能实现广告过滤、多拨加速、流量监控等进阶功能。本文…

2026/10/5 5:41:14 阅读更多 →
ShaderGraph立方体贴图采样节点:从原理到实战应用全解析

ShaderGraph立方体贴图采样节点:从原理到实战应用全解析

1. 项目概述:为什么我们需要采样立方体贴图节点?在ShaderGraph的世界里,我们经常需要处理各种纹理来为模型增添细节。对于平面纹理,我们使用Sample Texture 2D节点,这很直观。但当你需要模拟一个无限延伸的环境&#x…

2026/10/14 1:05:24 阅读更多 →

最新新闻

Python编程核心优势与工程实践指南

Python编程核心优势与工程实践指南

1. 为什么Python值得你投入时间?如果你已经掌握C/Java这类静态语言,第一次接触Python时可能会产生"这也算编程语言?"的疑惑。没有类型声明、没有分号、甚至没有大括号,这种极简风格正是Python的设计哲学。我在2010年从J…

2026/10/14 15:08:50 阅读更多 →
阿里云Maven镜像settings.xml配置避坑指南

阿里云Maven镜像settings.xml配置避坑指南

简介:本资源是一份开箱即用的Maven加速配置文件,专为Java开发者、初学者及企业开发团队设计,解决默认中央仓库下载依赖慢、构建卡顿的核心痛点。资源仅含1个XML文件(settings.xml),大小仅711B,可…

2026/10/14 15:08:50 阅读更多 →
Display技术全解析:从信号链路到同步机制与参数调优

Display技术全解析:从信号链路到同步机制与参数调优

1. 什么是Display?从一块屏幕说起Display这个词,在中文语境里常被直译为“显示”,但实际远不止“把东西亮出来”这么简单。它是一整套从信号生成、数据传输、图像处理到物理发光的完整链路,是人与数字世界之间最直接、最频繁的交互…

2026/10/14 15:08:50 阅读更多 →
VS2010 下编译 libcurl 7.64.1 实战:兼容 C99 与静态库配置

VS2010 下编译 libcurl 7.64.1 实战:兼容 C99 与静态库配置

简介:这份资源面向需要在 Visual Studio 2010 环境下集成网络传输能力的 C/C 开发者,提供一套可直接编译通过的 libcurl 库文件,并兼容 VS2008。它重点解决了 VS2010 Release 模式下常见的外部链接错误,省去自行配置预处理器定义、…

2026/10/14 15:08:50 阅读更多 →
Node.js与Vue构建共享单车租赁报修系统实战

Node.js与Vue构建共享单车租赁报修系统实战

1. 项目概述这个基于Node.js和Vue的共享单车租赁报修信息系统,本质上是一个面向现代城市共享单车运营的数字化管理解决方案。我在实际开发过程中发现,这类系统最核心的价值在于将传统的线下单车管理流程全面数字化,同时为运营方和用户提供双向…

2026/10/14 15:08:50 阅读更多 →
Spring MVC请求转发与重定向详解及实践

Spring MVC请求转发与重定向详解及实践

1. 请求转发与重定向的本质区别1.1 工作流程对比请求转发(Forward)和重定向(Redirect)在Spring MVC中代表着两种完全不同的跳转机制。请求转发发生在服务器内部,整个过程对客户端透明。当我们在Controller中返回"…

2026/10/14 15:07:45 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →