// 输入一个颜色,输出映射后的颜色

vec3 tonemap(vec3 color) { ... }

```

重要提示:作业必须在提供的 homework1 框架基础上修改,不要提交其他框架的代码。

参考资料与示例

为了帮助大家完成作业,框架中提供了多个示例代码,可以通过取消注释 setupWorkingExample 函数中的相应行来启用和运行:

  • gltf_skin:演示如何读取 glTF 骨骼动画。
  • pbr_base:演示基于方向光的 PBR 实现。
  • example_pbr_ibl:演示基于环境光的 PBR 实现。

关于 glTF 格式中骨骼和材质的详细描述,可以参考 glTF 官方文档。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_38.png

Vulkan 对象创建 🏗️

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_40.png

上一节我们概述了完整的渲染管线。本节中,我们来看看管线每个阶段所对应的 Vulkan 对象及其创建方法。

Vulkan 绘制管线中涉及的主要对象包括:

  • BufferImage:用于存储顶点、索引、 uniform 数据和纹理。
  • Pipeline:定义整个图形绘制管线的状态和着色器。
  • Descriptor Set:用于向管线传递 uniform 变量等资源。

对象创建通用模式

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_42.png

Vulkan 创建对象的函数遵循统一的模式:vkCreate[Object]。例如,创建缓冲区的函数是 vkCreateBuffer

该模式通常包含以下参数:

  1. VkDevice:逻辑设备。
  2. 描述要创建对象的结构体(例如 VkBufferCreateInfo)。
  3. 自定义内存分配器(通常可传 nullptr 使用默认分配器)。
  4. 返回创建对象句柄的指针。

所有 Vulkan 函数都通过返回 VkResult 来指示操作成功或错误类型。

创建 Buffer 的流程

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_44.png

以下是创建一个 Uniform Buffer 的典型步骤,创建 Image 的流程与此类似:

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_46.png

  1. 创建 Buffer 对象:使用 vkCreateBuffer,并通过 VkBufferCreateInfo 指定其用途(如 VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT)和大小。
  2. 查询内存需求:使用 vkGetBufferMemoryRequirements 获取该 Buffer 实际需要的内存大小和对齐要求。这里查询的大小可能与申请时指定的大小不同,因为驱动可能出于优化目的进行内存对齐。
  3. 分配内存:使用 vkAllocateMemory 分配一块符合要求的内存。需要根据内存用途(CPU可见、GPU本地等)指定正确的内存类型。
  4. 绑定内存与 Buffer:使用 vkBindBufferMemory 将分配的内存与创建的 Buffer 对象绑定起来,offset 参数通常为 0。

关键点:Vulkan 允许开发者自定义内存分配器来管理 CPU 端的内存分配。而 GPU 内存的分配和管理,Vulkan 也提供了一套机制,我们将在内存管理部分详细讨论。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_48.png


Render Pass 与 Framebuffer 🖼️

在配置好绘制所需的数据后,我们需要定义绘制的目标(输出到哪里)以及绘制过程中的状态。这就是 Render Pass 和 Framebuffer 的作用。

Render Pass

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_50.png

Render Pass 对象描述了单个渲染过程中涉及的附件(Attachment)、子通道(Subpass)以及它们之间的依赖关系。

它主要定义两部分:

  1. 附件描述(Attachment Description):定义输出目标(如颜色附件、深度附件)的格式、在渲染开始和结束时的操作(如清除、存储)以及内存布局(Layout)。
  2. 子通道描述(Subpass Description):定义该子通道引用哪些附件作为输入/输出,以及多个子通道之间的依赖关系。

在代码框架的 setupRenderPass 函数中,我们定义了两个附件(颜色附件和深度附件),并设置它们在渲染前执行清除操作(loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR),在渲染后存储结果(storeOp = VK_ATTACHMENT_STORE_OP_STORE)。同时,也定义了这些附件在进入和离开 Render Pass 时的布局状态。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_52.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_54.png

关于 Layout 的理解VkImageLayout 表示图像在不同用途下的内存布局状态。对于初学者,可以将其简单理解为图像的一种“使用状态”,例如“作为颜色附件”、“作为被采样的纹理”、“作为拷贝源”等。以状态机的方式理解比记忆具体布局更容易。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_56.png

Framebuffer

如果说 Render Pass 定义了渲染过程的“模板”或“流程”,那么 Framebuffer 就是根据这个模板,指定本次渲染具体使用哪些图像视图(Image View)作为附件。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_58.png

在代码的 setupFramebuffers 函数中,我们为交换链(Swapchain)中的每一个图像都创建了一个 Framebuffer。每个 Framebuffer 都绑定了对应的交换链图像视图作为颜色附件,以及一个公共的深度图像视图作为深度附件。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_60.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_62.png


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_64.png

Pipeline 与管线布局 ⚙️

定义了渲染目标和流程后,我们需要创建最重要的对象之一:图形管线(Graphics Pipeline)。它定义了从顶点输入到最终颜色输出的完整处理过程。

Pipeline 创建

创建 Pipeline 需要填充一个庞大的 VkGraphicsPipelineCreateInfo 结构体,它包含了管线所有阶段的状态:

  • 顶点输入状态:定义顶点数据的格式和绑定方式。
  • 着色器阶段:指定编译好的顶点着色器和片元着色器模块。
  • 视口与裁剪状态:定义输出图像的区域和裁剪规则。
  • 光栅化状态:定义多边形填充模式、是否剔除背面等。
  • 多重采样状态:定义抗锯齿相关设置。
  • 深度与模板测试状态
  • 颜色混合状态:定义多个颜色附件如何混合。
  • 动态状态:定义哪些状态可以在绘制时动态修改(如视口大小)。
  • 管线布局:定义描述符集(Descriptor Set)和推送常量(Push Constant)的布局。
  • Render Pass:指定该管线所兼容的 Render Pass。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_66.png

在作业框架中,createPipeline 函数详细展示了如何设置这些状态。例如,它定义了顶点数据的四个属性(位置、法线、UV、颜色),并绑定了对应的着色器。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_68.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_70.png

着色器编译

Vulkan 使用 SPIR-V 字节码格式的着色器。我们可以使用 Vulkan SDK 中的工具(如 dxcglslangValidator)将高级着色语言(HLSL/GLSL)编译为 SPIR-V。

例如,使用 dxc 编译一个 HLSL 片元着色器的命令可能如下:


dxc -T ps_6_0 -E main -spirv -fspv-target-env=vulkan1.2 Shader.hlsl -Fo Shader.frag.spv

作业提交时,请记得附上你编译好的着色器文件。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_72.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_73.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_75.png

管线布局与描述符集

管线布局(Pipeline Layout)定义了着色器可以访问哪些资源,以及这些资源的组织方式。资源通过描述符集(Descriptor Set)进行分组和绑定。

描述符集就像一个资源表,表中的每个条目(描述符)可以是一个 Uniform Buffer、一个纹理采样器或一个存储图像等。将资源分组到不同的描述符集有利于高效更新。

在作业框架的着色器中:

  • 绑定到 set = 0, binding = 0 的是场景级的 Uniform Buffer(包含相机矩阵、灯光等),所有模型共享。
  • 绑定到 set = 1, binding = 0 的是材质级的 Uniform Buffer 和纹理,每个材质独有。

在绘制代码中,我们先绑定 set = 0 的描述符集(场景数据),然后在绘制每个模型或子网格时,再绑定其对应的 set = 1 描述符集(材质数据)。这种分离使得全局数据和频繁变化的数据可以独立、高效地更新。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_77.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_79.png

Vulkan 内存管理 💾

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_81.png

Vulkan 赋予了开发者精细控制内存的能力,理解内存类型和属性对于性能优化至关重要。

内存架构与类型

在典型的台式机架构中,CPU 和 GPU 拥有各自独立的内存,通过 PCIe 总线连接。Vulkan 内存根据其属性主要分为几种类型,可以通过 vkGetPhysicalDeviceMemoryProperties 查询:

  1. DEVICE_LOCAL:GPU 本地内存,GPU 访问速度最快,CPU 通常不能直接访问。适合存储频繁被 GPU 读写的数据,如帧缓冲、深度缓冲。
  2. HOST_VISIBLE:CPU 可见内存,CPU 可以映射(map)并读写。适合用于需要从 CPU 频繁更新的数据,如每帧变化的 Uniform Buffer。
  3. HOST_COHERENT:保证 CPU 和 GPU 对该内存的写入相互立即可见,无需手动刷新缓存。通常与 HOST_VISIBLE 一起使用。
  4. HOST_CACHED:CPU 端缓存该内存,适合 CPU 需要频繁读取的数据(如回读渲染结果)。

注意:在集成显卡或移动 SoC 上,CPU 和 GPU 可能共享物理内存,情况会有所不同。

内存分配策略

直接使用 Vulkan 原生接口管理内存非常复杂。AMD 提供了一个开源库 Vulkan Memory Allocator (VMA),它极大地简化了内存管理。

VMA 将内存用途抽象为更易理解的类型,例如:

  • VMA_MEMORY_USAGE_GPU_ONLY:仅 GPU 使用,如纹理、静态顶点缓冲。
  • VMA_MEMORY_USAGE_CPU_TO_GPU:从 CPU 上传到 GPU,如每帧更新的 Uniform Buffer。
  • VMA_MEMORY_USAGE_CPU_ONLY:暂存内存(Staging Buffer),用于高效的 CPU 到 GPU 数据传输。

高效数据上传:Staging Buffer

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_83.png

对于需要每帧从 CPU 更新到 GPU 的数据(如 Uniform Buffer),一个高效的策略是使用 Staging Buffer

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_85.png

工作流程如下:

  1. 创建一块 HOST_VISIBLEHOST_COHERENT 的内存作为 Staging Buffer。
  2. CPU 将数据写入 Staging Buffer(通过 vkMapMemory/vkUnmapMemory)。
  3. 在命令缓冲区中,记录一个从 Staging Buffer 拷贝到最终 GPU Buffer 的命令(vkCmdCopyBuffer)。
  4. 提交命令缓冲区执行。

优势

  • 减少同步:拷贝操作在 GPU 命令流中,与渲染有序进行,避免了 CPU 直接写入 GPU 内存可能需要的显式同步。
  • 提高性能:Staging Buffer 通常位于一块 CPU 和 GPU 都能高效访问的特殊内存区域。
  • 简化逻辑:避免了在 GPU 使用资源时,CPU 误写入导致的数据竞争问题。

作业框架中更新 Uniform Buffer 使用的是简单的 map/unmap 方式。作为练习,大家可以尝试将其改造为使用 Staging Buffer 的方式。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_87.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_89.png

Vulkan 绘制命令记录 🖌️

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_91.png

Vulkan 的绘制是异步的。CPU 将绘制命令记录到命令缓冲区(Command Buffer)中,然后提交给 GPU 的队列执行。

命令记录流程

一个基本的绘制命令序列如下:

  1. vkBeginCommandBuffer:开始记录命令。
  2. vkCmdBeginRenderPass:开始一个 Render Pass,定义渲染目标和作用域。
  3. 设置视口和裁剪矩形(vkCmdSetViewport, vkCmdSetScissor)。
  4. 绑定图形管线(vkCmdBindPipeline)。
  5. 绑定顶点缓冲区和索引缓冲区(vkCmdBindVertexBuffers, vkCmdBindIndexBuffer)。
  6. 绑定描述符集(vkCmdBindDescriptorSets)。
  7. 记录绘制命令(vkCmdDrawvkCmdDrawIndexed)。
  8. vkCmdEndRenderPass:结束 Render Pass。
  9. vkEndCommandBuffer:结束命令记录。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_93.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_95.png

在作业框架的 buildCommandBuffers 函数中,我们可以看到这个流程的具体实现。它绑定了场景的全局描述符集(set = 0),然后遍历 glTF 模型的节点和网格,为每个材质绑定其独有的描述符集(set = 1),并推送模型矩阵(使用 Push Constant),最后发起绘制调用。

命令缓冲区的重用

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_97.png

Vulkan 的一个高效特性是命令缓冲区可以重用。在作业框架中,命令缓冲区在初始化时创建并记录一次。在后续每帧渲染时,只要绘制内容没有变化,就直接提交已记录好的命令缓冲区,无需 CPU 再次介入记录。这显著降低了 CPU 开销,使其能专注于逻辑、模拟等任务。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_99.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_101.png


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_103.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_105.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_107.png

调试工具与方法 🔧

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_109.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_111.png

最后,我们来介绍一些强大的 Vulkan 调试和性能分析工具。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_113.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_115.png

1. RenderDoc

RenderDoc 是一个功能强大的图形调试器,支持 Vulkan。

主要功能

  • 抓帧分析:捕获某一帧或连续几帧的所有 GPU 命令。
  • 资源检查:查看任意时刻的缓冲区、纹理内容。
  • 管线状态调试:查看绘制调用时管线各个阶段的状态、绑定的资源、着色器代码。
  • 动态着色器编辑:支持在调试器中直接修改着色器代码并实时查看效果,无需重新编译程序。这对于调试着色器逻辑非常方便。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_117.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_119.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_121.png

局限性:其性能分析(Profiling)功能可能不够精确。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_123.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_125.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_126.png

2. NVIDIA Nsight Graphics

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_128.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_130.png

对于 NVIDIA 显卡用户,Nsight Graphics 是性能分析和调试的利器。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_132.png

主要功能

  • 精确的性能分析:提供以微秒为单位的 GPU 时间线分析,能准确找到性能瓶颈。
  • 帧调试:类似 RenderDoc 的抓帧和状态检查功能。
  • 着色器性能分析:可以对特定的着色器进行底层性能分析和统计。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_134.png

3. 其他平台工具

  • 高通 Snapdragon Profiler:用于在高通设备上进行图形性能分析。
  • Arm Mobile Studio:包含 Graphics Analyzer 和 Performance Analyzer,适用于 Arm Mali GPU 的设备。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_136.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_138.png


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_140.png

总结 🎯

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_142.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_144.png

本节课我们一起深入学习了 Vulkan 图形绘制流水线的核心实践部分:

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_146.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_148.png

  1. 作业详解:明确了第一次作业的目标——实现 glTF 模型的骨骼动画、PBR 材质渲染,并通过额外通道实现色调映射。
  2. 对象创建:学习了 Vulkan 创建 Buffer、Image、Pipeline 等核心对象的通用模式和具体步骤。
  3. 流程定义:理解了 Render Pass 和 Framebuffer 如何定义渲染目标和流程。
  4. 内存管理:探讨了 Vulkan 复杂的内存类型系统,并介绍了使用 VMA 库和 Staging Buffer 策略来简化管理并提升数据上传效率。
  5. 命令记录:掌握了 Vulkan 异步绘制命令的记录、提交和重用机制。
  6. 调试工具:了解了 RenderDoc 和 NVIDIA Nsight Graphics 等强大工具,用于调试渲染错误和分析性能瓶颈。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_150.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_152.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c93999a69e3717f65bb2911756bbe5a1_154.png

通过本课的学习,你应该对如何使用 Vulkan 组织一次完整的绘制有了更清晰的认识。下节课我们将探讨 Vulkan 的多线程同步机制以及在移动平台上的优化实践。

GAMES106-现代图形绘制流水线原理与实践—P4-4–图形绘制流水线的基本原理与实践-三----GAMES-Webinar—BV1Uo4y1J7ie_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_1.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_3.png

在本节课中,我们将学习 Vulkan 图形 API 中的多线程同步机制、Frame Graph 技术以及移动端常见的优化实践。课程将涵盖同步原语的使用、资源依赖管理以及针对移动设备架构的性能优化策略。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_5.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_7.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_9.png


Vulkan 同步原语 🔒

上一节我们介绍了 Vulkan 的绘制对象与内存管理。本节中,我们来看看 Vulkan 中用于协调 CPU 与 GPU 以及 GPU 内部命令执行顺序的同步原语。理解这些同步机制对于编写高效、正确的 Vulkan 程序至关重要。

Vulkan 的多线程框架基本结构如下:在 CPU 端(Host)有若干条命令缓冲区(Command Buffer),每条命令缓冲区是 GPU 命令的堆栈。CPU 通过队列(Queue)将命令缓冲区提交给 GPU 执行。

Vulkan 主要提供了三种同步信号:

  1. 栅栏 (Fence)
  2. 事件 (Event)
  3. 信号量 (Semaphore)

此外,还有用于管理内存资源读写的屏障 (Barrier)

1. 栅栏 (Fence)

栅栏是粒度最大的一种同步原语,它用于同步一整条命令缓冲区的执行完成状态。

  • 工作原理:CPU 将命令缓冲区提交给 GPU 后,GPU 会在该命令缓冲区全部执行完毕后,改变对应栅栏的状态。CPU 端可以通过查询(vkGetFenceStatus)或等待(vkWaitForFences)这个状态来判断命令是否执行完成。
  • 等待方式vkWaitForFences 会依赖于操作系统的调度,让线程进入休眠,从而节省 CPU 资源。当然,也可以使用类似自旋锁(spin lock)的方式循环查询状态。
  • 类比:可以近似等价于 OpenGL 中的 glFinish,用于保证所有 GPU 调用都已完成。

2. 事件 (Event)

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_11.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_13.png

事件的同步粒度比栅栏更细,它可以标记命令缓冲区中某个特定操作的完成。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_15.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_17.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_19.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_21.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_22.png

  • 工作原理:可以在一条命令的结束时设置事件状态(例如,标记为“已完成”)。CPU 和 GPU 都可以设置事件,但只有 GPU 的命令可以等待事件。CPU 只能查询事件状态,不能像等待栅栏那样阻塞等待。
  • 应用场景:常用于构建命令之间的依赖关系,类似于拓扑排序。例如,后处理 Pass 需要等待几何渲染 Pass 完成,就可以通过事件来通知。
  • 额外用途:可用于手动实现 GPU 性能分析。通过创建事件并在不同操作点设置,然后由一个线程查询事件完成时间,可以记录每个 GPU 操作的耗时。

3. 信号量 (Semaphore)

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_24.png

信号量主要用于GPU 端不同队列(Queue)之间同一队列内命令缓冲区之间的同步。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_26.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_28.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_30.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_32.png

  • 工作原理:它表示一条命令缓冲区需要等待另一条命令缓冲区执行完成后,才能在 GPU 上开始执行。CPU 端不能直接设置或等待信号量,它是纯粹给 GPU 内部使用的同步机制。
  • 与事件的区别:信号量同步的粒度是整条命令缓冲区,而事件可以同步到缓冲区内的某个具体操作。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_34.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_36.png

4. 屏障 (Barrier)

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_38.png

屏障与其他原语不同,它主要用于管理内存资源的读写顺序和一致性,解决资源竞争问题。

  • 类型:分为全局内存屏障(Memory Barrier)、缓冲区屏障(Buffer Barrier)和图像屏障(Image Barrier)。
  • 作用:确保在屏障之前的所有命令对资源的读写操作完成后,屏障之后的命令才能访问该资源。例如,防止一个 Pass 在读某个纹理时,另一个 Pass 同时在写这个纹理。
  • 使用思路:在实际开发中,可以简化理解为:当资源的使用方式发生改变时(例如,从“渲染目标”变为“着色器只读资源”),就需要插入一个屏障。虽然底层实现更复杂,但遵循这个原则通常可以保证正确性。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_40.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_42.png

优化提示:应尽量减少屏障的使用。频繁改变资源状态(读->写->读)会影响性能。可以通过优化渲染流程,将同类操作(如所有写操作)集中在一起,来减少状态切换。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_44.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_46.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_48.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_50.png

Frame Graph 技术 🗺️

了解了基础的同步原语后,我们面临一个问题:在复杂的多 Pass 渲染流程中,如何清晰地管理资源依赖并实现高效的同步?这就引入了 Frame Graph 技术。

Frame Graph 是一种声明式的渲染框架设计,它将一帧内的所有渲染 Pass 及其资源依赖关系描述为一个有向无环图(DAG)。

为什么需要 Frame Graph?

  1. 管理复杂依赖:现代渲染管线包含许多 Pass(如深度预渲染、SSAO、阴影、光照、后处理等),Pass 之间存在着复杂的输入输出依赖。Frame Graph 能清晰地表达这些依赖关系。
  2. 自动同步:基于依赖图,Frame Graph 可以自动在需要的地方插入正确的同步原语(信号量、事件、屏障),避免手动同步容易出错的问题。
  3. 资源生命周期管理:Frame Graph 可以识别出哪些资源在某个 Pass 之后就不再使用,从而允许复用内存,减少内存分配开销。
  4. 优化与调试:可以方便地启用/禁用某个特性(如关闭 SSAO),Frame Graph 会自动剔除相关的 Pass 和资源,便于性能分析和调试。

Frame Graph 示例

一个简单的延迟渲染管线 Frame Graph 可能包含以下节点和边:

  • 蓝色节点:渲染 Pass(如 DepthPrePass, SSAOPass, LightingPass)。
  • 橙色节点:资源(如 DepthBuffer, NormalBuffer, AlbedoBuffer)。
  • :表示 Pass 对资源的读写依赖。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_52.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_54.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_56.png

通过 Frame Graph 编译(Compile)阶段,系统可以自动计算出最优的 Pass 执行顺序和资源分配策略。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_58.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_60.png

实践推荐

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_62.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_64.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_66.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_68.png

对于想深入学习 Frame Graph 实现的同学,推荐研究谷歌的 Filament 渲染引擎。它包含了一套清晰且高效的 Frame Graph 实现,其接口通常包含:

  • addPass:声明一个渲染 Pass。
  • setInput / setOutput:声明该 Pass 的输入输出资源,从而构建依赖关系。
  • execute:执行编译后的 Graph。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_70.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_72.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_74.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_76.png


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_78.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_80.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_82.png

Vulkan 移动端优化实践 📱

最后,我们探讨一些针对移动端设备的 Vulkan 优化实践。移动端 GPU(通常采用 Tile-Based Rendering 架构)与桌面端 GPU(Immediate Mode Rendering)有显著差异,因此优化策略也不同。

移动端优化的核心矛盾是:计算能力相对强大,但内存带宽和功耗极其受限。因此,优化大多围绕节省带宽和减少功耗展开。

以下是来自 ARM、高通等厂商最佳实践文档中的一些关键点:

常见优化建议

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_84.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_86.png

以下是针对移动端 Vulkan 开发的一些具体建议:

  • 使用索引绘制 (Indexed Draw Call):即使顶点没有复用,也建议使用 vkCmdDrawIndexed。这与直觉相悖,但符合移动端驱动的优化特性。
  • 避免使用 32 位索引:在满足顶点数量的前提下,使用 16 位索引(VK_INDEX_TYPE_UINT16)可以减少带宽消耗。
  • 使用半精度浮点数:在 Fragment Shader 中,除了计算世界空间位置等需要高精度的数据,应尽量使用 mediump(半精度)浮点数。
  • 避免深度预渲染 (PreDepth Pass):在移动端,Tile-Based GPU 的 Hidden Surface Removal (HSR) 机制能有效处理 Overdraw,额外的深度预渲染 Pass 反而会增加开销。
  • 避免破坏 Early-Z 的行为
    • Alpha BlendDiscard 操作会禁用 Early-Z 优化,导致 Overdraw 增加。
    • 优化方案:对于 Alpha Test 物体(如树叶、草丛),可以将其几何体轮廓提取出来,用实体几何代替 Alpha Blend 的四边形,从而保持 Early-Z 有效。
  • RenderPass 配置:尽量使用 LOAD_OP_CLEARLOAD_OP_DONT_CARE,避免 LOAD_OP_LOAD,以减少内存加载带宽。
  • 避免巨型 Uber Shader:不要使用通过 Uniform 变量控制大量分支的“全能”着色器。分支会严重影响性能。应该根据特性编译不同的着色器变体。
  • 减少单个着色器的代码量:过于复杂的着色器会导致编译慢、执行效率低。必要时可考虑将功能拆分到多个 Pass。
  • 减少通用寄存器使用:例如,将两个 vec2 变量打包成一个 vec4
  • 展开短循环:对于确定次数的短循环,手动展开代码通常比使用循环语句性能更好。

案例分析:前向渲染 (Forward) 与延迟渲染 (Deferred) 在移动端的抉择

这是一个经典的性能取舍问题:

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_88.png

  • 延迟渲染 (Deferred Lighting)
    • 优点:灯光数量与性能消耗呈线性关系,易于支持大量动态光源。
    • 缺点:需要存储 G-Buffer(法线、颜色、深度等),带宽消耗极大,在移动端是主要瓶颈。
  • 前向渲染 (Forward Lighting)
    • 优点:带宽消耗低,更适配 Tile-Based Rendering 架构。
    • 缺点:灯光计算在着色器内进行,受限于着色器指令数和寄存器数量,能支持的光源数量有限。一个模型有 N 个光源就需要计算 N 次。
混合优化方案

为了在移动端兼顾性能和效果,可以采用一种混合方案

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_90.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_92.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_94.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_96.png

  1. 分离阴影计算:将有阴影的灯光和无阴影的灯光拆分成不同的渲染 Pass
    • ShadowCasterPass:专门处理带阴影的灯光(数量需严格限制,如1-3个)。每个这样的灯光可能都需要单独的 Pass。
    • ForwardLightingPass:处理所有不带阴影的灯光。由于没有阴影计算,着色器较轻量,可以支持较多数量(如几十个)。
  2. 性能提升:通过这种拆分,避免了在着色器中使用 Uniform 分支判断“是否计算阴影”,符合了“避免 Uber Shader”的最佳实践。实测中,这种优化可能带来 60%-70% 的性能提升。
  3. 代价:Draw Call 数量会增加,变为 Mesh数量 * (1 + 有阴影灯光数量)。需要权衡 CPU 提交命令的开销和 GPU 着色器的执行效率。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_98.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_100.png

核心思想:移动端优化没有银弹,必须仔细权衡带宽、计算量和 Draw Call。最可靠的依据是硬件厂商(如 ARM、高通)提供的官方最佳实践文档,并在真实设备上进行测试。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_102.png

总结 📝

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_104.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_106.png

本节课我们一起学习了 Vulkan 图形管线中三个高级主题:

  1. 同步原语:掌握了 Fence、Event、Semaphore 和 Barrier 的作用与区别,理解了它们如何协调 CPU 与 GPU 的执行。
  2. Frame Graph:了解了这种声明式渲染框架如何通过图结构管理复杂的 Pass 依赖和资源生命周期,实现自动同步与优化。
  3. 移动端优化:探讨了移动端 GPU 架构的特点,并学习了一系列以节省带宽为核心的最佳实践,特别是通过拆分渲染 Pass 来优化复杂光照场景的具体案例。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/586e9be416be3093b03e2d25990a971b_108.png

掌握这些知识,将帮助你构建更高效、更健壮的 Vulkan 渲染引擎,尤其是在资源受限的移动平台上。

GAMES106-现代图形绘制流水线原理与实践—P5-5–多概率着色—GAMES-Webinar—BV1Uo4y1J7ie_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/af8a876aa9e24e27281939b3c451a487_1.png

在本节课中,我们将要学习现代图形绘制管线中的多频率着色概念。我们将从绘制管线的基本计算频率开始,逐步介绍几何管线和着色管线中的不同着色阶段,并探讨如何利用多频率着色的思想进行性能优化,在保证画面质量的同时提升渲染效率。

绘制管线中的计算频率 📊

上一节我们介绍了课程概述,本节中我们来看看绘制管线中计算频率的基本概念。

最简单的计算频率理解是:顶点着色器(Vertex Shader)每个顶点执行一次,片元着色器(Fragment/Pixel Shader)每个像素执行一次。在定义管线时,先对每个顶点执行一遍顶点着色器,然后进入光栅化阶段。

然而,真实的绘制管线远比这个复杂。从输入网格开始,管线经历了曲面细分、几何着色等阶段。在DirectX 9之后,管线变得越来越复杂,引入了可见性样本、着色样本等概念。可编程着色器内部的计算频率和缓存频率也各不相同。本节课的目标就是梳理这些概念,并探讨如何优化。

几何管线 🔷

在介绍现代绘制管线之前,我们先了解一下早期影视制作中使用的管线。

早期(约1987年)的管线被称为“REYES”(Render Everything You Ever See)。为了在大荧幕上获得连续平滑的曲面(而非三角形构成的棱角),它使用了参数曲面,并通过细分算法(如Catmull-Clark细分)生成更平滑的几何体。

其核心架构是“Split and Dice”:

  • Split阶段:将输入的参数图元分割成更小的图元(Primitive)。
  • Dice阶段:将参数曲面细分成更小的多边形(如微多边形),每个多边形大约一个像素大小。
  • 细分后,可以在顶点上进行着色计算(包括求交和光照),最后通过像素点在各顶点间的插值得到最终颜色。

现代实时绘制管线的许多概念都由此演化而来,因为我们的目标始终是尽可能地逼近影视级的渲染质量。

顶点着色器(Vertex Shader)

现代管线始于顶点着色器。在此阶段,我们需要定义输入装配(Input Assembly)和顶点输入数据。

以下是定义输入的基本步骤:

  1. 定义图元拓扑(Primitive Topology),最常用的是三角形列表(Triangle List)。
  2. 提供顶点输入数据(Vertex Input Data),通常包括顶点缓冲区(Vertex Buffer),并常配合索引缓冲区(Index Buffer)进行索引。
  3. 如果使用实例化(Instancing),还需要实例缓冲区(Instance Buffer)。

GPU执行时,会根据顺序为每个顶点执行一次顶点着色器,并将结果写入。GPU会缓存已执行过的顶点结果,避免对共享顶点重复计算。

几何着色器(Geometry Shader)

顶点着色器的一个主要限制是,其输出的几何体必须在CPU端预先设定。为了提供更灵活的几何修改能力,DirectX 10时代引入了几何着色器。

几何着色器的好处在于可以自由地修改几何。例如,一个三角形输入,可以输出其法线进行可视化,或者输出多个三角形,甚至改变拓扑结构。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/af8a876aa9e24e27281939b3c451a487_3.png

然而,由于其过于自由,GPU难以进行有效的任务调度和性能优化,因此几何着色器在实践中被认为不太成功,使用相对较少。

曲面细分着色器(Tessellation Shader)

为了解决几何着色器的性能问题,并提供可控的细分,DirectX 11引入了曲面细分着色器。

其工作流程如下:

  1. 曲面细分控制着色器(Tessellation Control Shader / Hull Shader):设置细分参数。对于三角形,包括三条边的细分因子(Tessellation Factor)和内部的细分因子。
  2. 固定功能曲面细分器(Fixed-Function Tessellator):根据细分参数生成更多的顶点。
  3. 曲面细分求值着色器(Tessellation Evaluation Shader / Domain Shader):计算新顶点的属性(如位置),通常利用原始三角形的重心坐标进行插值。

曲面细分可用于实现平滑几何(如PN三角形)或增加几何细节(如位移贴图)。在地形渲染中,它常用于实现细节层次(LOD)。

使用曲面细分时需注意两个问题:

  1. 共享边的细分因子匹配:相邻三角形的共享边必须使用相同的细分因子,否则会产生T型连接(T-junction)和裂缝。
  2. 顶点数据一致性:进行位移映射时,共享顶点应使用相同的法线和UV等属性数据,否则也会产生裂缝。这通常通过计算平滑顶点法线(Smooth Vertex Normal)或强制生成相同的顶点数据(Duplicate Vertex)来解决。

网格着色器(Mesh Shader)

随着计算着色器(Compute Shader)的引入,程序员获得了更大的自由度。DirectX 12 Ultimate进一步引入了网格着色器(Mesh Shader)架构。

网格着色器将几何管线的部分工作(如裁剪、LOD选择)转移到GPU上,以任务(Task)和网格(Mesh)的形式进行更高效、更自由的并行处理。它提供了比曲面细分更灵活的计算粒度。

我们来总结一下几何管线中各阶段的特点:

  • 顶点着色器:每个顶点执行一次,不知道图元拓扑。
  • 几何着色器:知道输入图元拓扑,但输出可变,调度效率低。
  • 曲面细分着色器:采用固定模式,速度较快,但灵活性受限。
  • 网格着色器:沿袭计算着色器的自由度高,且带有几何感知,适合特定高效应用。

着色管线 🖌️

介绍完几何管线,我们转向管线的另一半——着色管线。本节我们重点关注像素级别的着色计算。

基础光栅化与片元着色器执行

在最简单的光栅化中,一个三角形覆盖的像素会执行片元着色器。但实际执行是以像素块(如2x2的像素Quad)为单位的。这是为了支持求导指令(如ddx, ddy),这些指令需要与相邻像素比较来计算梯度,常用于纹理Mipmap选择。

片元着色器在Quad上执行,但只有被覆盖(Coverage)的像素中心才会真正写入颜色和深度。

多重采样抗锯齿(MSAA)

基础光栅化会导致几何边缘出现锯齿。多重采样抗锯齿(MSAA)的核心思想是在比像素更小的尺度上进行可见性计算。

在MSAA中,每个像素包含多个采样点(如4x MSAA有4个采样点)。这些采样点用于深度/模板测试,以更精确地确定三角形覆盖像素的比例。

着色计算(片元着色器执行)通常仍在像素中心进行(每个像素一次)。然后,根据覆盖采样点的比例,将计算出的颜色混合后写入。

与无MSAA相比,MSAA的代价在于:

  • 深度/模板测试的样本数量增加。
  • 片元着色器的执行次数可能增加(因为部分覆盖的像素也需要执行)。
  • 写入的颜色数量增加。

MSAA通过增加采样点来提升几何边缘的质量,同时相比于单纯提高分辨率,它减少了片元着色器的执行次数(每个像素通常仍只着色一次)。但如果有多个三角形部分覆盖同一像素,着色次数仍会增加。

现代API提供了对MSAA更细致的控制,例如逐采样着色(Per-Sample Shading),允许对每个采样点分别执行着色。

可变速率着色(VRS)

与MSAA在更小尺度着色相反,可变速率着色(VRS)允许在比像素更大的尺度上进行着色计算。

例如,可以设定2x2的像素块只执行一次着色计算,其余像素通过插值得到颜色。这可以显著减少着色计算量。

VRS可以通过多种方式控制:

  • 基于图元(Per-Primitive):为特定物体(如远景物体)设置较低的着色率。
  • 基于屏幕图像(Screen-Space Image):提供一张纹理来指定屏幕上不同区域应使用的着色率。这可以是静态的(如VR中中心区域用高着色率),也可以是自适应的(根据画面内容动态生成)。

VRS可以与MSAA结合使用,实现更灵活的着色质量与性能平衡。

总结一下,着色管线关注的是采样率和着色器执行。着色频率直接影响图像质量。多频率着色的核心就是在计算性能与图像质量之间寻找最佳平衡点。

多频率着色优化实践 ⚙️

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/af8a876aa9e24e27281939b3c451a487_5.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/af8a876aa9e24e27281939b3c451a487_7.png

了解了管线提供的各种计算频率后,本节我们来看看如何利用多频率着色的思想进行实际优化。

进行多频率着色优化的前提是正确识别信号的频率。

对于几何信号:

  • 高频区域:法线变化剧烈、几何细节丰富的区域。
  • 低频区域:平坦、光滑的区域。
    优化思想包括:对高频区域保留更多几何细节(如更多三角形),对低频区域进行简化;或者将高频几何细节编码到法线贴图、位移贴图中,而基础网格保持低频。

对于着色信号(屏幕空间信号):

  • 高频区域:光照变化剧烈、纹理细节丰富、运动剧烈的区域。
  • 低频区域:光照平缓、纹理一致、或距离很远的区域。

学术界和工业界有许多利用这种思想的工作:

  1. 基于内容的自适应着色:通过分析屏幕像素间的梯度(变化),推导出全分辨率、半分辨率等着色结果之间的误差,从而动态决定每个区域使用何种着色率,以在视觉无损的前提下降低计算量。
    公式示例误差 ≈ K * (相邻像素差异)
  2. 时域自适应:不仅考虑单帧画面内容,还考虑帧与帧之间的运动(Motion Vector)和遮挡变化,在运动剧烈的区域采用更保守(或更激进)的着色率策略。
  3. 材质速率着色(Material Rate Shading):更进一步,在着色器内部对不同部分采用不同频率。例如,漫反射(Diffuse)可能是低频的,而高光(Specular)或阴影边缘可能是高频的。现代引擎中已有类似应用,如环境光遮蔽(AO)或全局光照(GI)常使用半分辨率计算再加模糊。

除了在像素着色器内部优化,还可以考虑将低频的着色计算(如某些光照)上移到顶点着色器或曲面细分阶段,通过插值来获得像素颜色,从而减少像素着色器的负担。

总结与作业 📚

本节课我们一起学习了现代图形绘制管线中的多频率着色。

首先,我们回顾了绘制管线中从几何到着色的各种计算频率,包括顶点着色器、几何着色器、曲面细分着色器、网格着色器、MSAA和VRS。理解这些频率是进行优化的基础。

其次,我们探讨了多频率着色优化的核心思想:识别几何和着色信号中的高频与低频区域,并利用管线提供的不同计算粒度,对低频区域采用代价更低的计算方式,在保证视觉质量的同时提升渲染性能。

课后作业建议
请以“Visually Lossless Content and Motion Adaptive Shading in Games”等相关论文为基础,尝试实现或深入理解以下两点:

  1. 基于内容的自适应着色:如何根据画面内容(如颜色、深度梯度)动态决定着色率。
  2. 时域自适应扩展:在运动场景中,如何结合运动向量进一步优化着色率策略,以处理运动模糊和动态遮挡。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/af8a876aa9e24e27281939b3c451a487_9.png

通过本课的学习,希望大家能够建立起利用多频率思想分析和优化渲染管线的能力。

GAMES106-现代图形绘制流水线原理与实践—P6-6–性能分析—GAMES-Webinar—BV1Uo4y1J7ie_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_0.png

在本节课中,我们将学习图形程序性能分析的核心概念、工具与方法。理解性能瓶颈是优化的第一步,我们将从CPU与GPU的协作、GPU架构基础,到具体的性能分析工具使用,系统地介绍如何定位和诊断性能问题。

概述 📋

高性能的图形代码意味着更高的帧率、更流畅的画面和更低的延迟。实现这一目标需要CPU与GPU的高效协作。性能分析旨在定位整个绘制流水线中的瓶颈环节,无论是CPU端的逻辑处理、渲染线程准备,还是GPU端的着色器执行与内存访问。

上一节我们介绍了现代图形API的绘制流程,本节中我们来看看如何分析这个流程中各个环节的性能表现。

性能分析基础概念 ⚙️

一个最基本的性能指标是帧率。高帧率代表更流畅的体验。不同平台或应用对帧率的要求不同,例如主机游戏通常为60 FPS,VR应用则可能需要90 FPS。除了平均帧率,帧率的平稳性同样重要,应避免帧时间大幅波动。

写出高性能程序需要同时考虑CPU和GPU。CPU擅长复杂的控制逻辑,而GPU专为高吞吐量的并行计算设计。两者需要良好协作:CPU需要高效地批量准备并提交绘制数据给GPU,以避免GPU空闲等待。同时,CPU自身也在通过多核与并行编程技术提升处理能力。

从引擎层面看,完成一帧绘制是一个流水线过程。以Unity为例,一帧可能包含物理计算、游戏逻辑脚本、渲染准备等多个阶段。现代引擎通常将任务拆分到不同线程,例如:

  • Game Thread:处理物理、游戏逻辑等。
  • Render Thread:负责场景剔除、绘制命令准备与提交。

GPU驱动将提交的绘制命令放入队列,由GPU硬件调度执行。最终结果被写入帧缓冲区,在垂直同步信号到来时交换到屏幕显示。

由于这是一个流水线,最慢的一环决定了整体的帧率。因此,性能分析的关键是测量流水线中每个环节的耗时。

以下是测量各环节时间的基本方法:

  • CPU端时间:可以使用高精度计时器(如 std::chrono)在任务前后打点计算。
  • GPU端时间:一个常见误解是将提交绘制命令的时间当作GPU执行时间。正确的方法是使用GPU时间戳查询。在命令列表开始和结束处插入时间戳命令,并在数帧后异步查询结果,以获得真实的GPU执行耗时。

通过对比Game Thread、Render Thread和GPU的执行时间,可以定位瓶颈所在。

针对不同瓶颈,优化方向也不同:

  • CPU (Game Thread):优化脚本逻辑、物理计算、网络同步等。
  • CPU (Render Thread):优化动态批处理、灯光数量、遮挡剔除等。
  • GPU:优化三角形数量、纹理分辨率、着色器复杂度、显存使用等。

为了进行深入分析,我们需要借助专门的性能分析工具。

GPU架构简述 🏗️

在深入GPU性能分析工具之前,有必要简要了解GPU的基础架构,这有助于理解性能指标的含义。

GPU的核心是大量并行的小型处理单元(流处理器)。其编程模型与CPU的SIMD(单指令多数据)不同,属于SIMT(单指令多线程)。在SIMT模型中,开发者编写的是单个线程的行为,而硬件以线程束(Warp/Wavefront)为单位进行调度和执行。例如,NVIDIA GPU的Warp通常包含32个线程,这些线程以锁步(Lock-step)方式执行相同的指令。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_2.png

以下是GPU执行的关键概念:

  • 线程束(Warp/Wavefront):GPU调度和执行的基本单位。一个线程块(Thread Block)会被划分为多个线程束来执行。
  • 分支(Branching):在SIMT模型中,分支对性能的影响取决于线程束内线程的分支路径是否一致。如果线程束内所有线程走相同分支,没有额外开销。如果线程束内线程走不同分支(分支发散),则所有分支路径的指令都可能被执行,通过掩码(Mask)控制哪些线程的结果有效,这会带来性能损失。
  • 占用率(Occupancy):衡量GPU流处理器上活跃线程束数量的指标。高的占用率有助于隐藏内存访问延迟。当某个线程束因等待内存数据而停顿时,调度器可以迅速切换到其他就绪的线程束继续执行,从而保持硬件忙碌。
  • 内存层次结构:GPU拥有复杂的多级内存体系,包括全局内存(显存)、L2/L1缓存、以及每个流处理器独有的共享内存(Shared Memory)和常量缓存/纹理缓存等。访问不同层级的内存,延迟和带宽差异巨大。

图形管线中还包含一些固定功能单元,如顶点获取(Vertex Fetch)、图元装配(Primitive Assembly)、光栅化(Rasterization)和输出合并(Output Merger)等。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_4.png

理解这些架构特点,是解读GPU性能分析报告的基础。

PC端常用GPU性能分析工具 🔧

上一节我们介绍了GPU架构的基本概念,本节中我们来看看如何使用工具对GPU进行性能分析。NVIDIA的Nsight系列工具功能强大,常作为分析范例。

Nsight工具的核心原理是指令重放。它捕获应用程序发出的图形API调用序列,然后离线或在线重新执行这些命令,并在此过程中收集GPU内部各种硬件计数器(Counter)的数据。

主要有两种分析模式:

  1. Nsight Frame Profiling:进行深入的单帧分析。它可以展示每个绘制调用(Draw Call)的详细耗时,以及消耗了哪些GPU硬件单元(如SM、内存带宽、光栅化器等)的资源。其提供的绝对时间可能因 profiling 开销而不完全准确,但用于对比优化前后的相对性能变化非常可靠。
  2. Nsight GPU Trace:进行实时、轻量级的性能概览。它可以连续捕获多帧,展示GPU利用率的时序图,快速定位是CPU提交命令慢导致GPU空闲,还是GPU自身执行成为瓶颈。它能给出SM占用率、显存带宽使用率等宏观指标。

在分析报告中,你会看到一系列硬件单元的性能百分比,例如:

  • VF:顶点获取单元利用率。
  • SM:流处理器利用率,是衡量计算瓶颈的关键。
  • L1/TEX:一级/纹理缓存利用率。
  • DRAM:显存带宽利用率。
  • ROP:渲染输出单元利用率。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_6.png

通过观察哪个单元的利用率接近100%(成为瓶颈),就可以确定大致的优化方向。例如,如果SM利用率很低,但GPU仍有大量空闲时间,可能是CPU提交命令不够快;如果SM利用率已接近饱和,则需要优化着色器代码或减少计算负载。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_8.png

性能分析与优化策略 🎯

掌握了工具的使用后,性能分析的核心策略是定位瓶颈。首先确定是CPU瓶颈还是GPU瓶颈。如果是GPU瓶颈,则进一步利用工具定位是哪个具体的硬件单元(如SM、内存带宽、纹理单元)利用率最高。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_10.png

优化是一个迭代和验证的过程:

  1. 测量:使用工具获取性能基线数据。
  2. 假设:根据数据和代码逻辑,提出性能瓶颈的假设。
  3. 优化:实施针对性的优化(如简化着色器、减少纹理采样、合并绘制调用)。
  4. 验证:再次测量,确认优化是否有效,并观察是否引入了新的瓶颈。

需要特别注意的是,优化往往是平台相关的。在一款GPU上的优化可能在另一款GPU上收效甚微甚至成为负优化。因此,必须在目标硬件上进行测试和验证。

本课程后续关于几何简化(Geometry LOD)、着色器简化(Shader LOD)和纹理流送(Texture Streaming)等内容,都是具体的GPU优化技术。学习本课的性能分析方法后,你将能够在实现这些技术时,不仅观察帧率变化,更能深入理解它们具体缓解了GPU哪个环节的压力(如减少了顶点处理负载、降低了纹理带宽占用等),从而更科学地评估优化效果。

总结 📝

本节课我们一起学习了图形程序性能分析的全流程。我们从高性能代码的目标出发,理解了CPU与GPU协作的流水线模型,以及定位瓶颈的基本方法。接着,我们概述了GPU的SIMT架构、线程束、占用率等关键概念,这是理解GPU性能指标的基础。然后,我们介绍了Nsight等性能分析工具的原理和使用方法,学会如何解读硬件单元利用率报告。最后,我们明确了性能优化“定位瓶颈-针对性优化-验证结果”的核心策略,并强调了平台相关性的重要。

通过本课的学习,希望你能够建立起系统的性能分析思维,并运用工具为后续课程中的具体优化实践提供有力的评估手段。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/e021816c5503e5353a0cb4e14c79cb4d_12.png


参考资源:

  1. NVIDIA Nsight Graphics 官方文档。
  2. 《GPU性能分析与优化》相关教程与案例分析。
  3. NVIDIA/AMD GPU 架构白皮书。

GAMES106-现代图形绘制流水线原理与实践—P7-7–几何处理与模型减面引言—GAMES-Webinar—BV1Uo4y1J7ie_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/34814f6da7589962c0023e80206b3aed_1.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/34814f6da7589962c0023e80206b3aed_3.png

在本节课中,我们将要学习三维模型的几种主要表示方法,并初步了解模型减面的基本思路和分类。我们将从模型表示的基础概念开始,逐步过渡到模型简化的核心思想。


模型表示方法

上一节我们介绍了课程的整体安排,本节中我们来看看三维模型在计算机中是如何被表示的。对于三维模型,我们需要用数字化的方式来描述其形状。其中一种非常常见且历史悠久的表示方式是参数化的曲线和曲面。

  • 参数化曲线与曲面:对于一条二维空间中的曲线,其x和y坐标可以用一个参数t来控制。对于一个三维空间中的表面,则可以用两个参数(例如uv)来控制。例如,计算机图形学中常见的贝塞尔曲线和曲面就属于此类。这种表示方式在计算机辅助设计领域非常流行。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/34814f6da7589962c0023e80206b3aed_1.png

  • 网格模型:另一种常见的表示方式是使用网格。一个连续的表面可以用许多多边形(最常见的是三角形)拼接起来进行逼近。网格模型在渲染时通常需要转换为三角形。我们可以为网格表面贴上纹理或赋予材质颜色以便渲染。

  • 点云:点云是另一种表示方式,它通过在模型表面上采集一系列离散的点来近似描述形状。三维扫描仪(如激光扫描仪)获取的数据通常就是点云。

  • 隐式距离场:与点云相关的一种表示是隐式距离场。它在整个空间中定义一个函数,对于任意一点,该函数值表示该点到模型表面的(有符号)距离。距离场便于进行距离查询等操作,但需要额外技术来提取出显式的表面网格。

  • 神经表示:近年来,使用神经网络来表达模型数据变得非常流行。例如,神经辐射场将一个三维空间点的位置(x, y, z)和观察方向(θ, φ)作为输入,通过神经网络输出该点的颜色(RGB)和密度(σ)。这种表示通常与体渲染结合,能从少量输入(如照片)中重建出逼真的多视角模型。

由于本课程更关注渲染,我们将集中讲解最常用的网格模型表示方式。


网格模型详解

上一节我们了解了模型的多种表示法,本节中我们深入探讨网格模型的具体构成。一个网格模型要表达出如图所示的完整外观,需要两部分信息。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/34814f6da7589962c0023e80206b3aed_3.png

  1. 形状:通过点、线、面(网格)的组合来定义模型的轮廓和曲面起伏。
  2. 外观(如颜色):通常通过一张称为纹理贴图的二维图片来定义。为了将这张二维图片“贴”到三维网格表面,需要建立两者之间的映射关系。

这个过程称为UV展开。我们需要将三维网格表面(可能经过切割)展开到二维平面(u, v)上,建立网格顶点与纹理像素之间的一一对应关系。这样,在渲染每个三角形时,就能通过查询对应的UV坐标从纹理贴图中获取颜色值。

接下来,我们介绍网格模型的一些基本概念,这些概念对理解后续的简化算法非常重要。

连接关系与拓扑

网格由顶点、边和面(如三角形)组成。连接关系描述了这些元素是如何关联的,类似于图论中的图。

  • 点的度数:指一个顶点所连接的边的数量。对于内部点,其度数等于相邻的三角形面数;对于边界点,则有所不同。
  • 邻域:例如,一个顶点的一阶邻域包括所有通过一条边直接与该顶点相连的顶点、边和面。二阶邻域则需要通过两条边才能到达。
  • 流形与非流形:流形网格是指其表面任意一点的局部邻域都能与一个二维圆盘建立同胚映射。非流形情况包括:
    • 点的非流形:一个顶点及其相邻的三角形无法构成一个简单的环(即映射不到一个圆盘上)。
    • 边的非流形:一条边被三个或更多个面共享。

欧拉-庞加莱公式

这是一个连接拓扑信息的恒等式,对于表面网格有:
V - E + F = 2 * (S - G)
其中:

  • V = 顶点数
  • E = 边数
  • F = 面数
  • S = 连通分量数(模型由几个独立部分构成)
  • G = 亏格(通俗理解,最少需要切几刀能将模型变成与球面同胚,例如一个环面的亏格为1)

几何量

以下是一些常用且重要的几何概念:

  • 边长:边在三维空间中的欧几里得长度(L2范数)。
  • 面积:三角形面积有明确公式。对于非平面的四边形,通常将其分割为三角形计算。
  • 二面角:一条边相邻的两个三角形所在平面之间的夹角。
  • 法向:表面上某一点处垂直于切平面的方向,通常为单位向量。
  • 相交:指模型自身面片之间相互穿透,或不同模型之间发生穿透。
  • 退化单元:面积为零的三角形(退化成线或点),或长度为零的边。
  • 包围盒:能完全包裹住三维模型的最小轴向对齐或方向自适应的长方体。常用于算法中作为尺度参考,以提高对不同大小模型的鲁棒性。
  • 曲率:描述表面弯曲程度的量。表面上一点有无数个曲率方向,其中存在主曲率(最大和最小曲率)。
  • 特征:通常指模型中需要特殊处理的部位,如根据二面角提取的尖锐边、角点等,它们构成的图可将表面划分为不同区域。
  • 豪斯多夫距离:衡量两个形状之间差异的度量。定义为从一个形状上任意点到另一个形状最近点的距离的最大值。
  • 面翻转:在一致的网格朝向(如所有三角形顶点按逆时针排列)中,某个面的法向与周围面相反。在UV展开中,面翻转会导致纹理映射错误。
  • 凸包:包含给定点集的最小凸集。在二维中是最小凸多边形;在三维中是最小凸多面体。

数据结构

如何在程序中存储和高效访问网格信息是关键。设计需要在存储空间、访问速度和更新效率之间取得平衡。存储的信息通常包括顶点位置、每个顶点附加的属性(颜色、UV、蒙皮权重等),以及点、边、面之间的连接关系。

一个经典且巧妙的数据结构是半边结构。它将一条边拆分为两个有方向的半边来存储。主要存储以下信息:

  • 顶点:位置 + 一条出射半边的索引。
  • 半边:起始顶点索引 + 左侧面片索引 + 同面内上一条和下半边的索引。
  • 面片:包含的一条半边的索引。

通过这种结构,可以高效地进行邻域遍历等操作。例如,要遍历一个顶点的一环邻域面,可以从该顶点存储的半边出发,找到其对边,再通过“下一条半边”指针遍历该面,并循环此过程访问所有相邻面。


模型简化方法概述

前面我们介绍了网格模型的基本概念,那么接下来我们看看如何对模型进行简化或压缩。在游戏等实时渲染应用中,场景包含大量三维模型。模型越精细(面数越多),效果越逼真,但对计算和存储的压力也越大。

一种常见的解决方案是层次细节渲染:根据物体与摄像机的距离,使用不同面数版本的模型进行渲染。距离越近,使用面数越多的高模;距离越远,则使用面数少的低模。这就需要我们预先计算好同一个模型的多个简化版本。

模型简化方法在实际中主要分为两大类:

保纹理简化

核心思想:在简化三维网格和其UV网格的同时,不改变原始的纹理贴图。简化后的模型渲染时仍使用原纹理。

优点:节省存储空间,因为纹理数据通常很大,重用原纹理避免了额外的纹理存储。

要求:简化后的UV布局应尽可能与原UV布局保持一致,以确保从纹理采样得到的颜色能正确映射到简化后的模型表面,保持外观相似。

主流方法:基于二次误差度量的边坍缩算法是这类方法的核心。它通过逐条计算并坍缩对模型形状改变最小的边,来逐步减少面数。此类方法有大量研究和变种。

其他思路

  • 对三角面进行聚类,每一类用一个多边形表示,再对该多边形进行三角化。
  • 针对四边形网格的简化。
  • (注:一些基于机器学习的简化或基于内在三角形表示的方法,可能不专注于保持UV,但提供了不同的思路。)

不保纹理简化

核心思想:简化过程完全打乱原有的网格连接关系和UV布局,生成一个全新的、更简单的网格。因此,必须为这个新网格重新生成对应的纹理贴图。

优点:通常能获得面数更少、几何更简洁的简化结果。

流程:通常包含三个主要步骤:

  1. 重新网格化:为模型生成一个全新的、质量更好的网格。方法多样,例如用大的平面片(如四边形)去拟合模型、将模型划分为更规律的网格、或通过优化方法提取一组平面来近似原模型等。
  2. UV展开:为新的网格计算UV坐标,将其展开到二维平面。
  3. 纹理烘焙:将高精度模型(高模)上的颜色、光照等信息“烘焙”到新网格(低模)的纹理贴图上。

纹理烘焙方法

  • 光线投射:从低模表面向高模发射射线,取交点处的信息(如颜色)作为低模对应纹理像素的值。简单直接,但在高低模差距大时可能映射错误。
  • 可微渲染:利用可微渲染技术,通过优化过程将高模的外观信息传递到低模的纹理上,通常能获得更高质量的结果。

本节课中我们一起学习了三维模型的多种表示方法,重点剖析了网格模型的构成、基本概念(拓扑、几何量、数据结构),并概述了模型简化的两大方向:保纹理简化和不保纹理简化,以及它们的基本流程和核心思想。下一节课,我们将深入讲解保纹理简化中具体的算法原理。

GAMES106-现代图形绘制流水线原理与实践—P8-8–几何处理与模型减面之保纹理的模型减面—GAMES-Webinar—BV1Uo4y1J7ie_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/957475990229daab406cfb3f87b0c878_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/957475990229daab406cfb3f87b0c878_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/957475990229daab406cfb3f87b0c878_4.png

在本节课中,我们将要学习一种在三维模型简化中非常经典且实用的方法——保纹理的模型减面。我们将深入探讨其核心算法框架、误差度量方式以及实现中的关键细节,目标是让初学者能够理解如何在不生成新纹理贴图的前提下,有效减少模型的面数。


上一节课我们介绍了三维模型的不同表达方式,并重点讲解了网格表示及其拓扑与几何属性。我们还概述了模型简化的几种不同方式及其优缺点。本节中,我们将聚焦于其中一种方法:保纹理的模型减面。

保纹理减面方法的核心优势在于,简化后的模型可以复用原始模型的纹理贴图,从而节省存储空间和带宽,这在游戏等对数据量敏感的应用中尤为重要。

以下是该简化算法的通用框架步骤:

  1. 定义并提取简化操作单元:首先,需要确定对模型进行简化的基本操作,例如边折叠。
  2. 计算操作误差:为每个简化操作单元计算其执行后对模型造成的“误差”。
  3. 构建优先队列:根据计算出的误差,将所有操作单元排序,放入一个优先队列中。
  4. 迭代简化:进入循环,只要队列不为空且未达到停止条件(如目标面数),就执行以下操作:
    • 从队列中取出误差最小的操作单元。
    • 检查该操作是否有效(例如,是否会导致模型自相交或严重变形)。
    • 如果有效,则执行该简化操作。
    • 更新受此操作影响的相邻操作单元的误差,并将其重新加入优先队列。

这个框架是绝大部分保纹理简化方法的基础。


上一节我们介绍了简化算法的整体框架,本节中我们来看看框架中的核心组件之一:简化操作单元。常见的简化操作有以下几种:

  • 顶点移除:删除一个顶点及其相连的所有三角形,然后在形成的多边形空洞上进行重新三角化,从而减少面数。
  • 边折叠:这是最常用的一种操作。将一条边的两个顶点合并为一个新的顶点,从而移除这条边以及与其相邻的两个(或多个)三角形。
  • 顶点对合并:将两个位置非常接近的顶点合并为一个,这个操作本身不减少面数,但可以为后续的边折叠等操作创造条件。

在这些操作中,边折叠因其高效和通用性而被广泛采用。算法的核心挑战在于两点:如何对边折叠操作进行排序(即计算误差),以及如何确定边折叠后新顶点的位置。这两点都紧密关联于误差的计算。


理解了操作单元后,接下来我们探讨如何衡量一次简化操作带来的“误差”。误差的定义方式多种多样,例如基于几何距离的豪斯多夫距离,或基于视觉感知的误差。本节课重点介绍其中最经典、最实用的方法:二次误差度量

二次误差度量 的核心思想是:衡量新顶点到其关联的原始三角形所在平面的距离平方和。对于一个顶点 v 和一组平面(每个平面对应一个原始三角形),其误差 Δ(v) 定义为:

Δ(v) = Σ (distance(v, plane_i))²

其中,distance(v, plane_i) 是顶点 v 到第 i 个平面的距离。对于一个由系数 [a, b, c, d] 定义的平面(满足 a² + b² + c² = 1),点到平面的距离为 a*x + b*y + c*z + d。因此,误差可以展开并重写为矩阵形式:

Δ(v) = vᵀ Q v

这里,v 是顶点的齐次坐标 [x, y, z, 1]ᵀQ 是一个 4x4 的对称矩阵,由该顶点关联的所有平面的系数矩阵 K_pK_p = p pᵀ,其中 p = [a, b, c, d]ᵀ)累加而成:

Q = Σ K_p

当我们对一条边进行折叠时,这条边两个端点 v1v2Q 矩阵分别为 Q1Q2。折叠后新顶点 v’Q 矩阵可以近似为两者的和:Q’ = Q1 + Q2。新顶点的误差则为 Δ(v’) = v’ᵀ Q’ v’

为了得到最优的新顶点位置,我们需要最小化这个二次误差 Δ(v’)。这可以通过对 Δ(v’) 求关于 x, y, z 的偏导数并令其为零来实现,从而得到一个线性方程组:

∇Δ(v’) = 0

求解这个方程组,即可得到使误差最小的新顶点坐标 v’。将 v’ 代入误差公式,就能得到这次边折叠操作的误差值,用于在优先队列中排序。


在算法的迭代过程中,每次从队列中取出一个边折叠操作时,并非直接执行,而是需要先进行有效性检查,以确保简化后的模型质量。以下是几种常见的检查策略:

  • 几何距离约束:防止折叠操作导致局部表面形变过大。一种高效的方法是使用层次化的空间细分结构来近似约束顶点移动的范围。
  • 拓扑一致性检查:确保简化操作不改变模型的拓扑性质(如亏格、连通分量数)。可以通过检查 Link Condition 来快速判断。
  • 面片翻转检测:防止操作导致三角形法向翻转,从而在渲染时产生瑕疵。通常通过比较新旧三角形法向的点积来判断。
  • 自相交检测:避免简化后的模型表面出现自相交。这可以通过构建空间加速结构来高效检测三角形对之间的相交情况。
  • 数值鲁棒性处理:由于浮点数精度问题,计算出的新顶点可能位置异常。可以预先定义模型的包围球,将新顶点位置限制在合理范围内。

如果操作通过了所有有效性检查,就可以安全地执行。执行后,需要更新受影响的相邻边的误差,并重新插入优先队列。


上述QM方法主要处理顶点的几何位置。但模型顶点通常还包含其他属性,如颜色、法线、纹理坐标等。保纹理减面需要将这些属性也纳入简化框架。

处理思路是将所有顶点属性视为一个高维向量。例如,一个包含位置(x,y,z)、颜色(rgb)和纹理坐标(u,v)的顶点,可以表示为 [x, y, z, r, g, b, u, v, 1]。同样,我们可以为每个关联的“平面”(现在是在高维属性空间中定义)构造系数矩阵,并累加得到高维的 Q 矩阵。优化过程在形式上完全不变。

但需要注意属性值的有效范围:

  • 颜色:计算出的颜色值可能超出 [0,1] 范围,需要进行截断或缩放。
  • 法线:计算出的法线向量需要重新归一化。
  • 纹理坐标:可能需要回绕或钳制到纹理空间的有效范围内。

通过这种方式,QM框架可以优雅地扩展到处理带有多重属性的模型,实现保纹理、保颜色等多种属性的简化。


此外,QM框架不仅限于三角形网格,经过适当调整,也可应用于四边形网格、体网格等。其核心思想——通过最小化到一组“约束”(平面或超平面)的二次距离来进行简化——具有相当的通用性。

对于四边形网格,可以提取一组平行的边构成“条带”,然后同时对条带中的多条边进行折叠操作。对于体网格,原理类似,但“平面”变为三维空间中的超平面。这展示了QM方法强大的扩展性。


本节课中我们一起学习了保纹理模型减面的核心算法——基于二次误差度量(QM)的边折叠简化法。我们详细剖析了其算法框架、误差度量的数学原理、新顶点位置的优化求解、操作有效性的多种检查方法,以及如何处理顶点附加属性。该方法因其高效、实用且易于扩展,成为工业界长达二十余年的经典选择。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/957475990229daab406cfb3f87b0c878_6.png

下节课,我们将探讨另一大类模型简化方法:不保纹理的模型减面。那些方法通常更为灵活,但也会引入新的挑战。

GAMES106-现代图形绘制流水线原理与实践—P9-9–几何处理与模型减面之不保纹理的减面算法—GAMES-Webinar—BV1Uo4y1J7ie_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/f75f18ae3a73a7dafff38b8aeab67236_1.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/f75f18ae3a73a7dafff38b8aeab67236_3.png

在本节课中,我们将要学习不保纹理的模型减面算法。与上节课介绍的保纹理算法不同,这类算法在简化模型时,不保留原始模型的纹理贴图,而是为简化后的新模型重新生成纹理。这带来了更大的简化自由度,但也引入了新的处理步骤。

概述

不保纹理的减面算法旨在将高面数模型简化为低面数模型,同时不保留原始模型的纹理和UV映射。这意味着我们需要为简化后的模型重新计算UV并烘焙新的纹理。这类算法通常能实现更大幅度的面数削减。

不保纹理减面算法的概念

首先,我们简单回顾一下什么叫不保纹理的减面算法。减面算法的目标是将网格模型的面数降低。不保纹理的意思是,在生成新的简化模型时,附着在其上的贴图并非原始模型的贴图,而是需要针对输出结果新生成的贴图。

例如,左边三幅图是输入模型及其UV和纹理。右边及下方的模型是不保纹理减面算法的结果。这些结果模型使用的贴图是重新生成的。

不保持原始纹理为算法设计增加了自由度,因为我们无需保持原始UV。但另一方面,这也意味着我们需要处理重新展UV和生成贴图等步骤。

不保纹理算法的缺点在于会生成额外的数据(新纹理),这与减面以压缩数据量的初衷相悖。但其好处是可以极大地减少模型面数,例如从18000多个面减少到200多个面,降幅可达约1.5%。

算法核心流程管线

总的来说,不保纹理的减面算法经典流程管线主要分为三个步骤:

  1. 重新网格化:对输入模型生成一个新的、简化的网格。
  2. 展UV:为简化后的网格计算并展开UV。
  3. 纹理烘焙:将高模的信息(如颜色、法线)转移到低模上,生成新的纹理贴图。

每一步都对应着许多不同的算法。本节课将从高层次阐述经典方法,帮助大家理解流程和原理。

第一步:重新网格化

重新网格化是整个流程中最复杂、最具挑战性的一步。其目标是生成一个面数少、视觉上与原始模型相似,同时拓扑和几何结构干净简洁的低模网格。干净的拓扑有利于后续的展UV等步骤。

面临的挑战

在实际应用中,尤其是游戏中的建筑模型,高模的拓扑和几何可能非常复杂。例如,一个仅6000个三角形的模型可能包含151个互不连接的组件,以及大量自相交的三角形对。这些“脏”数据(非流形、自相交等)对自动化算法构成了巨大挑战。

美术人员在创建模型时,首要关注视觉外观,而非拓扑整洁度,这与自动化算法的要求不匹配。因此,手工或交互式创建低模至今仍耗费大量人力成本。

现有解决方案与我们的方法

现有解决方案包括工业界软件(如Blender、Simplygon)和学术界算法,但它们在处理复杂模型时,可能在简化程度、视觉保真度或鲁棒性上存在不足。

我们设计方法的出发点是根本需求:视觉保真处理复杂输入。核心思路是:与其直接操作复杂模型,不如先为其计算一个视觉相似且干净的“代理网格”,再对此代理网格进行简化。

我们的方法管线如下:

  1. 生成视觉网格:通过从多个视角对模型进行投影和体布尔运算,生成一个从这些视角看外轮廓与高模一致的封闭网格。
    • 公式示例(概念性):VisualMesh = ∩(SweepVolume(view_i) ∩ BoundingBox)
  2. 生成覆盖网格:针对视觉网格无法捕捉的凹陷区域,通过额外的投影和布尔差运算进行“雕刻”。
    • 公式示例(概念性):CoverMesh = VisualMesh - ∪(SweepVolume(cut_plane_j))
  3. 简化网格:对得到的干净网格应用上节课介绍的QEM边折叠算法进行简化。
    • 我们改进了参数选择过程,通过计算帕累托前沿,让用户从一系列不同面数的优质结果中选择,避免了繁琐的参数调试。

该方法能对各种复杂建筑模型生成外观相似、面数极低的干净网格,在面数和视觉误差指标上均表现良好。

第二步:展UV

在得到低模网格后,我们需要将其表面展开到二维平面,建立UV映射,以便后续贴上纹理。

展UV的要求

展UV步骤有以下要求:

  • 硬约束:避免三角形翻转和UV自相交。
  • 软约束:切割线尽量短、映射扭曲尽量小、UV块排布(装箱)尽量紧凑以减少纹理空间浪费。

经典流程方法:Teaser

我们介绍一个相对易理解的管线方法,它包含几个步骤:

  1. 模型切割:将输入网格分割成多个面片(Patch)。例如,可使用基于Lloyd算法的变分形状近似方法进行迭代分割。
  2. 面片参数化:将每个面片展开到二维平面。常用Tutte嵌入算法保证在凸边界条件下内部无翻转。之后可通过连续优化减少扭曲,并用“脚手架”三角形防止自相交。
    • 代码概念:UV_coords = TutteEmbedding(3D_patch, convex_boundary)
  3. UV装箱:将展开后的各个UV面片像拼图一样紧密排列在纹理坐标空间内,形成最终的UV图。

这是一个模块化的流程,已有MeshLab等工具集成相关算法,相对实用。

第三步:纹理烘焙

拥有低模和其UV后,最后一步是将高模的视觉属性(如颜色、法线)烘焙到低模的纹理上。

方法一:基于光线投射(Raycasting)

这是经典且鲁棒的方法。核心思路是为低模创建一个外扩的“笼子”(Cage),然后从笼子顶点向低模表面发射射线,与高模求交。将交点处高模的属性,赋值给对应的低模顶点(或通过插值生成纹理像素)。

  • 优点:非常鲁棒,易于GPU并行化,速度快。
  • 伪代码描述
    
    for each texel in low-poly texture:
    
        world_pos = map_uv_to_world_via_cage(texel.uv)
    
        hit_info = raycast(world_pos, direction, high-poly_mesh)
    
        texel.color = hit_info.color
    
    

方法二:基于可微渲染(Differentiable Rendering)

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/f75f18ae3a73a7dafff38b8aeab67236_5.png

这是较新的思路。它将渲染过程变为可微分的,从而可以构建优化问题。例如,目标是让低模渲染出的图像与高模渲染出的图像尽可能相似。通过可微渲染计算梯度,可以反向优化低模的纹理贴图(甚至几何)。

  • 优点:作为连续优化方法,在某些情况下能得到比光线投射更优的效果。
  • 公式描述min_θ L = || Render(high_poly) - Render(low_poly(θ)) ||, 其中θ为需要优化的纹理参数。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/f75f18ae3a73a7dafff38b8aeab67236_7.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/f75f18ae3a73a7dafff38b8aeab67236_9.png

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/f75f18ae3a73a7dafff38b8aeab67236_11.png

本节课我们一起学习了不保纹理的模型减面算法。我们首先了解了其核心概念与流程管线,它主要分为重新网格化、展UV和纹理烘焙三大步骤。

随后,我们深入探讨了重新网格化的挑战与一种解决方案,即通过多视角体布尔运算生成视觉代理网格再进行简化。接着,我们概述了展UV的经典流程,包括切割、参数化和装箱。最后,我们介绍了两种纹理烘焙的核心思路:基于光线投射的经典方法和基于可微渲染的优化方法。

不保纹理的算法流程复杂,但能实现极高的简化率,是游戏等应用中压缩三维资产数据量的重要手段。掌握其核心步骤与思想,有助于我们理解现代几何处理流水线的构成。

GAMES201-高级物理引擎实战指南2020—P1-Lecture-1-课程介绍—GAMES-Webinar—BV1ZK411H7Hc_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_2.png

在本节课中,我们将学习高级物理引擎实战课程的整体介绍,包括课程目标、物理引擎的定义与应用、讲师个人经历分享,以及本课程将使用的核心编程工具——太极语言的入门知识。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_4.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_6.png

大家好,我是胡渊明,我在MIT读书。非常高兴有这么多同学一起来看我们的高级物理引擎实战2020课程。整个课程是基于太极编程语言的。内容是关于怎么写一个物理引擎。我们非常注重物理引擎的实战。

每位同学,我们希望都能在这个课上写一点自己的代码。因为你写的代码和听我讲数学公式,你的感觉是不一样的。你会很有成就感。这个课的目标就是自己动手打造一个影视级的物理引擎。适合人群是0~99岁的计算机图形学爱好者。如果大家有一点高等数学,或者Python,或者任何一门编程语言的预备知识的话,那就最好不过了。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_8.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_10.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_12.png

我们课程安排是每周一,北京时间晚上八点半到九点半,一共十节课。一个小时的课程有可能我们会往后延长,比如八点半到十点。这个根据大家的反馈。我这边是早上八点半到九点半,我们正好是12个小时时差。所以一般都是把am换成pm,或者pm换成am就可以算波士顿和北京的时间。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_14.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_16.png

课程内容后面我们会具体再讲。我们先开门见山定义一下什么叫物理引擎。在维基百科上可以看到这个定义:物理引擎是一个计算机软件,它提供对于一个物理系统的近似的模拟,比如说刚体物理的动力学、软体动力学以及流体动力学。主要的应用是在计算机图形学里面,比如游戏和电影。其实一句话说,就是在你的电脑里面模拟这个世界。我想这大家应该有很多同学想过做这个事情,我觉得还是一个很有意思的事情。我小时候就很想怎么在电脑里面模拟一个世界出来。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_18.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_20.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_22.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_24.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_26.png

除了刚才说到这些应用,它还可以在工业软件里面有很多应用。现在你去做一个模型,你要做它的应力分析,你可能先在电脑里面算一算,看看它做出来以后应力分析可能会是什么样。这样你就不用真的把它做出来,然后试一试发现不行再重做。这种迭代过程可以完全在计算机里面进行。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_28.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_30.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_32.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_34.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_36.png

还有一个很重要的应用就是电影里面的视觉特效。现在电影里面,特别是动画电影,基本上每个电影里面可能有非常非常多的镜头都是用物理效果模拟出来的。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_38.png

第三个应用就是虚拟现实、增强现实。如果你在VR里面有一个基于物理世界的话,那你的沉浸感会增强很多。因为你可以和这个世界交互了。并且这个虚拟世界里面的物理规律和现实世界里的物理规律是一致的,所以你就可以有更好的VR体验。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_40.png

当然现在很多人用物理引擎来训练机器人,比如说无人驾驶。搞一个街道的物理引擎,这样车就可以在虚拟世界里面开,不用到真实世界里面冒着产生交通事故的危险。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_42.png

大家可能最关心的物理引擎的用处还是在游戏里面。我今天就仔细讲一讲游戏。

我很小的时候就玩过很多物理的游戏,基本上它们构成了我童年的很大一部分。有一个我印象非常深刻的叫做《不可思议的机器》。这个游戏是这样的:你可以在游戏里面摆入各种各样神奇的道具,比如说一个球、火箭、杠杆,然后这些都会根据物理规律来运动。那个时候其实这个游戏里面有一个问题,就是它这个球是不会转动的,它这个球只会以各种形式平移。当然我是20年以后重新到网上找它视频出来的时候,我才发现原来居然有这个问题。我之前小时候玩的时候完全没有意识到有这个问题。可以看到大家对随着时间推移,对物理效果的追求真的是水涨船高。小时候玩一个这个我觉得已经很厉害了,然后后面有更厉害的。

大概可能很多同学都玩过《愤怒的小鸟》。我记得那个时候,我可能上初中的时候,那个时候非常流行iPad,就是最早的第一代iPad。iPad上面它提供了一个触摸屏,这个触摸屏它可以多点触控。一个很经典的游戏就是这个《愤怒的小鸟》。你可以通过拖拽这个弹弓,然后弹弓打出去,小鸟就撞到游戏里面的各种障碍物。障碍物有的时候会碰撞。这个截图应该很多同学都玩过。我觉得还是很有意思的,我花了很多时间玩这个。

另外一个稍微更加可定制一点的物理引擎是《Phun》。这是一个纯的物理引擎,它不是一个游戏。你可以自己在里面放各种东西,放点什么齿轮、水、滑轮、棱镜什么的。你就可以在这个软件里创建一个物理世界,然后去看它怎么模拟。我小时候经常玩这个东西。感觉有一段时间好像小学或者初中吧,那个时候特别喜欢玩这个,大概每天一放学就玩这个东西。

15年的时候又有一个很有意思的游戏,这个叫《Besiege》。这个游戏的特点是你可以自己把各种各样的模块组装成一个你的攻城武器。你带着这个攻城武器,你就可以去攻打各种各样的城池什么的。这个我自己没玩,这个是我本科的时候看我室友他玩的。感觉非常有意思,可以造各种飞艇或者攻城车什么的。我觉得还挺有意思的。

我最近大概是去年暑假,我玩了一下《塞尔达传说》。这游戏里面有很多物理效果,有很多谜题你需要用物理引擎去解决。这个是这个物理引擎的一个bug:你如果把三个箱子堆在一起,你就可以通过拉最后一个箱子的方式让自己朝前移动。但这不是很物理。

最近应该说我3月份的时候玩了一下《Ori》今年最新的这个。我觉得还是非常不错。这个游戏里面几乎所有的东西都是可以动的。你踩到地上地会往下陷,然后你可以和游戏里面各种各样的东西去交互。我3月份玩的时候因为我是比较早期的玩家,然后有各种bug。我觉得这个效果还是非常好的。

刚才讲了一下过去二三十年整个物理引擎技术里面,我自己玩过的一些游戏。下面我来讲讲我自己的物理引擎的故事。这个从大概初二开始,到现在居然11、2年过去了,我还在做和小时候喜欢做的是一样的东西。很有意思。

这个是我09年的时候写的第一个物理引擎。这个其实非常简单,就是一个弹簧质点系统。那个时候我对09年我应该是初中。你可以看到当时这个可能用一台不太好的笔记本,分辨率实在是非常低。所以你可以看到这个还是一个双核的笔记本。它是一个你可以创建很多这个弹簧质点系统。视频我没有留了,我只有一些截图,这个代码我也找不到了。

后面有一些有视频的。这个是11年的时候,那个时候我应该上高中了。那个时候学了一年刚体动力学,然后写了一个刚体的物理引擎。应该也是受到愤怒小鸟的影响,因为当时玩这个游戏就想这游戏里面这个物理效果是怎么实现,然后我就自己写了一个。那这个代码链接上面是有的。不过我觉得一个有意思的就是这个代码到现在还能跑。我可以跑一跑。

我来跑一跑这个物理引擎。这个最早我是在Windows上面写的,那个时候没有买这个MacBook Pro。后来我把它移植到Mac上面。还是挺有意思的,可以加各种各样的几何体。这个有一些参数可以调。调了以后,比如说我现在可以把摩擦关掉,大家可以看一看效果是什么样的,就滑滑走了。然后我再把它拆下来。我还可以把重力调成负的。你可以创建各种几何体。我记得还有个功能可以画任意几何体。对,他会做一个三角形的剖分,然后把它用三角形表示出来。再看看能不能画一个齿轮。我记得有一个功能可以画齿轮。哇,这个快捷键我十几年过去我居然还记得,真有意思。我们把它…这个理论上这个齿轮是可以驱动的,但是我已经忘了怎么去拖它。可以拖可以拖。我再看看我自己有一些其他的场景。这个是一个叫做牛顿摆。这个我记得当时物理老师经常喜欢用这个东西来做一个演示。我可以尝试一下。你如果放一个,你看到这边弹起来是六个的,右边一定会弹起来六个,左边一定弹起来六个。这个我记得当时上物理课的时候,盯着东西看了好久。下面是个多米诺骨牌。还有一些别的demo啊,这个是一坨弹簧。你可以看,我可以拉近一点。就我小时候玩过一个玩具,好像叫做什么机灵鬼,就是一坨一些弹簧,你可以两个手,他可以从左手跑到右手,右手跑到左手。应该就这些demo。所以这个代码已经年久失修了。你可以看看最后一次什么时候改的。他最后一次是8年前,8年前我把它扩展到Mac OS X上面。还有这个2012年8月26号,时光飞逝。

OK然后下一个。这个是我14年,我应该是大二的时候写的一个基于物理引擎的游戏。这个还是能跑,我看看跑一跑看看。OK,这个是这个物理引擎,不是我自己写的,这个是用的Box2D。当时我记得很喜欢玩一个游戏,叫做《纪念碑谷》,然后这个画风就抄了纪念碑谷。这个你是你可以通过W、S、A、D去控制这个能转的这个小人。然后你可以让他去驱动这个齿轮什么的。哎呦这靠的太近了,有的时候会和齿轮卡住。转这个齿轮的时候,这个右边这个树会长出来。上面这个齿轮好像是控制光影。我一开始想做一个完整的游戏,后来因为没时间做了一半就放弃,做了一个最小化的demo。这个大家可以自己去这个网址去玩一玩,还是挺有意思的。这个看起来是三维,但它其实是很多分层的二维物理。你看到这个这个块,它虽然浮在天上,但它其实还是一层一层之间是有碰撞,但层与层之间是没有的。OK赶紧把它关了,这个很耗GPU啊。

这个是我大概16年的时候写了另外一个流体的模拟。这个也在网页上可以放。大家我结束以后会把链接分享在网上,大家可以直接点进去玩一玩。你可以调各种solver参数。你可有的时候solver参数不对,他就不work。你看这个Jacobi iteration,如果不进行,他就肯定不行。然后这个FLIP的话,你可以在里面选择。那这些复杂概念我们后面都会介绍,所以大家暂时听不明白的话呢,不用担心。你可以调分辨率。我记得我当时好像你现在随便找一个好一点的GPU,跑512x512实时是没有问题,但我这个笔记本实在太烂了。没有办法。你可能有同学说这个风扇转起来可能非常吵,我就先把它关了。

所以这个是16年的时候。这个是一个17年的时候写的一个烟的模拟。哎呦怎么这么卡,哈哈不好意思,这个笔记本实在是不太行。这可能放的不是很流畅,我试试再放一遍行不行。啊看来这个笔记本已经放弃了。Anyway,这个当然,这个我后来跟做烟雾模拟的张星星前辈请教了一下,然后他说这个太黏了,效果不是很好啊,我就不过多展示了。

这个是我的第一篇SIGGRAPH paper。我们是用MPM做了各种切割的模拟。它实现了固体和液体的显示的耦合。然后切割是这个耦合的一个副作用。我们可以切各种切香蕉,切这个兔子,这个叫奶酪。你可以切沙子或者叫搅拌沙子。那这个算这个文章里面呢,我还提出了一种基于移动最小二乘的新的物质点法,它会比之前CCD的物质点法要快两倍。还是基于2016年的蒋老师的一篇叫Implicit Particle-in-Cell paper去进行的一个算法上的精简。这样使得它计算的时候工作量会小一半。

有同学问这个渲染时间和粒子的数目,这个我可以说一说。基本上这边所有的都不是实时的。这个paper里面基本上没有实时的demo。然后切你像这个切兔子或者切奶酪,这个基本上都是100万个到300万~400万个粒子。而这个右下角这个搅沙子,这个大概有我记得有三百五十万个粒子。我们当时模拟它可能得等上一天,还是挺费时间的。但是现在MPM这个算法就是它的性能逐渐的也在提升,所以逐渐的很多实时的应用应该也会出现。

这个是一个更大规模的例子。这个是西瓜分形,2018年我们做了一个,在一台机器上面减10亿个voxel的FEM。然后用FEM做完FEM以后,我们有sensitivity analysis,然后可以做topology optimization。这些概念我后面都会提到。这边大家只要记住这个voxel数目非常多就行了。代码网上也有,然后大家可以自己下来跑一跑。这个跑的时间就非常长了。这个我记得这个demo可能跑了得有一个星期。我记得中间还挂了一次,我还重启了。我对这个事情印象特别深刻。所以性能是非常重要的,在物理模拟里面。

然后最近一个东西我最近开始搞的是可微模拟。那这个东西其实很早以前也有,只不过我们最近把它流程搞简化了一遍。什么叫可微模拟呢?可微模拟就是说这个视频可能不是很流畅,我尽量让它放的流畅一点。笔记本已经弃掉了,现在已经非常卡了,可能是因为刚才跑的那个GPU的程序。可微模拟就是说,我们正常的模拟就是给你一个初始的状况,然后让你预测比如说十秒钟以后这个系统是什么样的。然后呢可微模拟是反过来就说,我希望去求出初始状况的一个扰动对于结果影响是什么样的。然后这个其实就是要求一个梯度对吧。当你有梯度的时候,你就可以对初始状况进行各种各样的优化。比如说这个地方,我们就在求一个初始的水面的高度上,使得经过512个time step以后,可以得到一个太极的pattern。可能现在解释有点抽象,我们后面会继续讲。

就到了今年了。刚才讲了过去10年的故事。那现在我在MIT读博士,现在第3年了。我还是在做物理引擎,但是我花很多时间去写编译器。为什么呢?因为我发现如果你没有一个好的编程语言,没有一个好的编译器,你要写高性能simulation是非常非常难的,就是非常非常费时间。所以我就重造了一个编程语言,这样大家就可以更容易地写自己的simulator。那么现在基本上可以说有一半的时间都在做这个太极编程语言。当然这个里面有非常非常多的贡献是太极社区的同学们一起帮忙做的。真的大家非常不容易。我觉得每天早上一起来就能看到GitHub上面好多的issue,每天跟大家一起来嘛,就跟开网游下副本一样,就解决一个个很难修的bug,进行各种各样的设计决策,真的非常有意思。当然我最近也开了这么个课,然后主要是想跟大家分享一下,怎么样去写物理引擎比较有意思。当然也做做这个直播,带货带一带这个太极编程语言,希望大家都能尝试一下这个新东西。

那么我们接下来讲一讲这门课。我们会注意力集中在哪些关键词上面。我们会简单讲讲各种各样的离散化。那这部分数学比较involved,所以可能也不会讲的特别深。更多的呢因为我自己主要是做计算机方面的,所以我会更多讲一讲,你从一个程序员的角度,怎么去直观地理解各种各样的离散化。

我们会讲怎么样写有效率的求解器。比如说现在你要写一个求解器,很长的时间很可能就是花在这个linear systems solver上面。不同的linear systems solver它的性能有天壤之别。后面我们会继续详细介绍这些preconditioner。

我们会讲讲怎么样去提高生产力,怎么样用比较短的代码实现一个高效的物理引擎。你别看这个短代码啊,听起来好像是一个非常糟糕的metric,好像这个代码行数是measure一个软件是非常糟糕的一个metric。但是你仔细想想,有别的更好的metric吗?其实你很难想到比这个代码行数更好的metric。这也是为什么我喜欢用这种metric来证明一个代码可以用很少代码能写出来。

当然我们也很在意性能。刚才提到很多模拟的我们都要等上一天的时间。然后迭代的话呢,其实你如果要再去做迭代的话,那就非常非常费时间了。

我们也会简单介绍一下硬件的体系结构。因为你如果要写高性能solver的话,你得去了解一下你的硬件是什么样的。如果你不了解硬件呢,那你就很难把它全部利用起来。

我们也会讲讲各种各样的复杂的数据结构。但是太极让这个数据结构的使用变得更加的简单了。这个后面我们会详细介绍。

我们也会讲讲怎么样去做复杂的并行。因为现在并行硬件大行其道。CPU和GPU如果算它吞吐量的话,那真的是天壤之别,一般能差上一个数量级。这边展示的是SIGGRAPH 2020年的一篇比较新的SIGGRAPH paper。这个paper的结果非常非常impressive。他们可以用四个GPU,然后模拟应该是1亿个particle。这个在之前是前所未有的。大家有兴趣的同学可以去读一读这个paper。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_44.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_46.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_48.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_50.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_52.png

最后我们会讲讲怎么去求simulator的导数。导数求出来呢,你就可以做很多有意思的事情。比如说我们这边还有两个机器人,都是软体机器人。上面的是一个,上面机器人他有四块肌肉,就是他有两条腿,每条腿有两半,然后左边一半,右边一半,每一半可以伸缩,或者可以叫变长或者变短,就相当于你的肌肉嘛,你可以肌肉可以收缩或者舒张。那下面一个呢是一个弹簧质点的机器人。上面这个是一个MPM机器人。

好那么我们这个第一讲的上半部分就结束了。我们来下面呢我们介绍一下太极编程语言,因为这个是一个大家都要用上的工具。好,那么现在这个slides我在整个课程期间会不断的更新。因为我们今天只能讲一讲最简单最简单的内容。然后后面我们会逐渐的加入太极里面的一些比较复杂的feature。这些slides呢都会课程结束以后都会在网上有的。所以大家不用担心。如果说有任何slides里面没有介绍的东西了,大家可以去看太极的文档。现在太极的文档在大家努力下,英文文档和中文文档应该都是比较可读的。特别是中文文档真的非常不容易。基本上就以大家以迅雷不及掩耳盗铃儿响叮当仁不让之势,就把这个中文文档给他全部翻译好了。真的太不容易了。当然很多feature我今天讲到的,并不是我自己一个人写的。很多feature是我们社区一起写的。然后我当然也不是唯一的developer。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_54.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_56.png

那么什么是太极呢?太极是一个高性能的领域特定语言。它嵌入在Python里面。所以它的语法和Python非常非常的像。然后你如果会Python,那你基本上很快你就可以学习太极语言怎么用。他这个语言专门是为了计算机图形学的应用来设计的。我们在设计的时候,非常非常注重生产力和可移植性这两个东西。以前在计算机图形学里面是非常难以做到的。我说的这个生产力,不是说你用游戏引擎做游戏的这个生产力,我说的是对于一个想研究图形学算法的人,去写一个图形学算法的生产力。我们也很注重可移植性。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_58.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_60.png

这门课呢,我们建议大家都使用太极编程语言。因为太极程序有什么好处?你在一台机器上写的太极程序,复制粘贴一下到另外一台机器,只要一台机器装了太极,基本上都是能跑的。这个就和你用OpenGL,和你用其他的像CUDA写的程序就完全不一样。因为你很容易,如果你不用太极,你会各种各样的依赖,你就为了让一个画一个三角形,你可能要装三四个包,就非常非常麻烦。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_62.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_64.png

太极呢是面向对象的,然后它是自动并行,并且kernel是mega kernel。这个和深度学习里面的那种coalescability比较弱的kernel是不一样的。我们还有一些比较好的稀疏数据结构以及可微编程的支持。这个我们就后面再介绍了。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_66.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_68.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_70.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_72.png

OK那今天其实就讲几个非常简单的内容:怎么安装,怎么定义数据,怎么做计算,然后怎么进行调试。我们先看看怎么安装。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_74.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_76.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_78.png

那么如果你用Python 3.6,3.7或者3.8,你就可以直接用pip去装一下太极。大家要注意一下,我之前闲聊的时候也说过,就是说国内镜像有的时候它有一点延迟。所以有的时候,比如说官方镜像已经0.6.7了,然后国内镜像可能还是0.6,然后就会有一点延迟。然后就会导致因为太极可能不是最新的版本。这个我自己没有去实践过,但是之前知乎上面有很多人跟我说这个事情。就是说大家装的时候要确认一下版本。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_80.png

然后三个主流的操作系统我们都是支持的。太极程序呢也可以既在CPU上面运行,也可以在GPU上面运行。GPU我们支持CUDA,然后OpenGL和Apple Metal。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/93c403cfd927f2ac75e2d90b2e44b11a_82.png

如果你有一些神奇的配置,比如说你的CPU是ARM,或者你非要用Python 3.9以上,那也没办法,那你就得build from scratch,把太极的源代码下来装吧。但如果我相信用Python 3.9的同学是比较少的。最新的Python已经3.10了,我记得5月25日的时候好像3.10已经出现了。但是大部分同学应该都是3.6或者3.7,3.8可能都是相对

GAMES201-高级物理引擎实战指南2020—P10-Lecture-10-总结—GAMES-Webinar—BV1ZK411H7Hc_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_4.png

在本节课中,我们将回顾整个课程的核心内容,探讨“简单性”在科研与工程中的重要性,并展望物理引擎的未来发展。最后,我们将总结课程收获并表达感谢。


欢迎大家来到我们课程的最后一讲。从第一讲到今天,过去了大约三个月的时间。大家相当于用一个暑假的时间,基本学完了这门课程。

在正式讲解之前,我们首先展示作业二的各种获奖作品。虽然这次作业二时间非常紧,并且有很多同学反映正好遇上期末考试或工作繁忙,但仍有12位同学提交了作品。我们会给这12位同学都寄一份精美的纪念品。后续会找时间撰写文章,让课程以外的同学也能了解到大家在这门课上做出的精彩程序。课程结束后欢迎大家继续提交,但后续提交可能需要等待较久才能收到纪念品,因为课程结束后的组织会更加松散。

我们还要介绍一下ChinaVR上的太极竞赛。之前有很多同学反映报名表太长,成为了报名的阻力,所以我们后来做了一个一分钟完成报名的报名表。这个报名表非常简单,只有四个问题,其中三个是必填的。希望大家都能尝试报名一下。奖项设置非常丰厚,有特等奖一个、一等奖三个、二等奖六个,以及三等奖和优秀奖若干。基本上只要完成一个合格的提交,都会拿到某种奖项。竞赛的网站也放在这里,请感兴趣的同学多多参加。

在正式开始讲今天的内容之前,首先要恭喜各位。因为大家掌握了各种各样的新技能。这些技能包括一门新的编程语言、基于物理的模拟的各种算法、各种数值线性代数的方法,以及现代计算机的处理器微架构、并行编程、GPU编程等各种高性能计算的入门知识,还有稀疏数据结构和可微编程这两个太极的独特功能。能把这些知识都掌握甚至入门,需要花不少精力。这门课难度不小,能坚持到最后的都是勇者。大家应该给自己鼓个掌。

那么我们今天讲什么呢?今天主要是我的一些个人体会。我在构建物理引擎和太极这个项目时所学到的知识。如果让我必须只能用一个词来概括我这10年来从这些事情里面学到的东西,这个词就是 simplicity(简单性)。我后面会论述为什么简单是好的。讲完这个以后,我会讲讲物理引擎的未来,简单讲讲我自己在课程中的收获,最后感谢各方面为这门课程提供支持的老师和同学。

刚才提到,如果只让我用一个词概括我在物理引擎和太极这个项目里学到的东西,这个词一定是 simplicity(简单性)。说到这个简单性,很多同学可能还在学校里面读本科。在基础教育或本科阶段,学生最重要的一个事情是把考试考过。考试时要做的事情是证明自己会某种方法。但是当你本科毕业以后去工作、读PhD做研究或做一个工程时,最重要的能力是证明自己能找到好的方法。当你离开学校以后,有很多情况会改变。第一个改变是你有自己选择方法的权利。第二个很重要的改变因素是你需要与他人合作。这两个因素使得找到一个好方法非常重要。往往在你脱离了学校环境以后,用哪种方法并不重要,重要的是系统性地解决问题。解决问题是最重要的,方法不重要。一天只有24个小时。聪明的人都是一样聪明的。在时间和脑力都非常有限的情况下,我们必须采取的一个措施是:用简单可行的方法去解决大部分简单的问题。因为世界上有很多难题是真的非常复杂的。你必须用简单的方法、用最少的时间把简单的问题解决了,才会有时间去解决真正复杂的问题。别把时间都浪费在解决简单的问题上面,那你就没有时间去解决真正复杂的问题了。

有影响力的创新往往会伴随着复杂性的减少。很多同学觉得新的东西一定会更加复杂,但往往并不是这样。发明它的人往往已经对它进行了最大程度的简化。这有点像日本文化里的一个宗旨:不要给他人带来麻烦。你要发明一个新的东西,必须让你的用户能够轻易地使用它。你造出来的东西必须是简单的,必须不能给他人带来麻烦。如果你写了一份代码非常难以使用、难以读懂,那么在和他人合作时,这份代码可能根本就不会出现在最后的代码库里。因为审阅者会删掉你的代码,因为他不懂你到底在干什么。即使你的代码有幸进入最终代码库,它的维护、测试和正确性都非常难以保证。

刚才说到简单性是好的,反过来说就是复杂性是坏的。为什么复杂性很坏呢?首先,如果你有一个非常复杂的东西,那么其他人不能理解你在做什么。其他人不见得和你有一样的背景知识。如果你刚提出来一个非常复杂的东西,没有人能懂你做的东西,它产生的影响力就非常小。其次,一个复杂的东西要实现它非常缓慢,并且往往难以快速失败。为什么要快速失败呢?因为如果你做科研,你就会知道99%的想法都是不工作的。剩下的1%里面,可能有90%是被前人尝试过的。你只有可能剩下的1‰的想法是能工作的。你必须在精力有限、并且大家都一样聪明的情况下,还想做出有意义的工作。一个非常重要的事情就是能快速地知道这个方法是否有效。你必须能够快速失败。如果你有一个非常复杂的想法,它实现起来非常费时间,你很难快速失败。因为你不知道到底是自己实现错了,还是这个方法本身不行。你很难区分到底是方法复杂,还是自己没有实现好。这其实是复杂方法的原罪。这就引出了第三点:复杂的方法更容易出错,而且出了错以后很难去分析它。因为它其中的成分太多了,你不知道到底是哪一部分搞错了。很多复杂的模拟算法,虽然最后能发表在顶级会议上,但是随着时间推移,很快大家就忘了这个算法,因为它太复杂,没有人能够正确实现。看着一篇论文实现这个算法其实是非常难的。如果你实现了半天发现它不工作,那到底是论文有问题,还是实现得不对,还是自己对论文的理解有问题?你很难知道到底是什么原因。你甚至可以说这是不科学的,因为你不能证伪它。

复杂性还有一个很坏的事情:你有一个很复杂的算法,不管是你手写也好,还是编译器帮你编译也好,它都非常难以高性能地实现。大家可能会觉得复杂的算法往往跑得更快,但是实际情况下并不是这样。复杂的算法首先你得实现正确。假设你能把它实现正确,还记得上一讲吗?上一讲我们提到,对一个算法,如果考虑上计算机体系结构、考虑上各种高性能计算的技巧,你是能够把它提高十倍乃至二十倍的速度。但前提条件是这个算法足够简单。如果说你给我一段普通的随手写的程序,我可以花一些时间重写一遍,可能能快个十倍、二十倍。这些一般都是可行的。但是如果说你给我一个非常复杂的程序,我即使知道我花很多时间能让他快十倍二十倍,我可能都没有动力去做这个事情。因为太复杂、太烦了,大家懒得帮你去做性能优化。所以你要实现一个高级的算法,它首先得是简单的,不能太复杂。复杂了以后没有人能知道怎么去把它高性能化。因为高性能本身就是一个复杂性。人能处理的复杂性是有限的。如果你这个算法本身非常复杂,那剩下来的能够接受的复杂性就非常小。那么代价就是你在剩下来的脑力里面,能够用在优化它的部分是非常少的。所以最后导致的结果就是:复杂的数据结构、自适应数据结构、复杂的网格、复杂的数值格式,最后都会败在暴力解法之下。什么是暴力解法?就是稠密数据结构、一阶精度的有限元或MPM。当然这不绝对,有限元大家有的时候还是会用很高阶精度的。但另外一种方法就是用一阶精度的元素,把分辨率提得很高很高。虽然在严谨意义上这可能不太行,但实际情况下往往就不错了。特别是在你写物理引擎的时候,如果你把你的求解器搞得非常复杂,那编译器也会很难优化你的代码。编译器会遵循一些规则去分析你的程序。如果你的程序非常复杂、难以分析,那编译器也会很困惑,不知道你的代码在干什么,生成的代码质量也会受影响。

既然复杂性这么坏,为什么大家还会把东西搞得非常复杂呢?大家可以想一下这个问题。答案或许出乎我们的意料。答案是什么呢?Because it’s way easier to be complicated than to be simple.(搞出复杂的方法比搞出简单的方法简单多了。)听起来可能有一点矛盾。总体的意思是:对于大部分人来说,如果你精力有限、经验有限、各种资源都有限,你搞出来的方法往往是比较复杂的,而不是简单的方法。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_6.png

我们来看几个具体例子,为什么有很多情况下大家会喜欢把东西搞得非常复杂。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_8.png

以下是大家可能把事情搞复杂的几个动机:

  1. 证明自己很聪明。这是我们的教育里面存在的一个很有意思的现象:我需要通过一些复杂的方法来证明自己和其他人不一样,证明自己很聪明。因为大家的直觉是:你懂这么难的东西,你一定很聪明吧。在学校里面或许这是一个好主意。你可能通过做一些难题,或者通过做一些大家都不懂的方法,显得自己独一无二、鹤立鸡群。但是实际上当你需要去和其他人交互的时候,这几乎永远不是一个好主意。因为如果你去看一看你做的项目的更大图景,其中的关键因素就是你能不能和其他人有效沟通。你的想法能不能从你的脑子里面精确传输到另外一个人的脑子里面。如果是一个简单的方法,沟通已经很困难了。沟通是最难的事情,沟通比写代码难多了。简单方法的沟通已经很难了,更何况一个很复杂的方法。所以一旦你要和他人沟通的时候,把事情搞复杂绝对不是一个好主意。一定要把事情简化简化再简化。这就是为什么简单精炼的一些方法能够流传得很广。

  2. 展示工作量。证明自己做了很多东西。有一些课程项目会要求你写一个五页或十页的报告。结果你搞了一个方法,发现报告好像只能写一页。你是不是可以把方法搞复杂一点,这样就能凑到五页或十页了?学校里的学生往往会有这样的思维:老师要求的方法必须要有复杂性。实际情况下,学校里面大家可能更加过程主义,看你解决问题的过程,因为目的是让你学会解决问题的方法,而不是让你把问题解决。但是当你脱离了老师、脱离了学校环境、脱离考试设定以后,去真正解决实际问题的时候,谁在乎你用什么方法去解决问题呢?你只要把问题解决好了就行了。所以大部分专家并不需要去展示你做了多少工作。

  3. 隐藏错误。这个或许也有点反直觉,但这个逻辑其实也很容易理解。有些人觉得我做的这个工作里面可能有错误,那怎么办呢?我把它搞得非常复杂,这样就没有人证明我是错的。因为没有人理解我这个东西到底在干什么,所以我就是对的。这个逻辑说起来很讽刺,但有些时候势必会有人这么做。当你这么想的时候,其实已经开始违背科学的精神了。卡尔·波普尔对于科学的定义在于:科学必须满足一个特性,就是它是可证伪的。如果你在科学的体系里去论证一个东西,你怎么说明它是科学的?就是说如果我这个论断是错的,你是可以去证明它是错的。如果你把一个东西搞得非常复杂,它既不能被证真,也不能被证伪,那这个东西它就“not even wrong”(连错了都不是),它不属于科学讨论的范畴。

  4. 希望和先前工作区分开来。这样你的论文就可以被接收。你写了一篇论文,方法非常简单,之前可能已经有人做过了。审稿人就会说你的方法和之前某某某论文做的一样,我要拒稿。所以有些做研究的同学可能会把方法搞得非常复杂,这边打个补丁,那边打个补丁,使得它看起来和之前的方法不一样。审稿人在考察这个方法到底之前有没有做过的时候,看这个方法好像非常复杂,那可能之前确实没有人做过吧,至少在创新性上面就不会再来质疑你了。这么做可不可以呢?如果你的目的就是让你的论文被接收,这么做是完全可以的。如果你的目的是尽快毕业、发表足够数量论文,这么做是完全可以的,也有很多人确实就是这么做的。但是如果你真的希望你的文章有很大的影响力,那最好还是别这么做。因为这些文章做出来,说白了就叫做增量性工作。如果你把这个推到极致,大家就叫它“灌水”。就是说我把一个方法这边修修改一下,里面修改一下,把它搞得非常复杂,本身原来想法的精简性就没了,搞出来一个非常复杂的算法。这种论文投了也能中,但是一般来说很难产生很大的影响力。

  5. 懒得去简化它。一个想法诞生的过程,往往可能是20%的时间是在想这个东西,然后后面80%的时间是在去简化你这个东西。你可能20%时间做个雏形,写上一坨乱七八糟的代码,然后花80%的时间去简化它,使得得到一个最小份的、还能工作的代码。如果你只花前面20%时间,你就只用了别人1/5的时间,你也能得到一个非常复杂的算法。这种情况下,如果把后面80%忽略掉,那就相当于懒得去优化。这样其实也是可以的。但是你如果希望别人能够理解、希望你这个工作从长远角度来看很有影响力,那最好还是花一点时间去简化你的想法。

  6. 受众过于宽容。这是一个很有意思的例子。我记得当时我在姚班上一门课,老师说如果你的方法非常复杂,我们看了半天也不知道它是对的还是错的,我们会当做错了来处理。在大部分情况下,甚至有些人在考试的时候会故意写出来一坨显得自己好像懂得很多。阅卷老师可能会觉得这个同学写了这么多,我虽然不知道他是对的还是错的,好歹给他个辛苦分吧,给他个两三分。这样其实就是纵容了这种把事情搞得非常复杂的想法。但是这种事情只会在学校里面出现。在实际的过程中,没有人会欣赏你这种做了很多工作、最后没有人能够理解的事情。我记得当时那个老师就是说,如果你不会,那你就写你不会,我会给你三分。但是你不要说你不会,你写上一大坨,这样我作为阅卷人浪费很多时间,你也浪费了很多时间。

所以你看其实大家有各种各样的动机,会去把事情搞得复杂。还有一个有意思的事情是:大家会觉得用简单方法的人很傻。是这样吗?其实并不是,绝对不是这样。实际上世界上最聪明的人都知道把事情搞简单的重要性。

我们来看看:

  • 爱因斯坦说:任何事情都应该被搞得尽可能的简单,但不要过于简单。
  • C++之父Bjarne Stroustrup在CppCon会议上给过很多关于简单性的演讲。C++现在已经变成了一个非常复杂的语言。他的演讲中有一个叫做“Make Simple Tasks Simple”(用简单的方法解决简单的任务)。Being too clever is not clever.(表现得太聪明并不是聪明人的做法。)他举了一个很有意思的例子:调试一个程序比写程序还要难两倍。因为大家写程序的时候,大部分时间并不是在敲键盘上面,大部分时间是在调各种bug上面。如果说你在写你的程序的时候,已经把你的全部脑力都用上了,那你调试的时候就没法调试了。因为你竭尽全力才把代码写出来,调试又比写代码要难,那你就没有能力去调试它了。这其实是一个很精辟的话。每当我看到各种太极社区里的PR搞得非常复杂,或者看到合作者写非常复杂代码的时候,我就会援引这些例子。首先,调试比写代码难。如果你写代码的时候已经搞得很难,那你怎么调试?第二,别人来接替你的工作,比你自己在你的代码上工作难。如果你自己写代码的时候就把代码搞得非常复杂,那你找谁来接替?你只能找一个比自己更聪明的人来接替。但是问题又来了,在MIT或者在姚班这样的地方,大家都一样聪明,那你上哪儿去找这样的人?很难找到这样的人的。那么最后结果就是你的项目就自己死了。
  • 图灵奖得主Tony Hoare(他发明了快速排序)说:有两种软件设计的方法。第一种是使得软件足够简单,以至于显然没有错误。第二种是使得软件足够复杂,以至于没有显然的错误。第一种方法难得多。 他的第二种方法其实就是我们之前提到的,把一个事情搞得非常复杂,这样就没有人能指出他其中到底哪里错了。一个常见的做法是你发明了一个算法,你发现这个算法在这种情况下会错,我给他打个补丁去特殊判断一下;在那种情况下好像也会错,我给他打个补丁特殊判断一下。最后你得到了一件满是补丁的衣服。现在我问你这个衣服它有没有洞?你很难说,因为补丁太多了。我不知道它到底有没有洞,或者说我很难指出它哪里有洞。能证明它没有洞吗?并不能。
  • 2017年的图灵奖得主David Patterson在Google给过一个演讲,叫做“How to Have a Bad Career”(如何毁掉你的职业生涯)。他列出了十个毁掉自己职业生涯的做法(他在说反话)。他的第二点是:Let complexity be your guide(让复杂性指导你)。他其中说到两点其实我们之前也提到:It’s easier to be complicated(把事情搞复杂是很简单的)。这个其实很多人意识不到这一点。他会觉得复杂的东西就是复杂,简单的东西就是简单。其实你在真正做研究也好、做工程也好,打补丁把事情搞复杂永远是简单的方法,而提出简单的想法能解决问题,这个往往是最难的。他也提到,如果说我的方法很简单,那我怎么能显示出我和我的同事们的优越性呢?我怎么能显示出我比我的同事们更聪明呢?如果我的方法很简单的话。这其实是一个讽刺的反话。其实并不是这样,往往最伟大的想法都是很简单的。

好像说了这么多,其实都是一些抽象例子。我们来看一看我自己的体会。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_10.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/548e6245ad55bc04a0d5b9ec8f03a053_11.png

  • 我以前做过一篇论文叫做“ChainQueen”,发表在SIGGRAPH 2019上。这个论文一开始的目的就是要去做一个可微分的MPM求解器。但是在做这个项目的时候,我们还没有一个自动微分系统,我们必须手动的去求它的导数。我当时求这个导数求的真的非常痛苦。由于求导数你要用很多链式法则,所以我就把这个“chain”这个词塞到了项目的名字里面。但是当时有一个非常好的事情:当时我们已经有“Moving Least Squares MPM”(MLS-MPM),这是一个比传统的APIC MPM要简单很多的算法。正是因为MLS-MPM非常非常简单,我们才能够在25天之内做出一个SIGGRAPH论文,并且能正确地把它的导数推出来。如果你给我一个非常复杂的MPM算法,我根本就不会去推它的导数,因为太难了,推不对。人的脑力是有限的。如果搞得非常复杂,没有人能正确求出它的导数。所以说如果没有这个简化版的MPM,后面我们有很多工作都是做不了的。后面大家会知道我们有自动微分系统,我们有一个叫做“DiffTaichi”的项目,它的目的其实就是进一步简化可微编程的过程,再也不需要去手动求导数了。当时我推这个导数是非常痛苦的,其实就是这么一坨公式,你要把它们推对还是要花点精力的。
  • 另外一个例子:太极它之所以存在,它存在的目的就是把一切东西都能够简化。有一个算法叫做“Multigrid Preconditioned Conjugate Gradient”(MG-PCG,多重网格预条件共轭梯度算法),用来解线性系统(比如泊松方程)。这个算法的流程其实还是挺复杂的,一般大家都不愿意去实现,因为太难了。但是在有了太极以后,我可以只用80分钟、用300行代码把这个算法实现。这个80分钟是我从我的git log里面去看,两个commit的实现前和实现后,差了80分钟。这是因为太极这个编程语言非常非常容易,我才能做到这一点。如果你给我一个非常难用的工具,你给我个C++或者CUDA,那我绝对不可能在80分钟之内写一个GPU的MG-PCG出来。GAMES201做了什么呢?GAMES201把太极的这个想法继续推了一步。现在所有的人都知道怎么去写一个多重网格求解器。我看大家交的作业里面很多都是用上了多重网格。可以说现在MG-PCG这个算法在中国的物理模拟的圈子里面,这个代码可能已经人手一份了,大家都会写这个算法。所以你可以看到,从这个很少有人能正确实现,到我自己能轻易正确实现,到大家都能轻易正确实现,这个其实是有一个过程的。大家是付出了很多很多

GAMES201-高级物理引擎实战指南2020—P2-Lecture-2-拉格朗日视角-1----GAMES-Webinar—BV1ZK411H7Hc_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_4.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_6.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_8.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_10.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_0.png

在本节课中,我们将要学习基于物理的动画模拟中的两个基础概念:弹簧质点系统和光滑粒子流体动力学。课程将从拉格朗日视角出发,介绍这些模型的基本原理、数学公式和实现方法,并穿插讲解一些实用的编程技巧。

课程公告与作业展示 📢

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_12.png

我是胡元明,一名研究基于物理动画的博士生。在开始正式内容前,我们先同步一些课程相关的信息。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_14.png

太极框架已更新至 v0.6.8。有同学反馈新版本在编译时可能因语法检查更严格而出现问题。如果遇到编译错误,可以暂时退回使用 v0.6.7。核心开发者正在努力修复,并计划尽快发布 v0.6.9。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_16.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_18.png

作业零的评选已经结束。我们从众多优秀作品中选出了八个,并在知乎上进行了展示。这些作品涵盖了从离散傅里叶变换到流体模拟卡门涡街等多种算法,代码通常只有百行左右,易于学习和运行。获奖同学将获得神秘课程纪念品。

下周将发布作业一。与作业零的自由创作不同,作业一会增加一些限制,例如必须实现物理引擎相关功能,但整体上仍鼓励自由发挥。

以下是关于编程环境的一些常见问题与建议:

  • 在 Python IDLE 中运行太极可能有问题,建议尝试使用 IPython 或 Jupyter Notebook。请注意,在 Jupyter 中,太极内核的 print 输出会显示在启动服务器的终端上。

  • 代码格式:建议使用 yapf 工具自动格式化 Python 代码,以统一风格,无需纠结空格等细节。命令示例:yapf -i your_file.py

  • 在课程论坛中贴代码时,请使用 Markdown 的代码块语法(三个反引号 ```后注明语言,如 python),以便获得语法高亮,提升可读性。

  • 编译优化:如果编译速度较慢,可以尝试关闭高级优化选项:ti.core.set_gdb_advanced_optimization(False)

  • 输出动画:新版本太极提供了将模拟结果导出为 MP4 视频或 GIF 动图的功能,方便在论坛上分享炫酷的效果。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_20.png

物理模拟的两种视角:拉格朗日 vs. 欧拉 🔄

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_22.png

在基于物理的动画中,模拟各种介质(如固体、流体)时,存在两种基本视角:拉格朗日视角和欧拉视角。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_24.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_26.png

拉格朗日视角 如同在介质中放置许多随波逐流的小纸船(传感器),这些节点会随着材料一起运动。每个节点携带并追踪自身的物理量(如位置、速度)。常见的拉格朗日表示方法包括粒子、三角形网格等。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_28.png

欧拉视角 则如同在空间中固定一些木桩(传感器)。这些节点本身不移动,而是测量“流过”该固定点的介质的物理量(如速度)。欧拉视角通常使用背景网格来表示场。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_30.png

一个简单的记忆口诀是:拉格朗日方法,质点随波逐流。最常见的组合是:拉格朗日视角使用粒子,欧拉视角使用网格,但这并非绝对。

上一节我们介绍了两种基本的模拟视角,本节中我们来看看拉格朗日视角下两个最经典和实用的模型。

弹簧质点系统 🧵

弹簧质点系统模型虽然简单,但极其有用,可用于模拟布料、头发、弹性体等多种材料。其核心思想是用质点和连接它们的弹簧来离散化表示一个物体。

数学原理与公式

该系统基于两个基本物理定律:胡克定律和牛顿第二定律。

  1. 胡克定律 (弹簧力):连接质点 ij 的弹簧所产生的力为:

    F_{i->j} = -k * (|x_i - x_j| - r) * ((x_i - x_j) / |x_i - x_j|)

    其中:

    • k 是弹簧的刚度。

    • x_i, x_j 是质点的位置。

    • r 是弹簧的静止长度。

    • ((x_i - x_j) / |x_i - x_j|) 是从 j 指向 i 的单位方向向量。

  2. 牛顿第二定律 (运动方程):对于质点 i,其受到的总力是所有相连弹簧的力与外力(如重力)的矢量和。然后:

    a_i = F_{total, i} / m_i

    v_i = dx_i / dt

    其中 m_i 是质点的质量。在模拟中,通常预计算质量的倒数 1/m_i,用乘法代替除法以提高效率。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_32.png

时间积分方法

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_34.png

计算机模拟需要在离散的时间步 Δt 上推进系统状态。我们介绍了两种显式积分器:

  1. 前向欧拉法

    v_{t+1} = v_t + Δt * a_t

    x_{t+1} = x_t + Δt * v_t

  2. 半隐式欧拉法 (Symplectic Euler)

    v_{t+1} = v_t + Δt * a_t

    x_{t+1} = x_t + Δt * v_{t+1}

半隐式欧拉法更常用,因为它具有更好的能量守恒性质。一个模拟步(Substep)的核心代码通常遵循以下三步:

  1. 计算所有质点受到的合外力,并更新速度 (v_{t+1} = v_t + Δt * F/m)。

  2. 处理碰撞(例如,与地面碰撞,将穿入地下的质点的垂直速度分量设为零)。

  3. 用新速度更新位置 (x_{t+1} = x_t + Δt * v_{t+1})。

显式积分的稳定性问题

显式积分器有一个主要缺点:容易数值爆炸。对于弹簧系统,存在一个稳定性条件:

Δt <= sqrt(m / k)

这意味着当弹簧刚度 k 很大或质点质量 m 很小时,允许的最大时间步长 Δt 会非常小,否则模拟会失稳。这限制了显式方法对“硬”材料的模拟效率。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_36.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_38.png

隐式积分简介

为了允许更大的时间步长,可以使用隐式积分器,如后向欧拉法。其公式为:

v_{t+1} = v_t + Δt * a_{t+1}

x_{t+1} = x_t + Δt * v_{t+1}

注意,加速度 a_{t+1} 依赖于未来时刻 t+1 的状态,这就形成了一个需要联立求解的方程系统。

通过将力函数 F(x_{t+1})x_t 处进行一阶泰勒展开(线性化),可以将问题转化为求解一个形如 A * v_{t+1} = b 的线性系统。其中矩阵 A 通常是大规模稀疏矩阵。

求解此类线性系统是物理模拟的核心问题之一。最简单的方法是雅可比迭代法,其核心思想是逐行更新解向量,使其满足当前行的方程。以下是雅可比迭代的简化示意代码:

# 假设 A, b 已知,x 初始为0或猜测值
for iter in range(num_iterations):
    x_new = ti.Vector([0.0]) # 初始化新解
    for i in range(n):
        sigma = 0.0
        for j in range(n):
            if j != i:
                sigma += A[i, j] * x[j]
        x_new[i] = (b[i] - sigma) / A[i, i] # 更新第 i 个分量
    x = x_new # 更新解

在实际应用中,对于大规模系统,会使用更高效的方法如共轭梯度法及其变种。

隐式积分虽然稳定,允许大步长,但每个时间步的计算成本更高,实现更复杂,且可能引入过度的数值阻尼,使物体看起来“僵硬”。

光滑粒子流体动力学 (SPH) 💧

SPH 是一种经典的拉格朗日流体模拟方法,它用携带物理量的粒子来离散流体,并通过核函数对周围粒子进行加权平均来估算连续场。

核心思想与公式

SPH 的核心是场近似公式。在位置 x 处的某个物理量 A(如密度、压强)的近似值为:

A(x) ≈ Σ_j m_j * (A_j / ρ_j) * W(|x - x_j|, h)

其中:

  • 求和遍历所有粒子 j

  • m_j, ρ_j, A_j 是粒子 j 的质量、密度和携带的物理量。

  • W 是核函数,具有紧支性(在距离 h 外为零),起到平滑和加权的作用。

  • h 是核函数的支持半径。

SPH 非常适合模拟具有自由表面(如水流)的流体,因为它天然地描述了介质分布,无需背景网格。

WCSPH:弱可压缩SPH

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_40.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_42.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_43.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_45.png

最简单的 SPH 流体模型之一是弱可压缩 SPH。它求解以下运动方程(忽略粘性):

Dv / Dt = - (∇p / ρ) + g

其中:

  • Dv / Dt 是物质导数(跟随粒子运动的导数)。

  • p 是压强。

  • g 是外力(如重力)。

压强通过状态方程与密度关联:

p = B * ((ρ / ρ_0)^γ - 1)

其中 B 是体积模量,ρ_0 是静止密度,γ 是常数(通常取7)。当局部密度 ρ 高于静止密度时,压强增大,产生排斥力以维持体积。

密度 ρ_i 本身也通过 SPH 公式从周围粒子的质量估算得到。

梯度的计算(如 ∇p)在 SPH 中有专门的对称化公式以保证动量守恒。拉普拉斯算子(用于粘性项)也有对应的离散形式。

一个基础的 WCSPH 模拟循环如下:

  1. 对于每个粒子 i,根据周围粒子估算其密度 ρ_i

  2. 根据状态方程由密度计算压强 p_i

  3. 使用 SPH 梯度公式计算压强梯度 ∇p_i

  4. 计算加速度 a_i = -∇p_i / ρ_i + g

  5. 使用显式时间积分器(如半隐式欧拉)更新粒子的速度和位置。

变种与加速

  • PCISPH:预测-校正格式,能更好地保持不可压缩性。

  • PBF:基于位置的流体,将位置约束与 SPH 结合,在实时应用中很流行。

  • DFSPH:散度自由的 SPH,直接强制速度场无散。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_47.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_48.png

CFL条件:在显式积分中,除了材料刚度限制,还需考虑粒子运动引起的稳定性限制。柯朗数定义为:

C = u_max * Δt / Δx

其中 u_max 是粒子最大速度,Δx 约等于粒子间距(或 h)。模拟中需保持 C 小于一个阈值(如0.1~1),否则可能失稳。这意味着如果粒子运动过快,必须减小时间步长 Δt

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_50.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_52.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_54.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_56.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_58.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_59.png

邻域搜索加速:朴素 SPH 的时间复杂度是 O(N^2)。实际中通过空间数据结构(如均匀网格哈希)加速邻域搜索,将复杂度降至近似 O(N)。基本思路是将空间划分为体素,每个粒子根据位置存入对应体素。搜索某个粒子的邻居时,只需查找其所在体素及相邻体素内的粒子即可。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_61.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_63.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_65.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_67.png

表面重建与渲染

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_69.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_71.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_73.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_75.png

从 SPH 粒子生成用于渲染的流体表面网格是一个独立课题。常用方法是:

  1. 将粒子光栅化到一个背景网格上,生成一个密度场或符号距离场。

  2. 使用像 OpenVDB 这样的库对场进行平滑等操作。

  3. 使用移动立方体等值面提取算法从处理后的场中生成三角形网格。

在实时应用中,也常采用屏幕空间渲染技术,直接将粒子渲染到帧缓冲区,并模拟流体的折射、反射等光学效果。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_77.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_79.png

总结与展望 🎯

本节课我们一起学习了拉格朗日视角下的两个基础物理模拟模型:

  1. 弹簧质点系统:理解了其基于胡克定律和牛顿定律的数学模型,掌握了显式(前向/半隐式欧拉)和隐式(后向欧拉)时间积分方法,并认识了显式方法的稳定性限制。

  2. 光滑粒子流体动力学:了解了 SPH 用粒子离散流体、通过核函数近似场量的核心思想,学习了 WCSPH 的基本流程和关键公式,并接触了其变种与加速方法。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_81.png

我们还穿插介绍了实用的编程技巧,如代码格式化、论坛贴代码、动画输出等。这些模型是构建更复杂物理引擎的基石。下节课我们将深入欧拉视角,探索基于网格的流体模拟方法。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_83.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/d04ab0506a9353b8b06d1e6fd43594d2_85.png

演示中弹簧质点系统和雅可比迭代求解器的代码已发布在课程论坛,欢迎大家尝试、修改和整合。期待大家在作业一中创造出更精彩的作品!

GAMES201-高级物理引擎实战指南2020—P3-Lecture-3-拉格朗日视角-2----GAMES-Webinar—BV1ZK411H7Hc_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_1.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_3.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_5.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_7.png

在本节课中,我们将学习弹性体的模拟基础,包括形变、应力应变、超弹性材料模型以及线性有限元方法。同时,我们也将探索太极编程语言的一些高级特性,以帮助我们更高效地编写物理模拟程序。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_9.png

课程安排与作业 📋

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_11.png

上一讲我们介绍了拉格朗日视角下的物理模拟基础。本节中,我们来看看本课程的作业安排和一些后续工作。

作业零已经告一段落。今天我们会继续评出几个优秀作品。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_13.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_15.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_17.png

作业一今天发布,时间从6月15日到7月11日。这是一个跨度较长的区间,中间预计会空出一周,以便大家实现作业并消化课程内容。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_19.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_21.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_23.png

以下是作业一的具体要求:

  • 题目:显式时间积分器与隐式时间积分器的对比。

  • 内容:选择喜欢的模拟器,同时实现显式和隐式时间积分,并从性能、准确度、数值稳定性等角度进行对比。

  • 可选模拟器:隐式弹簧质点系统、隐式FEM、PCI-SPH、完全隐式的DFSPH或MPS。

  • 建议:组队分工合作,但个人完成也可以。

  • 提交格式:建议在Github上托管代码,在论坛发布链接并附上关键图表和分析。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_25.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_27.png

作业二将作为最终作业,目标是实现一个可交互的物理模拟器,可以进行高质量的渲染和可视化。

作业三暂定取消。


弹性模拟基础 🧱

接下来,我们进入本节课的核心内容:弹性体的模拟。弹性模拟能带来不错的视觉效果,也是其他复杂材料(如弹粘性、弹塑性)的基础。

形变与形变梯度

形变描述了一个材料当前形状与其静止状态之间的差异。

形变映射(Deformation Map)是一个函数 φ,它将材料的静止位置 X 映射到形变后的位置 x

x = φ(X)

形变梯度(Deformation Gradient)F 是形变映射关于静止位置的导数。

F = ∂φ/∂X

形变梯度 F 描述了材料局部的拉伸、压缩和旋转。其行列式 J = det(F) 表示体积变化率(形变后体积/静止体积)。

应力、应变与超弹性材料

弹性是指材料在形变后具有恢复其原始形状的趋势。

超弹性模型(Hyperelasticity)通过一个应变能密度函数 Ψ 来定义材料的应力-应变关系。该函数是形变梯度 F 的函数,用于惩罚形变。

Ψ = Ψ(F)

应力(Stress)是材料内部单位面积上的内力,用于抵抗形变。应变(Strain)是形变程度的度量。在本课程中,我们可以简单地将应变视为形变梯度 F

以下是几种常用的应力张量:

  • 第一皮奥拉-基尔霍夫应力(PK1 Stress)P:定义为应变能密度关于形变梯度的导数,P = ∂Ψ/∂F。它在静止空间中进行计算。

  • 柯西应力(Cauchy Stress)σ:也称为真实应力(True Stress),在形变后的空间中进行计算,并且是对称张量。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_29.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_31.png

这些应力张量之间可以相互转换,例如:

P = J σ F^{-T}
τ = J σ

其中 τ 是基尔霍夫应力(Kirchhoff Stress)。

材料参数

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_33.png

描述材料弹性通常使用以下参数:

  • 杨氏模量(Young‘s Modulus)E:表征材料抵抗拉伸或压缩变形的能力,类似于弹簧的劲度系数 k

  • 泊松比(Poisson‘s Ratio)ν:描述材料横向变形与纵向变形之比。对于常见材料,0 < ν < 0.5。ν=0.5 表示材料不可压缩。

  • 拉梅参数(Lamé Parameters)λμ:另一种常用的材料参数组,可以通过 Eν 计算得出。

常见的超弹性模型

在图形学中,常用的超弹性模型有:

  • Neo-Hookean:各项同性材料中最常用的模型,适用于大形变。

  • (Fixed)Corotated:在线性弹性的基础上进行了修正,解决了材料旋转后体积无惩罚增大的问题,适用于中等形变。

线性弹性模型(Linear Elasticity)计算简单,但仅适用于小形变分析。


线性有限元方法简介 🔺

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_35.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_37.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_39.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_41.png

有限元方法(FEM)是一种将连续偏微分方程离散化求解的通用方法。其核心思想是将求解域离散为许多小单元(如三角形、四面体),并在单元顶点上存储未知量。

本节我们介绍最简单的线性三角形/四面体有限元。它假设在每个单元内,形变映射 φ 是一个仿射变换,即形变梯度 F 在单元内为常数。

x = F X + b

对于三角形单元,我们可以利用其三个顶点的静止位置和形变后位置,直接计算出该单元的形变梯度 F

单元的弹性势能 Uₑ 可以通过对应变能密度在单元体积上积分得到。由于 F 为常数,积分简化为:

Uₑ = Vₑ * Ψ(F)

其中 Vₑ 是单元在静止状态下的体积(或面积)。

整个物体的总弹性势能是所有单元势能之和。顶点上的受力 f 是总势能关于顶点位置 x 的负梯度:

f = -∂U/∂x

在显式时间积分中(如半隐式欧拉法),我们可以利用上述公式计算受力,进而更新顶点的速度和位置。利用太极的自动微分功能,我们可以避免手动推导复杂的受力公式,极大地简化了实现。

对于隐式时间积分,则需要求解线性系统,涉及计算力的微分(即刚度矩阵),这需要求应变能密度函数 Ψ 关于 F 的二阶导数,实现更为复杂。


太极编程语言高级特性 🐉

在实现了基础的物理模拟后,让我们看看如何用太极的高级特性来优化和扩展我们的代码。

面向对象编程

太极支持面向数据编程(DOP)与面向对象编程(OOP)的混合范式(ODOP)。通过装饰器,可以创建可复用的太极类。

以下是三个关键装饰器:

  • @ti.data_oriented:修饰一个太极类。

  • @ti.kernel:修饰类中的核函数。

  • @ti.func:修饰类中的设备函数。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_43.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_45.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_47.png

使用类可以将相关的张量和函数封装在一起,提高代码的模块化和可复用性。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_49.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_51.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_53.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_55.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_57.png

元编程

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_59.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_61.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_63.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_65.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_67.png

元编程允许将各种参数(如张量、类、函数、数值)传递给太极核函数,实现编译期计算和维度无关编程。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_69.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_71.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_72.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_74.png

  • 模板核函数:使用 ti.template() 类型提示来定义可以接受多种类型参数的核函数。

  • 维度无关编程:使用 ti.grouped() 循环,可以编写适用于任意维度张量的通用核函数。

  • 编译期分支与循环展开:使用 ti.static() 进行编译期条件判断和循环展开,可以消除运行时开销,并处理太极中要求矩阵/向量下标必须是编译期常数的限制。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_76.png

可微编程

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_78.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_80.png

太极支持反向模式自动微分(Reverse-Mode AutoDiff),可以自动计算标量函数关于其输入的梯度。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_82.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_84.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_85.png

主要应用有两种方式:

  1. 简化受力计算:将弹性势能定义为标量函数,利用自动微分直接求出势能关于顶点位置的负梯度(即受力),无需手动推导。

  2. 可微模拟器:对整个物理模拟过程进行微分,可用于优化初始状态、控制器参数或进行对抗样本生成等任务。需要注意的是,这需要存储整个时间步的历史状态,内存消耗较大。

使用 ti.ad.Tape() 可以自动记录前向计算并执行反向传播。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_87.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_89.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_91.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_93.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_95.png

三维可视化

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_97.png

太极内置了 ti.GUI 用于二维可视化。对于三维结果,助教团队开发了 ti.export_poly() 功能,可以将粒子或网格数据导出为 .poly 格式文件。该文件可以被 Houdini 等专业三维软件导入和渲染,方便大家查看三维模拟结果。


总结 🎯

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_99.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_101.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_103.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_105.png

本节课我们一起学习了弹性体模拟的核心概念。我们从形变梯度和应力应变出发,介绍了超弹性材料模型。然后,我们探讨了线性有限元方法的基本原理,以及如何利用它和显式积分器实现一个简单的弹性模拟器。最后,我们了解了太极编程语言在面向对象、元编程、可微编程和三维可视化方面的高级特性,这些工具将帮助我们更高效、更灵活地构建复杂的物理模拟程序。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_107.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/77a6008ce0e4cbc90fade4ce72ba0e46_109.png

希望大家通过动手实践作业,巩固对这些知识的理解。我们下节课将进入流体模拟的主题。

GAMES201-高级物理引擎实战指南2020—P4-Lecture-4-欧拉视角—GAMES-Webinar—BV1ZK411H7Hc_note

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_4.png

在本节课中,我们将学习欧拉视角下的流体模拟。我们将从拉格朗日视角的回顾开始,逐步引入欧拉视角的核心概念,并详细讲解不可压缩纳维-斯托克斯方程的求解过程,包括平流、外力和投影三个关键步骤。课程将涵盖数据结构、数值格式以及求解大型线性系统的迭代方法,旨在为初学者提供一个清晰、实用的入门指南。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_6.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_8.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_10.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_11.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_13.png

课程概述与回顾

上一讲我们主要介绍了拉格朗日视角。在拉格朗日视角中,粒子随材料在空间中移动,并记录自身的位置、速度等属性。其关键在于传感器或流体粒子会随着流体或固体一起运动。

本节中我们来看看欧拉视角。欧拉视角的特点是采样点在空间中固定不动。每个采样点记录的是材料以何种速度穿过该点。可以将其理解为在水中插入许多固定的桩,每个桩都知道周围水流的流速。

核心概念:材料导数

为了联系欧拉视角和拉格朗日视角,我们引入一个核心概念:材料导数。其符号通常用大写的 D 表示。

材料导数描述了一个物理量随材料一起移动时的变化率。它由两部分组成:

  1. 物理量关于时间的偏导数 ∂/∂t

  2. 材料速度 u 点乘该物理量的空间梯度

公式

Dφ/Dt = ∂φ/∂t + u · ∇φ

直观理解是,一个随材料移动的采样点,其物理量的变化一方面来自该点本身随时间的变化,另一方面来自材料移动到不同位置所导致的变化。

常见的例子是温度变化。如果把温度计绑在粒子上,温度变化既包含该点温度自身随时间的变化,也包含粒子移动到不同温度区域所带来的变化。

不可压缩纳维-斯托克斯方程

欧拉法流体模拟的核心是求解不可压缩纳维-斯托克斯方程。我们介绍一个简化版本。

公式

Du/Dt = - (1/ρ) ∇p + ν ∇²u + g

这个方程描述了流体微元速度随时间的变化。其中:

  • - (1/ρ) ∇p 是压强梯度导致的力。

  • ν ∇²u 是粘性项,在图形学中常被忽略以突出涡流等视觉效果。

  • g 是重力加速度。

此外,对于不可压缩流体,速度场的散度必须为零:

公式

∇ · u = 0

这意味着流体局部不会被压缩或拉伸,密度保持不变。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_15.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_17.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_19.png

算子分裂法

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_21.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_23.png

直接求解完整的NS方程很复杂。我们采用算子分裂法将其分解为几个更简单的步骤。直观上,算子分裂法就是将复杂的时间导数拆分成若干个易于处理的部分。

以下是分裂后的三个主要步骤:

  1. 平流:只考虑速度场自身的输运。

    ∂u/∂t = - (u · ∇) u

  2. 外力:添加重力等外力。

    ∂u/∂t = g

  3. 投影:施加压强梯度力,并使速度场满足无散条件。

    ∂u/∂t = - (1/ρ) ∇p,且满足 ∇ · u = 0

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_25.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_27.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_29.png

对时间进行离散化后,每个时间步就对应三步操作:

  1. 通过当前速度场,计算出下一时间步速度场的初始估计 u*(仅考虑平流)。

  2. u* 上加上外力(如重力),得到 u**

  3. u** 进行投影,施加一个合适的压强场 p,使得最终速度场 u^{n+1} 的散度为零。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_31.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_33.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_35.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_37.png

数据结构:网格

在欧拉法中,我们需要一个数据结构来表示空间中的速度场和压强场。最常用的是均匀网格

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_39.png

以下是两种常见的网格存储方式:

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_41.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_43.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_45.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_47.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_49.png

  • 中心网格:将所有物理量(速度u、v分量,压强p)存储在网格单元的中心。实现简单,但可能导致数值误差。

  • 交错网格:将速度的不同分量和压强存储在网格的不同位置。例如,水平速度 u 存储在单元垂直边的中心,垂直速度 v 存储在单元水平边的中心,压强 p 存储在单元中心。这种方法能有效避免“棋盘格”伪影,是更常用的选择。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_51.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_53.png

由于我们只在离散的网格点上有数值,为了得到空间中任意点的值,需要使用插值技术。最常用的是双线性插值。它利用一个网格单元四个角点的值,通过面积加权平均的方式,计算出单元内任意点的近似值。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_55.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_57.png

平流

平流步骤负责移动流体的物理量(如速度、温度)。其目标是计算下一个时间步网格点上的物理量值。

有多种平流格式,它们在数值粘性、稳定性和性能之间进行权衡。我们介绍两种:

1. 半拉格朗日法

这是最简单、最稳定的方法,但数值粘性较高。

思路:要计算下一时间步 x 点的值,就沿着当前速度场反向追踪 Δt 时间,找到 x 点在上一个时间步的位置 x_prev。然后,在上一个时间步的速度场中,对 x_prev 进行双线性插值,得到的结果就作为 x 点在新时间步的值。

代码逻辑

for 每个网格点 x:
    x_prev = backtrace(x, u, -dt)  # 反向追踪
    u_new[x] = bilinear_interpolate(u_old, x_prev) # 插值得到新值

反向追踪的精度会影响结果。简单的欧拉法(x_prev = x - u(x)*dt)误差较大。使用更高阶的龙格-库塔法(如RK2中点法)进行反向追踪,可以显著提高精度,减少“越转越小”的误差。

2. MacCormack / BFECC 法

这是一种更精确的格式,能更好地保持物理量的锐利度,减少数值耗散。

思路

  1. 先用半拉格朗日法平流一步,得到中间结果 u*

  2. 再将 u* 反向平流一步(即用 -dt 做平流),得到 u**

  3. 理论上,如果平流完美,u** 应等于原始的 u。两者的差异 error = u - u** 就近似为平流误差。

  4. 最终结果修正为:u_final = u* + 0.5 * error

这种方法能有效减少物理量(如烟雾边界)在平流中变得模糊的问题。但需要注意,修正步骤可能产生过冲,通常需要配合值域限制来避免伪影。

投影与泊松方程

投影步骤的目标是求解一个压强场 p,使得施加其梯度力后,速度场变得无散。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_59.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_61.png

推导后,我们得到一个关于压强 p泊松方程

公式

∇²p = (ρ/Δt) ∇ · u

我们需要在离散网格上求解这个方程。对于网格点 (i, j),其拉普拉斯算子可以用五点差分格式近似:

(∇²p)_{i,j} ≈ (p_{i+1,j} + p_{i-1,j} + p_{i,j+1} + p_{i,j-1} - 4p_{i,j}) / Δx²

速度的散度 ∇ · u 也可以利用交错网格上的速度值进行离散近似。

这样,我们就为每个网格点建立了一个线性方程。将所有方程组合起来,便得到一个大型线性方程组:

公式

A p = b

其中矩阵 A 非常大(N*M × N*M),但非常稀疏(每行只有少数几个非零元素)。

求解线性系统

求解 A p = b 是投影步骤的核心。对于大规模稀疏系统,直接解法成本过高,通常采用迭代法。

共轭梯度法

共轭梯度法 是求解对称正定线性系统最有效的迭代法之一。其实现相对简洁,通过迭代在Krylov子空间中寻找最优解。

算法框架如下:

给定初始解 p, 计算残差 r = b - A p, 令搜索方向 s = r
while 残差 r 的范数未收敛:
    α = (r·r) / (s·A s)
    p = p + α * s
    r_new = r - α * A s
    β = (r_new·r_new) / (r·r)
    s = r_new + β * s
    r = r_new

迭代法的收敛速度与矩阵 A条件数密切相关。条件数越大,收敛越慢。

预条件子

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_63.png

为了加速收敛,我们使用预条件子。其思想是求解一个等价的系统 M^{-1} A p = M^{-1} b,其中矩阵 M^{-1}A 的条件数比原矩阵 A 更好,且 M 的逆很容易计算。

  • 雅可比预条件子MA 的对角线部分,求逆非常简单。

  • 多重网格预条件子:这是图形学中求解泊松方程的标准高效方法。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_65.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_67.png

多重网格法

多重网格法之所以高效,是因为它能同时在多个尺度上消除误差。细网格上的低频误差,在粗网格上会表现为高频误差,从而能被快速平滑掉。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_69.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_71.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_73.png

基本流程(V循环):

  1. 预平滑:在细网格上用几次简单的迭代(如雅可比迭代)平滑误差。

  2. 限制:将细网格上的残差转移到更粗的网格上。

  3. 递归求解:在粗网格上求解残差方程(递归进行,直到网格足够粗)。

  4. 延拓:将粗网格上的解修正值插值回细网格。

  5. 后平滑:在细网格上再进行几次平滑迭代。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_75.png

在图形学中,通常将多重网格作为预条件子与共轭梯度法结合使用,称为 MG-PCG。这已成为求解不可压缩流体压力泊松方程的标准配置,能保证迭代次数与网格大小基本无关。

进阶话题与总结

本节课我们一起学习了欧拉法流体模拟的核心流程:平流投影

  • 平流:关键在于选择数值耗散小的格式,如 MacCormack/BFECC,以保持流体的细节和能量。

  • 投影:关键在于高效、准确地求解泊松方程,通常使用 MG-PCG 方法,以保证速度场无散,从而保持流体体积。

在实际组合这些步骤时,顺序和方式也会影响效果。例如,标准的“平流-投影”步骤会损失部分涡量能量。高级方法如 涡量约束投影分裂 可以在一定程度上恢复这些能量,产生更逼真、更少数值粘性的流动效果。

对于希望深入学习的同学,可以探索以下方向:

  1. 实现三维模拟。

  2. 处理更复杂的边界条件(如嵌入式边界)。

  3. 流固耦合。

  4. 两相流模拟(同时模拟水与空气)。

  5. 水平集方法捕捉动态流体界面。

  6. 涡量法(基于涡量的公式,能更好地守恒涡量)。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_77.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/c6d91a5052cb15af085edc0958df24f9_78.png

欧拉法流体模拟是生成烟雾、火焰、水流等视觉特效的强大工具。虽然本课内容密集,但通过动手实践、阅读推荐资料和参考示例代码,你将能够逐步掌握这项技术,并创造出令人惊艳的效果。

GAMES201-高级物理引擎实战指南2020—P5-Lecture-5-多体问题与涡方法—GAMES-Webinar—BV1ZK411H7Hc_note

在本节课中,我们将要学习如何高效求解多体问题,特别是泊松方程及其快速解法。我们将从基础的直接求和问题出发,逐步深入到被誉为20世纪十大重要算法之一的快速多极展开方法,并探讨其在图形学、天体物理、分子动力学等领域的广泛应用。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_1.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_3.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_5.png


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_7.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_9.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_11.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_13.png

课程介绍与背景

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_15.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_17.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_19.png

欢迎各位同学。今天我们请来了重量级嘉宾张欣欣师兄。张毅师兄是UBC的计算机图形学博士,他热爱物理、计算与数学,致力于解决其中有挑战性的问题,目标是在计算机上再现大自然的美。这也是我们开设这门课程的初衷。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_21.png

关于课程难度,曾有疑问是否会让同学觉得拔苗助长。但兴趣是最好的老师,在兴趣驱使下,挑战并非问题。我们会尽力以通俗易懂的方式讲解。同时,希望大家能多参与直播,以便及时互动和反馈。

接下来,我们把时间交给星星师兄,他将为我们讲解泊松方程与其快速解法。

好的,谢谢胡世纪的介绍。我希望今天的课尽量做到通俗易懂。能够留存到今天的同学应该都很不容易。我们现在就开始吧。今天我会讲有关泊松方程和快速解法的一些内容。


从多体问题到泊松方程

我们从快速方法的角度来看泊松方程。之前我们讨论的解法是基于偏微分方程的角度。今天,我们考虑一个开空间的泊松问题,并通过积分形式的基础解来看待它。

许多同学在高中时就接触过这类问题,例如万有引力。万有引力场满足一个关于密度的泊松方程。

公式: ∇²φ = ρ

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_23.png

任意两点之间的受力就是势场的梯度。如果我们把这个梯度运算代入到基础解的求和系统中,就会得到熟悉的万有引力公式:力与距离的平方成反比。

公式: F = G * (m1 * m2) / r²

当我们看到这个求和表达式时,会注意到:如果一个万有引力系统中有 n 个粒子,需要对 m 个点求力,那么直接求和的方法需要 O(n*m) 的时间复杂度,其计算量很大。因此,我们需要快速方法来降低其计算复杂度。


快速多极展开的核心思想

这类方法被称为快速多极展开,它被誉为20世纪十大重要算法之一。快速多极展开究竟利用了怎样的思想,使得人们能够在线性时间内计算这个 O(n²) 的求和问题?我们现在就来一步步分解。

首先,我们考虑一个二维泊松方程及其基础解。为了方便,我们采用复数来表达坐标。我们知道,求和表达式的实部就是我们要求的泊松方程的解。log(距离) 就是二维情况下的格林函数。

此时,我们考虑一个圆以及它在远处 z 点引发的势能。如果我们在 qi 点处做一个泰勒展开,就会发现 z 点的势能可以看作是在零点处所有电荷的势能之和加上高阶项。

核心思想: 如果我们在靠近零点的地方有很多电荷,它们对远处 z 点的作用可以通过在零点做泰勒展开来近似。这可以拆分为两部分:

  1. 一个位于零点的、带有总电荷的“大电荷”对 z 点的作用。

  2. 一个高阶项,代表了电荷分布形状的影响。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_25.png

我们将公式抽象后,会得到一个总电荷 Q(零点附近所有电荷的总和)和一系列高阶项 q_k。远处 z 点的势能可以写成 Q 乘以格林函数,再加上这些高阶项的累加。后面的高阶项是几何收敛的。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_27.png

基于这个思想,我们已经可以得到一个 O(n log n) 的算法,这被称为 树码算法。其实现方式是:将空间中的粒子进行网格划分,在每个格子的中心点计算近似的 Qq_k,使得远处格子的势场可以被这个中心点的展开式近似。然后,我们不断向上层网格聚合这些信息,最终得到一个粗糙的网格,其中存储的 Qq_k 代表了其下所有粒子的信息。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_29.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_31.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/games/img/677ea37b47e422d4caae94d7b6de0b79_33.png

当我们需要计算空间中任意一点的势场时:

  1. 对于近处的粒子,使用直接求和公式。

  2. 对于远处的粒子,使用存储在对应粗网格中心点的泰勒展开式来近似计算。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐