💡学点儿丨Ra计划(1)-原画-SD风格模型训练&模型融合
什么是Ra计划 一张角色美术工作流的体验卡，主打一个学学再说。大概会捋一下AI原画-雕刻-模型-材质-绑定-动画的全流程，是个大坑。 Part1 原画 这一趴其实是来凑数的！Ra的原画是直接Midjourney抽出来的，其精度也达不到[原画]的要求，后期一边建模一边创作。 本文是张冠李戴的另一个MJ画风模型训练，但是基本是一样的原理，我是不介意多水一篇啦。 StableDiffusionv1.5 在实习期间接触了很多AI相关的尝试，关于AI原画的知识也来源于工作的积累。 11月了才来发这篇文章属实是大迟特迟，但还是想把之前学习的一些东西记录一下。所以先行预警，这是一篇（至少）过时了4个月且七拼八凑的训练笔记，不是教程！ 不是教程！ 请希望学习模型训练的读者查找最新SDXL的相关教程。 训练原理 模型微调与DreamBooth 模型微调 Stable Diffusion 的图像生成主要涉及三个主要模块：CLIP（Text Encoder），U-Net 和 VAE。 CLIP：将 Prompt 转化成模型能够理解的向量 U-Net:图像生成的主要网络，由于结构与字母 U 相似而得名 VAE：将噪声转化成输出图像 模型微调的过程就是用少量的图像对 Text Encoder 和 U-Net 两部分做微调，使模型学会我们新加入的风格或概念。但是由于微调过程中素材量较低，可能会发生过度拟合（比如训练集中只有趴着的小狗，就无法生成站着 or 跑步的小狗）和语言偏移（随微调逐渐失去语言的句法和语义知识）。 DreamBooth 为了避免这两个问题，DreamBooth 的训练方法提出了 Prior-Preservation Loss 先验损失，通过自己先生成一些图像（也是我教程里的 class image），来保留先验损失权重。用模型自己生成的样本来监督模型，以便在 few-shot（小样本，可以理解为几张图） 微调开始后保留先验知识。 举例中，我们训练特定长相的小狗，只有四张图片作为训练集。我们称它为 sks dog，其中 sks 是一个特殊的 token 关键词，dog 为狗狗的大类 class。dreambooth 会根据 dog 这个 class 生成大量其他小狗的图像，在学习新概念 sks 的特征时保留对狗狗大类的认知。 在现在使用的各种脚本中，先验损失保留这个过程也被称为“正则化”。另外允许训练时使用自己准备的训练集，即“reg dataset”。如果需要使用正则化，正则训练集的图像就准备同类别图像即可（例如训练角色 - 同性别的单人图）。 LoRA 通过 Dreambooth 和传统的微调方法，我们会得到“大模型”一般在 4-7GB 左右，且训练的过程占用显存较高，用时也比较久。LoRA 是在 Dreambooth/ 基础微调方法的基础上产生的。 LoRA ,英文全称Low-Rank Adaptation of Large Language Models ,直译为大语言模型的低阶适应,这是微软的研究人员为了解决大语言模型微调而开发的一项技术。LoRA建议冻结预训练模型的权重并在每个Transformer块中注入可训练层(秩-分解矩阵)。因为不需要为大多数模型权重计算梯度,所以大大减少了需要训练参数的数量并且降低了GPU的内存要求。 简单来说, LoRA只训练模型中的一部分参数。训练比例由秩dim(rank)决定,dim越大,显存要求越高,生成的模型文件也越大,也可以学到更多图像特征。一般dim128的LoRA训练会生成144MB左右的LoRA模型,生成时需要搭配大模型使用。 dim 并非越大越好，通常 128 已经能够满足绝大多数训练需求。人物训练甚至只需要 32-64 即可。 U-Net与层 UNet 是图像生成的主要结构，整体形态呈 U 形。上图是 Stablediffusion 中使用的 Unet 结构。图像从左上角输入，由左向右传递，最终从右上角输出。 左侧下行会经历三次降采样，共包含 IN0-IN11 共 12 层。中间是 MID 层。右侧上行会经历三次升采样（IN3、IN6、IN9），共包含 OUT0-OUT12 共 12 层。横向粉色的连线表明特征信息的传递，IN 与 OUT 的部分层会相互关联影响。 LoRA 通过 Attention 模块干涉所有图中橙色的层。而 Lycoris 的一些结构可以干涉所有 Res 层，即深蓝色的层。 层的差别 - 上&下 在实际效果上，偏上的层决定画面上更细微的特征，例如材质、边缘等。偏下的层决定宏观的特征，例如构图、色彩等。这主要是受到降采样的影响： 在 IN0 中，图像首先被缩放到原图的 1/8，然后再 IN3/6/9 时再分别缩小 1/2。 Stablediffusion 使用 3x3 的滤波核提取特征，当图片缩小后，滤波核能够覆盖的比例就会更大，即提取更宏观的特征。 （这也是为什么我们的图像像素最好是 8 的倍数） 在相似构图下，各画面元素在图像中占比是相似的，这意味着对应提取特征的层是能够推理得到的。而图像生成的场合中，我们使用最多的是 cowboy shot，即人物腰部及以上的特写。在这样的构图内，我们可以用下面这张表格来推理各层对各个画面元素的影响程度。 层的差别 - 左&右 可以抽象理解为 IN 的模块主要控制构图，OUT 的模块主要控制风格。 训练步骤 耍赖： 由于迟了4个月，当前的插件版本肯定已经有大幅度的更新。因此关于插件/脚本使用的部分直接跳过。 训练目的 训练特定画风的模型。 在这里我们希望画的是Midjourney很擅长的Q版2.5d小人。希望在SD中还原渲染质感，且利用SD更高的可控性完成绘制。 这次实验使用的是Dreambooth，但如果有人读到这里且真的想尝试画风模型的训练，首选还是LoRA。 素材准备 收集素材集 人物： a. 真人： 【数量】 10-100 【要求】 面部信息清晰，不同角度 / 衣着 / 背景 素材集参考： 【正则素材集数量】大量(>= 素材数量，一般 100 张以上) 【正则素材集内容】写实男人 / 女人（对应性别）图像，可使用底模生成。 b.二次元： 【数量】 8-30 【要求】 大头不同角度，全身不同角度 2.画风： 【数量】~100 【内容】推荐画面内容占比 :70% 人 20% 场景 10% 动物 / 物体。实际根据具体需求而定，只有一种内容（道具 / 场景 /...）也可以训练。 处理素材集 避免水印 /UI 避免小于 512 分辨率图像 避免长图 避免同名文件（注意 jpg 和 png 也不能同名） 主体明确，避免一张图上有多个人 素材集打标 该插件用于批量生成图片同名的 txt 文件，描述图像的内容。 使用 WebUI tagger 插件： 在 Extension 中通过 URL 安装 Tagger 插件： https://github.com/picobyte/stable-diffusion-webui-wd14-tagger.git安装好后重启 WebUI。 标签处理 画风模型可跳过这一步。 使用 WebUI Dataset Tag Editor 插件 处理原则：训练角色 Lora 时，我们希望模型从数据集中学习角色的主要特征，建立特征与角色的对应关系。因此，数据集中的 tag 里不应该包含描述角色特征的 tag，例如发色、衣着等等；而描述姿势、表情的 tag 则需要保留。另外还要在 tag 的句首（权重最高）添加角色名。 素材集更名 在 lora-scripts 脚本目录下新建 train 文件夹，用于存放之后所有训练素材。 在 train 内新建一个本次训练的文件夹，例如 myLoRA 。 在 myLoRA 内新建子文件夹，用于存放之前处理的素材集图片 & 同名 txt。一次训练可以同时训练多个概念，每个概念的图片应该位于不同子文件夹内。子文件夹的命名格式为 重复次数_概念关键词 。 【人物】一般 20 张素材左右时 设置为 7 【画风】一般 50 张素材左右时 设置为 5 训练参数 打开 lora-scripts 文件夹，右键 run_gui.ps1 - 使用 powershell 运行（linux 为 run_gui.sh ），即自动在浏览器上打开 GUI，点击 LoRA 训练 - 新手，进入训练配置页面； 将底模复制到 lora-scripts/sd-models，修改底模文件名 a.写实模型使用官方 v1.5 模型 b. 二次元模型使用 NovelAI 模型 修改训练数据集路径。注意这里这里的路径是 [repeat_concept] 的上一级，在上面的例子中就是 myLora 文件夹 设置保存模型名称 如有触发词，拉至页面最下方，把 keep token 设为 1. 页面右下角点击 保存 - 直接开始训练 训练参数 训练步数 Steps - epoch - batchsize 训练步数 = epoch （重复次数 图片数 ） * 正则系数 / batch size / 梯度累加步数 理想的训练步数？ 训练步数与学习率息息相关，在 UNet 学习率 1e-4 时，一般来说图片较少的时候训练人物需要至少 1000 步，训练画风则需要至少 2500 步，训练概念则需要至少 3000 步。 学习率更大时，可以适当减少步数，但二者并不符合线性关系，使用两倍的学习率需要使用比之前步数的一半更多的步数。 Epoch 重复轮次。每轮次训练完成后可进行模型保存、示例图采样等。 重复次数（Kohya 系限定） 重复次数即训练集文件夹名的第一部分，决定此文件夹内图片的重复次数。 对于【不使用正则】【单概念】的训练，不需要特别关注重复次数。它与 epoch共同调节总步数，维持二者乘积为理想总步数即可。 【多概念】的情况：每个概念的训练集数量可能不相等， 通过各概念的重复系数可调整每组素材的训练程度。一般会使各概念的重复次数 * 图片数基本相等。 【正则化】的情况：一般正则图像集数量会远大于训练素材数，通过重复训练素材使概念的重复次数 * 概念图像数 ≥ 正则图象数，且二者尽可能接近 正则系数 Regulatization factor 使用正则时为 2，不使用时为 1. Batch size & 梯度累加 Batch size 越大梯度越稳定，也可以使用更大的学习率来加速收敛，但是占用显存也更大： 6-8GB - 保持 1； 10GB - set to 2 12GB - set to 3 注意 batch size 调大时，学习率也需要更高。 梯度累加 gradient_accumulation_steps 梯度累积步数，用于在小显存上模拟大 batch size 的效果。 如果显存足够使用 4 以上的 batch size 就没必要启用。 学习率 lr - loss - 过拟合 & 欠拟合 学习率可以理解为每一步学习的比例，总体学习的程度可以量化为loss，描述扩散生成图像与训练图像的差别。在正常的训练过程中，loss应呈整体下降趋势，局部的起伏变化是正常的。 理想的 loss 值一般为 0.05 左右，当 loss 大于 0.15，训练特征表现不充分，一般称为欠拟合；当 loss 接近于 0 时，模型出图可能会产生扭曲 / 只能生成训练集中的图像，称为过拟合。 学习率过大：如果大趋势上 loss 值不降反增，生成图像接近潜在噪声（俗称炸炉），一般诱因是学习率过大导致收敛失败。就需要调小学习率。 学习率过小：可能需要加倍的步数才能接近收敛，训练速度慢。 学习率的规划策略 lr_scheduler： constant，常量不变 constant_with_warmup 线性增加后保持常量不变 linear 线性增加线性减少 polynomial 线性增加后平滑衰减 cosine 余弦波曲线 cosine_with_restarts 余弦波硬重启，瞬间最大值（lr_scheduler_num_cycles 配置余弦退火重启次数，不应该超过 4 次） 一般使用 consine_with_restarts 或 cosine，整体学习率会呈余弦曲线下降：即早期 loss 高时，学习率高；后期 loss 低时学习率也降低，防止过拟合。 如果开启预热，预热步数应该占总步数的 5%-10%。 分别调节 Unet 于 Text-encoder 的学习率 UNet 和 TE 的学习率通常是不同的，因为学习难度不同，通常 UNet 的学习率会比 TE 高 。如果 UNet 训练不足，那么生成的图会不像，UNet 训练过度会导致面部扭曲或者产生大量色块。TE 训练不足会让出图对 Prompt 的服从度低，TE 训练过度则会生成多余的物品。 查看当前的学习率&loss Kohya系：建议使用tensorboard或wandb Dreambooth脚本：训练时GUI右侧提供及时更新的图表 优化器 常用优化器： AdamW8bit：启用的 int8 优化的 AdamW 优化器，默认选项。 Lion：loss 最终下降更低，同时占用显存更小，可能需要更大的 batch size 以保持梯度更新稳定，更慢。 D-Adaptation：FB 发表的自适应学习率的优化器，调参简单，无需手动控制学习率，但是占用显存巨大(通常需要大于 8G)。使用时设置学习率为 1 即可，同时学习率调整策略使用constant。 Prodigy：可自适应 Dylora，全程自适应训练，使用时设置学习率为 1 即可。 不同的网络结构 不同网络结构对应不同的矩阵低秩分解方法。LoRA 是老祖宗，只控制模型中的线性层和 1x1 卷积层，后续的不同网络结构都是在 LoRA 的基础上进行改进。 LyCORIS 对其进行改进，添加了其他几种算法： LoCon 加入了对卷积层 (Conv) 的控制 LoHa（哈达玛积）和 LoKr（克罗内克积） IA3 理论上来说 LyCORIS 会比 LoRA 拥有更加强的微调效果，但是也更加容易过拟合。 需要注意的是，不同的网络结构一般需要对应不同的 dim 以及学习率。 网络大小dim(rank) 网络大小应该根据实际的训练集图片数量和使用的网络结构决定 上表中值为秋叶的角色训练推荐值，训练画风和概念需要适当增加 Linear 部分大小。推荐值并非对各个不同的数据集都是最优的，需要自己实验得出最优。Conv 的大小最好不要超过 8。 DyLoRA DyLoRA 是在这篇论文中提出的 DyLoRA: Parameter Efficient Tuning of Pre-trained Models using Dynamic Search-Free Low-Rank Adaptation，根据论文，LoRA 的 rank 并不是越高越好，而是需要根据模型、数据集、任务等因素来寻找合适的 rank。 使用 DyLoRA 时，需要添加参数 unit ， unit 应当可以被 network_dim 整除。 当 network_dim =16， unit =4 时，将可以提取 4 种 dim 的 LoRA：4/8/12/16. 网络 Alpha（network_alpha） alpha 在训练期间缩放网络的权重，alpha 越小学习越慢，关系可以认为是负线性相关的。 一般设置为 dim/2 或者 dim/4。如果选择 1，则需要提高学习率或者使用 D-Adapation 优化器。 不同噪声 相较于 Midjourney，Stable Diffusion 很难生成具有强烈黑白对比的图像，在表现纯黑白纹理、强烈光影变化时表现劣势。通过噪声偏移 / 金字塔噪声等方式可以改善这个问题： noise_offset 噪声偏移 通过在图片中随机添加统一的值来快速改变图片的均值（零频率）从而使模型学习能够更快学习到图片的均值从而能够生成更亮或者更暗的图片。推荐概念 0.06，画风 0.1。 multires_noise 金字塔噪声 金字塔噪声通过反复叠加不同分辨率的噪声来增加低频的部分从而达到同样的效果。 multires_noise_iterations #多分辨率噪声扩散次数，推荐 6-10,0 禁用。 multires_noise_discount #多分辨率噪声缩放倍数，推荐 0.1-0.3, 上面关掉的话禁用。 模型调试 利用 XYZ Plot 脚本 +Additional Networks 生成网格图 XYZ Plot 是 Webui 自带的脚本工具，可以替换单一参数生成网格图，以对比不同步数 / 参数的训练模型，挑选最优训练成果。 Additional Networks https://github.com/kohya-ss/sd-webui-additional-networks.git Kohya 提供的插件，可以提升附加网络的使用体验，并提供 lora mask 功能。建议安装后在设置 -Additional Networks 添加默认 lora 模型路径： 生成对比图 Additional Network 中启用脚本 选择 XYZ 脚本，XY 轴可以分别设为 lora 模型名和权重 生成结果 模型融合 使用场合 本节主要介绍大模型的分层融合。lora 的分层融合需要使用 kohya 脚本。 通过分层，可以特异的迁移大模型的某一级特征，例如修复训练过拟合的面部、人体结构；迁移画风、材质等等。 融合原理 Checkpoint Merger WebUI 默认插件之一，可以在[Checkpoint Merger]标签页找到。该插件可以通过特定的比例(A (1 - M) + B M)混合两个大模型的权重，从而获取介乎二者之间的效果。 结合 Unet 的分层原理（详见 训练原理），使用 Merge Block Weighted 可以分层控制融合权重，提升融合过程的可控性。 使用方法 使用 extension-install from URL 安装： https://github.com/bbc-mc/sdweb-merge-block-weighted-gui 界面下方的 25 个滑条对应 25 个层的权重。为 0 时权重完全来自模型 A，为 1 时权重完全来自模型 B 设置融合参数 a.设置模型 A 与模型 B3. b.设置输出模型名 c.设置 base alpha（见下） d.设置各层权重 e.点击 Run Merge f.实验模型效果，重复 1-5，进入调参地狱 base_alpha 该数值决定使用哪个模型的 text encoder。Text Encoder 中包含了训练上的[概念]。参考下方例图“Pepe the frog on beach”，其中模型 B 上训练了佩佩蛙的概念。 无论 Unet 来自哪个模型，当使用模型 B 的 Textencoder 时，就能画出蛙蛙的元素。 建议使用包含了训练概念的模型的 text encoder。数值建议保持 0(完全使用 A 模型)或 1(完全使用 B 模型)，防止出现中间状态的融合。
阶段性成果： Todo： Precomputed Light Refraction SSR Sclera Wrap Redness Shading 置入渲染素材 在先前的渲染框架上，我们将眼球与睫毛的模型导入，分配Shader，并于MASS的PBR pass时绘制。我为皮肤以外的元素创建了一组新的帧缓冲（temp_other_ms&temp_other），以便在SSSS的最后与后处理后的皮肤组合。 有个别实现细节在这里记录一下： 眼球渲染 素材包中分别提供了虹膜（iris）和巩膜（sclera）两张贴图。在引入眼部折射前，我们先暂时利用一个mask来将二者组合。 睫毛渲染 睫毛的原始obj文件中分了三层睫毛，使用时在maya中进行了一次排序后重新导出，可以暂时避免半透明排序的问题。不过这个素材的贴图清晰度不够高，非特写视角就会糊成一团....想实现暴雪的效果可能需要改成分组插片的毛发面片（后续暂时换成扭曲毛流了（?） 环境光反射 之前的单光源渲染遇到高反射率的表面就显得过于单薄了，环境光反射可以为眼部渲染的真实性带来巨大的提升！ 对比看看： 为模拟眼皮和睫毛对于反射的遮挡，引入一个Occlusion Mask来控制高光的范围： 眼角湿度 眼部的水分填充了眼球和眼眶模型的间隙，通过摄像观察，巩膜上水分的不均匀分布会影响高光的边缘形状，且在眼眶交接处由于堆积而形成线形高光。 首先是巩膜的反射，暴雪引入了一张法线噪波来控制表面的微小起浮。 然后是眼眶堆积的水分，暴雪建立了三个状态的模型，使用BlendShape来控制中间状态。 应用效果如图： 基于物理的视线折射 让我们回到眼球的结构，可以发现，眼球在虹膜和角膜中间存在着一个“前房”的腔体，内部充满了房水，当视线穿过前房时就会发生折射。我们可以使用视差贴图来实现这种效果，但基于物理的折射的效果会更好一些。 基于物理的折射的前提条件是眼球的模型相对准确，前房的区域会沿眼轴突出。或者也可以引入一张贴图来记录前房的突出程度height。 折射向量可以使用Real-Time Rendering book (Section 9.5. Refractions)的公式计算： float w = ior * dot( normalW, viewW ); float k = sqrt( 1.0 + ( w - ior ) * ( w + ior ) ); float3 refractedW = ( w - k ) * normalW - ior * viewW; 应用效果如下： 角膜缘环 边缘环是虹膜结构的渐变区域，可以通过S形曲线插值虹膜和巩膜来获取这个柔和的渐变边缘（巧了 本能就用的smoothstep）。 从正面观察时，真人眼部的边缘环颜色会比虹膜更深，但这一特性从侧面观察就变得没那么明显。显然我们现在的模型还没有这样的光照效果，这是因为在上一步中我们只模拟了光线从虹膜->房水->相机的这部分折射，光线入射时的折射还没有模拟出来。
上学期收到作业附件时，关于这个脑袋与SSSS的资源只有区区6页的一个slide，obj与diffuse，normal，specular三张贴图。 之前动视暴雪中皮肤的部分已经基本实现完了，但眼睛的渲染优化 GDC2013 Next Generation Character Rendering 溯源从之前暴雪GDC渲染的Animation部分开始，讲座介绍模型及表情的数据来自Graphics Lab of the Institute of Creative Technologies of USC，使用Lightstage X扫描。 在ICT USC的网站中详细介绍了近年来的工作，在2014年其发表了与视频驱动的表情捕捉相关的论文，其题图正是我们要找的这位仁兄！ 文末的相关项目为我们指明了前路：Digital Ira，发布于2013年，是专为实时渲染而扫描制作的人脸数据集。 （顺便吐槽一下ICT网站，大概是前些年换过域名，所有的站内超链接通通失效,,得单独去谷歌题目才行) Digital Emily 但Ira其实并非ICT扫描的第一个数字人，在2008年，ICT扫描了Emily O'Brien，并开展了Digital Emily项目。 扫描使用了15张不同光照的照片以计算面部的几何形状和反射率。通过使用不同的光源角度和偏振器，我们能够得到diffuse+specular的图像以及滤除高光的纯diffuse图像，从而观察面部的高光反射。不过这里的'specular'图像不是完全黑白的，这是因为部分光线反射前已经经历过一次散射，改变了色光的比例。 高光图是法线计算的重要依据，通过视线向量和光线向量可以反推出面部法线。 刚刚提到不同色光的分布在高光中分布并不一致，不同色光分量得到的法线图也是不一样的：红光散射的最远，因此法线细节会柔和许多。 好消息是，Digital Emily的模型、贴图是开源的，在Wikihuman Project下可以获取Emily的完整Maya场景与Shader文件。 （题外话——写完才想起来，这个项目之前动画原理课讲人脸动画时就讲过哇！） Hi Ira Ari Shapiro是该实验室的图形研究员，Digital Ira项目旨在创建一个实时的、照片真实的数字人物，数据集也分享给了Nvidia和动视暴雪，以进行技术演示（That's why we found him!) 。 Digital Ira与GDC2014的Digital ira and beyond可以获取更多关于Ira模型生成、表情系统构建的信息，但ICT并没有对Ira的模型及纹理信息进行公开:(((( 不过这篇文章详细介绍了Nvidia的Ira和动视的Ira，荐读一下👈 于是我们只好把目标转向拿到了数据的Nvidia和动视暴雪。Nvidia于2013年发布了这个面部渲染的Demo，可以在这里下载。 悲报是，作为一个成熟的demo，它的所有资源打包成了apk.......而我并不会解包...... Ira会面之旅就此卡住。我试了好多不同的关键词组合都没有找到新的内容——直到把Ira踢出了搜索框，转而搜索上述demo的项目名FaceWorks，终于找到了这个demo的Github开源版本😭😭😭😭 其模型等资源位于Faceworks/samples/media/DigitalIra.贴图的命名与作业附件的材料都是一致的，说不定之前助教也是从这里存下来的嘞💫 很高兴为Ira找回了眼睛和一丢丢毛发（以及maya的反光shader）！Nvidia的demo版本中并没有使用动视暴雪提到的眼周高光与虹膜渲染的优化，所以没有提供Jorge等人使用的泪光闪闪mesh&睫毛遮罩贴图等。下一篇我们就尝试复现一下暴雪眼部渲染的优化流程！期待！
🕹️OpenGL丨实时皮肤渲染效果优化 - 皮肤渲染(5)
前情提要： 🕹️OpenGL丨直接光照PBR - 皮肤渲染(1) 🕹️OpenGL丨延迟渲染&帧缓冲 - 皮肤渲染(2) 🕹️OpenGL丨ShadowDepthMap实现SSS透射 - 皮肤渲染(3) 🕹️OpenGL丨可分离的次表面散射SSSS - 皮肤渲染(4) 本文主要内容为GDC2013 Next-Generation Character Rendering 的ppt笔记整理总结。如果有空的话还会再研究一下UE5中的具体实现。 令人惊叹竟然已经马上十年过去了..... 第一部分我们主要关注皮肤渲染的细节实现。 皮肤反射 - 双镜叶高光与压表面结构 Gramham(2013)论文通过测量提出皮肤的高光具有两个独立的高光波瓣，Jorge等通过使用不同的粗糙度计算两个高光项来模拟这样的视觉效果，然后以85%,15%的方式线性插值两个高光。 分享中建议第二个高光波瓣的gloss值可以使用2倍的原gloss值，或将specular的指数乘方。 在作业项目中，以0.85插值得到的双镜叶高光效果如下： 这样我们其实已经得到了不错的表面压结构，但皮肤的微小褶皱会打破高光的形状，Jorge等使用了一张类似噪波的法线纹理来实现这个效果： 距离稍微离远一点的效果是这样的：成片的毛孔结构变成了更细碎的高光。 这里使用到的噪波纹理是由多个正弦波叠合而成的。 注：这里的rand()并非每像素随机，而应当是一个提前确定的随机数表。 在作业项目中，使用SD制作了这张噪波纹理。理论上其实应该用FX-map来做循环，但一直传循环数失败（...)，所以改用pixel processor写了一个正弦波生成器，再用Tile Generator去实现随机位置和随机大小来模仿上述的公式。 ——效果肯定是不如原文的，一方面pixel processor生成的是一定区域内的正弦波，虽然给了比较大的缩放值，但必然有些部分没有完全覆盖；另一方面缩放损失了一定图像质量。最终生成的噪波纹理只剩下了颗粒感，没有像原文中那么多细线条的纹理效果。 如果用FX-map理论上是可以的—— 先用Quadrant将纹理分到1024*1024(因为FX-map不是逐像素的而是逐细分(?)的，$Pos是细分的中心位置)，然后用iteration处理循环，并将当前循环次数传给计算的Quandant用作于Global Random的seed，剩余绘制跟下面贴的pixel processor差不多~ 获得噪波纹理后，我们要将这些细节与原法线混合，Jorge等推荐使用的混合方法是：Reoriented Normal Mapping (RNM)，详细解释可以参考这里。 （但但但....我用RNM直接套公式算出来不太对.......） （太困了，以后有机会再改，这里直接用的线性混合） 这些表面的亚结构也会作用于高光上，计算方法如下： 为了增加美术对该模型的控制，可以对base glossiness和细节变化结合得到： \frac{1}{s'}=\frac{1}{s}+\delta ^2 注意这里的base glossiness应当已经包含了normal map中的表面变换。 总结一下！在本工作流中，gloss map应该只包含低频度的细节信息，比如T区的划分等。 中频度的细节信息储存于normal map中，高频度的细节信息存储于detail tiled noise normal map中。 这两种normal的表面变化都会反应到粗糙度的最终结果上。 （but这个我作业项目里没写） 关于数值的具体信息，Jorge给出了一个参考表格，但还是需要美术根据具体的灯光环境进行微调。 Second Lobe对应的数值应为 2.0*G 或者 S^2.0。 皮肤毛孔 人类皮肤的毛孔是一个非常粗糙复杂的孔状结构，当它被简化至模型与法线表示时，它就变成了一个“盆地”，which means毛孔的底部会产生不正确的反光。 一张曲率贴图即可屏蔽正面的毛孔反光，但这张表现毛孔的specular occlusion会极大程度地影响脸侧面的高光：将cavity乘入Fresnel项可以解决这个问题。 Reference NEXT GENERATION CHARACTER RENDERING
前文提要—— 🕹️OpenGL丨RubikCube类搭建 - 魔方作业(1) 🕹️OpenGL丨Object Mouse Trackball - 魔方作业(2) 🕹️OpenGL丨利用模板缓冲实现鼠标拾取 - 魔方作业(3) 🕹️OpenGL丨魔方转动的动画与执行 - 魔方作业(4) 快交作业了，来最后把魔方作业完善一下，修了几个bug，然后再贴一下完整版的文档。 👇👇👇👇👇👇👇 要是播放不了戳这里 ☝☝☝☝☝☝☝ 交互相关 stencil残影 之前debug模板缓冲时，发现旋转会留下很多残影： 这是因为在glClear()命令前没有将glStencilMask()设置为0xFF，所以清空命令没有起效。 更正后的效果如下~ trackball转动过程中突然消失 在半边结构的作业中加入了trackball类的时候，一定程度上解决了这个问题。 为啥说一定程度呢...一方面不知道为啥，另一方面现在鼠标操作时偶尔会出现黑屏的闪烁..... 主要就是额外引入一个4x4矩阵来保存之前的旋转。在mouseClick()时拷贝matrix，用于计算local旋转轴和计算本次操作的的旋转矩阵。 void TrackBall::mouseMove(glm::vec2 pos2D) { glm::vec3 curpos = glm::vec3(0.0f); mapToSphere(pos2D, curpos); glm::vec3 axis_w = glm::cross(lastpos, curpos); glm::vec3 axis_l = glm::vec3(glm::inverse(lastmat) * glm::vec4(axis_w, 1)); float angle = glm::length(lastpos - curpos); matrix = glm::rotate(lastmat, angle, axis_l); } 歪门邪道网点shading 由于一个多月之前的我懒得读网格文件，这个魔方立方体的VAO是直接手写的，就没写法线的信息（而且直到刚刚才发现哦我没用indices...所以其实VAO里加一下就行.....）。最终是直接在着色器里歪门邪道地算了个world normal: vec3 N = step(vec3(0.499999),faceindex); N -= step(faceindex,vec3(-0.499999)); N = normalize(N); N = (modelmat*vec4(N,0.0)).xyz; emmm..... 没有任何参考价值。 Shaing根据光照和光线衰减大概分为三个区域，希望在中段的灰区绘制网点的效果： 一通调参之后就可以得到这样有点漫画感的效果~ 最终处理效果如图: 完整文档 实验原理 矩阵变换 三维模型的缩放、平移、旋转等操作都可以通过矩阵进行运算。 在本次实验中，使用了GLM库进行矩阵的管理和实现。 将物体绘制到屏幕上时，需要通过多次的坐标转换，一般的变换过程是[局部坐标]->[世界坐标]->[观察坐标]->[裁剪坐标]->[屏幕坐标]。 在魔方实验中，每个小立方体都是一组单独的网格，n x n x n个小立方体组成整个魔方。如果将整个魔方看为整体，那么我们还需要额外的一步坐标转换，将小立方体转换到"魔方空间"。 CUBE MATRIX保存每个小立方体各自的变形，包括其相对位置和相对旋转，也包括层旋转时的动画效果。 MODEL MATRIX保存整体魔方的旋转。 Trackball 使用TrackBall来实现鼠标对于物体的自由旋转，我们在屏幕外虚构一个球形曲面，使鼠标在二维屏幕上的移动投影到球形曲面上，以实现更佳的用户体验。 The motivation behind the trackball (aka arcball) is to provide an intuitive user interface for complex 3D object rotation via a simple, virtual sphere - the screen space analogy to the familiar input device bearing the same name. The sphere is a good choice for a virtual trackball because it makes a good enclosure for most any object; and its surface is smooth and continuous, which is important in the generation of smooth rotations in response to smooth mouse movements. Any smooth, continuous shape, however, could be used, so long as points on its surface can be generated in a consistent way. 假设我们的虚拟球面为x^2+y^2+z^2=1 ，即以屏幕中心为球心，半径为1的球。将屏幕坐标映射到(-1,1)，由于映射点具有相同的x,y坐标，我们通过球面方程得到映射后的点坐标；若点在球面外，我们暂定映射到球面边缘的点。 当鼠标从P1移动到P2时，将P1,P2映射到球面上的p_1,p_2，叉乘\vec{op_1}与\vec{op_2}即可得到旋转轴；\vec{op_1}与\vec{op_2}的夹角即为旋转角。 只使用半球面作为映射面时，球面边缘处的突变较大，操作感不太自然。因此我们可以使用一个喇叭形的双曲面（x^2+y^2=r^2/2）与球面拼合，以得到更加平滑的拟合效果。 基于模板缓冲的拾取 旋转魔方的层时，我们需要获知鼠标点击到的具体是哪一个小方块。 作业资料中提供的拾取方法，是基于固定管线GLUT环境下的Select Mode，通过glSelectBuffer获得集中列表。但由于作业使用的可编程管线，没有已包装好的拾取方法。因此借助帧缓冲来实现鼠标的拾取操作。帧缓冲的蒙版缓存附件和颜色缓冲附件都可以实现物件ID的记录，区别是模板缓冲的格式一般为Depth24Stencil8，只有8位空间，最多只能保存255个不同ID；而颜色缓冲一般有32位空间，适用于更多场景物体。 在本实验中，最多有6×6×6=216个子物体（内部方块不会被选择，表面子物体最多152个需要有不同ID），使用默认帧缓冲的模板缓存就绰绰有余了。 由于模板缓冲是一种帧缓冲的渲染缓冲对象附件（Renderbuffer Object as Attachment），数据存储于GPU，需要使用glReadPixels()来读取物件ID数据回传到CPU。 由于渲染缓冲不是纹理，我们很难将它可视化出来以debug。在本实验中的debug是通过多次不同模板值的模板测试绘制全屏幕四边形，通过模板测试的区域即可被绘制。绘制的颜色反映物件ID转换得到的index值。 实验步骤 1.构建魔方类 魔方类的构建主要参考作业附件中的参考框架。N阶的RubikCube中应当包含N×N×N个cube对象和N×3个layer对象。 cube储存自身index序号，与相对变换的矩阵。 layer储存层内的cube序号，与旋转轴。 RubikCube储存cube表与layer表，其中layer表的第0，1，2行分别对应不同轴。 2.构建TrackBall类 TrackBall类将输入的x,y坐标映射到球面，计算旋转轴与旋转角并以矩阵形式保存。 核心方法如下： 基础功能： void mapToSphere(const glm::vec2 pos2D, glm::vec3& pos3D) 输入：重映射到(-1,1)的屏幕坐标 输出：三维球面坐标 功能：计算球面坐标 void mouseClick(glm::vec2 pos2D) 输入：重映射到(-1,1)的屏幕坐标 功能：鼠标按下时由回调函数调用。计算初始的鼠标球面位置，保存至lastpos。 void mouseMove(glm::vec2 pos2D) 输入：重映射到(-1,1)的屏幕坐标 功能：鼠标移动时由回调函数调用。计算当前的鼠标球面位置，计算旋转轴与旋转角，将结果写入变换矩阵matrix。 拓展功能： Trackball可实现自由连续的球面角转动，但当右键旋转单层时，我们需要限制它仅沿自己的轴转动，为此设计了如下方法： int calAxis(glm::vec2 _curPos, glm::mat4 global, bool& locked); 输入：重映射到(-1,1)的屏幕坐标，整体魔方的变换矩阵，旋转轴是否已锁定 输出：轴的index（0-x,1-y,2-z) 功能：由于每一个方块同时位于不同方向的3个layer上，此方法计算当前转动操作的主要方向。由于鼠标刚刚按下时移动距离较小，不能完全确定本次操作的方向，因此设计了角度相关的阈值，当旋转角大于该阈值时，旋转轴锁定。 void restrictedMove(glm::vec2 pos2D, glm::vec3 axis, glm::mat4 global) 输入：重映射到(-1,1)的屏幕坐标，旋转轴，整体魔方的变换矩阵 功能：与void mouseMove(glm::vec2 pos2D)功能相似，但是限制特定旋转轴。 void calSteps(int speed, int& steps, int& totalMoves) 输入：单次旋转角步长 输出：剩余步数、总旋转次数（以旋转90°为一次） 功能：用于层动画。当用户右键松开时，可能层没有旋转到整90度的位置。本方法根据当前旋转的角度计算用于动画的旋转步数以及此次旋转总共旋转的次数（以旋转90°为一次）。 3.模板缓冲拾取 为利用模板缓冲储存cube ID，首先构建了工具函数以实现3维index与1维ID的相互转换： unsigned int indexToStencil(int i, int j, int z) 输入：cube的x/y/z方向索引 输出：cube ID bool stencilToIndex(unsigned int value, glm::ivec3& result); 输入：cube ID 输出：是否查找到cube，若有则写入cube的x/y/z方向索引 使用glEnable()启用模板测试，并配置glStencilOp(),glStencilMask(),glStencilFunc(). 正常绘制模式下，所有魔方的片元都可以通过模板测试（GL_ALWAYS），其中亦通过了深度测试的部分片元会写入模板值。这保证了使用鼠标点击时，采样得到的模板值总是来自距离最近的物体。 当触发点击事件时，通过glReadPixel()可读取输入坐标的模板值。 //reset picked object double keyx,keyy; glfwGetCursorPos(window, &keyx, &keyy); uint stencil; glReadPixels(keyx*COORDSCALE, (SCR_HEIGHT-keyy)*COORDSCALE, 1, 1, GL_STENCIL_INDEX, GL_UNSIGNED_INT, &stencil); if(!stencilToIndex(stencil, myCube.pickedCube)) return; 由于OpenGL的纹理采样零点在左下角，但窗口坐标的零点在左上角，注意采样时需要将y值反转（SCR_HEIGHT-keyy）。 4.鼠标监听 使用glfwSetInputMode()开启对鼠标时间的监听，并设置鼠标点击、移动的回调函数。 交互要求左键对整个魔方进行旋转，右键对单一层进行旋转。使用两个bool值记录左键和右键的按下状态，以便鼠标移动时计算对应的行为。 void click_callback(GLFWwindow* window, int button, int action, int mods) 鼠标点击的回调函数，负责设置bool值状态，并记录当前鼠标位置。 void mouse_callback(GLFWwindow* window, double xpos, double ypos) 鼠标移动的回调函数，根据bool值状态采取调用左键与右键不同的旋转函数。 5.交互逻辑 为RubikCube增加两个TrackBall对象，分别管理整体和单层的旋转操作。 对于global trackball而言，它一直保存着整体变换的一个矩阵，并在绘制阶段设置到顶点着色器，相当于一个local到world的转换。 而temp trackball在每一次右键操作中都会重置矩阵，它保存的矩阵会在右键松开前，通过RubikCube类的activeLayer()方法设置给被选中的layer的每一个小方块的变换matrix，再在绘制阶段设置到顶点着色器。 程序的运行逻辑如下： 6.层动画实现 主要由如下方法实现： RubikCube::startAnimation() 当右键松开时，读取temp trackball总共旋转的角度，以90度为单位进行取整计算，分为rotateIteration和rotateCountor两部分储存。rotateCounter是多少个整90度，rotateIteration是距离旋转至整90度还有多少个step步长。 RubikCube::rotateOnestep() Iteration--，每次旋转一个step步长。 RubikCube::finishAnimation() 动画运算完毕后，对rubikcube的结构进行更改，主要做了两个工作： 复制layer所索引的n*n个Cube，将整90度的旋转写入到cube.pos 修改magiccube的Cube[][][]数组 7.风格化漫画网点Shading 开头写了。略。 &.过程中遇到的一些问题&解决： 开头写了。略略略。 实验效果 详见demo.mp4. 左键旋转整体魔方 右键旋转单层 中键滚动缩放魔方 中键按下切换不同主题 键盘数字键（2-6）切换魔方阶数 键盘←键→键旋转光照 实验环境 Window 10. Visual Studio 2022. 依赖库：GLFW，GLAD, GLM，stb_image shader.h参考learnOpengl框架。
本应是Mesh系列的第一篇~为了赶动画原理拖了几天。 在上一篇中，我们用半边结构读取了网格，但实际上还没真正用到半边。 今天就是半边结构大显身手(?)的时候了！它可以快速找到每个顶点的所有邻居节点，以计算顶点的法线。 （入坑两年了，怎么还有人在写写了800次的基础卡渲.......) B类作业④ 实验报告 实验原理 Shading 使用半边结构读入obj格式的三维网格模型，通过叉乘计算顶点法线，使用Blinn-Phone模型进行简单的渲染。 Interaction 模型交互使用了A类作业①魔方程序中构建的TrackBall类，将鼠标在窗口内的位置移动映射到球面，计算球面角对模型进行旋转。 实验步骤 1.使用半边结构读入网格 详见B类作业③实验报告。 2.计算顶点法线（见Vertex::computeNormal()） 通过叉乘三角面的两个半边，我们可以获得三角面的法线。但顶点不止存在于一个三角面上，因此需要遍历所有其所在的三角面，对所有法线求平均值。 顶点不存储面相关的信息，因此遍历所有定点所在三角面是通过获取 所有顶点的出半边 实现的。通过outEdge->twin->next即可访问下一条出半边。 计算得到的法线可视化如图： 碎碎念：半边结构其实改了特别特别久。最开始没有成对创建半边，也没想起用哈希表，就分类讨论判断是否加新边之类的....改到头秃，最后索引关系还不对，算出来的法线： 像我的精神状态一样错乱...... 3.渲染 将灯光位置与相机位置传入着色器，通过与Position做差可得到视线向量viewDir与光线向量lightDir，取平均得到半程向量H = normalize(viewDir+lightDir).点乘法线即可进行简单的光照计算。 4.鼠标交互-旋转 引入了TrackBall类。 当鼠标在窗口按下时，通过回调函数click_callback将鼠标位置传入TrackBall，并用布尔值leftkey记录按键状态。 当鼠标在窗口移动时，若leftkey为真，通过回调函数mouse_callback将鼠标位置传入TrackBall，计算旋转角，并生成旋转矩阵，传递给vertex shader。 实现细节： 通过glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_NORMAL);开启鼠标监听 使用glfwGetCursorPos()获得鼠标的屏幕坐标，并重映射至[-1,1] TrackBall中用mat4 lasttrans记录先前的历史旋转。球面角计算得到的旋转轴是世界空间的，需要通过lasttrans的逆矩阵将旋转轴转换至物件空间。 5.鼠标交互-缩放 缩放的实现主要通过更改透视投影矩阵的视锥角实现。 当滚轮在窗口改变时，通过回调函数scroll_callback将滚轮增减作用于视锥角。 6.渲染补充 补充了简单的程序化背景和简单的卡通渲染效果。 在Blinn-Phone的光照基础上，对漫反射、高光的点乘计算值进行step()/smoothstep()即可获取清晰的光照边界。也可以添加ramp map以映射不同阶数的光照表现。 背景由一个的四边形面片构成，在片元着色器中计算颜色。 渐变：用TexCoords.y插值两种颜色。 晶格化渐变：以TexCoords.y为半径的sdf画圆。 将顶点朝法线方向偏移，即可获得描边效果。为刻画圈圈的网格结构，这里描边只绘制网格的背面，且绘制后不清理深度缓冲，这样在网格转折明显的部位仍可以出现描边效果。对比如下图： （Yeah 我知道还有让描边宽窄不变的trick🤪🤪但我懒得写了。这个case里摄像机根本就不能动。） 渲染时发现，附件提供的mesh模型缺少了一个三角面： （还以为法线又算错了 生气🤪 为了视觉效果，在obj文件中添加了这个面。因此实际提交的`eight.uniform.obj`比附件提供的文件要多一行~ 实验效果 通过鼠标左键旋转模型。 通过鼠标滚轮缩放模型。 按键盘1/2/3切换显示效果。 实验环境 Window 10. Visual Studio 2022. 依赖库：GLFW，GLAD, GLM
今天压ddl写完动画原理与算法的小作业，也是关于三维网格的题目。那么就先发出Mesh系列的第二篇—— （简化版）网格变形实操！ 问题描述 拉普拉斯网格变形的基本思路是,对网格进行变形时(让控制点(anchor)在变形后处于目标位置)，保持网格内在结构(保证顶点的相对坐标发生的变化尽可能小) 顶点的相对坐标的定义: \delta_i = \mathscr{L}\left(\mathbf{v}_{i}\right)=\mathbf{v}_{i}-\frac{1}{d_{i}} \sum_{j \in \mathbb{N}_{i}} \mathbf{v}_{j} \ , \quad i=1, \ldots,n 其中 \mathbf{v_i} 是顶点 i 的绝对坐标，\delta_i 是相对坐标，d_i 是顶点的度数，\mathbb{N}_i 是其邻域。 绝对坐标V与相对坐标\Delta之间的转换: \Delta = (I - D^{-1}A)V = LV 其中 A 是 0-1 邻接矩阵，D 是度数矩阵, L = (I - D^{-1}A) 是谱图论中的拉普拉斯矩阵 预备知识补充时间： 微分坐标系 Differential Coordinates 题目中提到的“相对坐标”即微分坐标，定义为“该点的全局坐标减去所有邻居顶点全局坐标的平均值”。 全局坐标和微分坐标之间的变换是线性的，因此可以表示为上文中的矩阵形式： \Delta = (I - D^{-1}A)V = LV 其中V表示全局坐标，\Delta表示微分坐标，A表示邻接矩阵，D表示度数矩阵，二者可表示如下： A_{ij}=\begin{cases} 1&&i\in N(i)\\ 0&&\text{otherwise} \end{cases} \\ D_{ij}=\begin{cases} d_j&&i=j\\ 0&&\text{otherwise} \end{cases} L是图论中 拉普拉斯矩阵 的 RW规范化形式 . (...) 拉普拉斯矩阵 Laplacian Matrix 拉普拉斯矩阵，也称调和矩阵。主要应用在图论中，作为一个图的矩阵表示。 它具有很多形式，最普通的拉普拉斯矩阵定义为L=D-A。 常用的还有“对称规范化拉普拉斯矩阵 (Symmetric normalized Laplacian）”,L^{sym} L^{sym}=D^{-\frac{1}{2}}LD^{-\frac{1}{2}}=I-D^{-\frac{1}{2}}AD^{-\frac{1}{2}}\\ \\ L^{sym}_{i,j} =\begin{cases} 1&&\text{if }i=j\text{ and }deg(v_i)\neq0\\ -\frac{1}{\sqrt{deg(v_i)deg(v_j)}}&&\text{if }i\neq j\text{ and }v_i\text{ is adjacent to }v_j\\ 0&&\text{otherwise.} \end{cases} 本文中用到的是“RW规范化拉普拉斯矩阵 (Random walk normalized Laplacian）”,L^{rw} L^{rw}=D^{-1}L=I-D^{-1}A\\ \\ L^{rw}_{i,j} =\begin{cases} 1&&\text{if }i=j\text{ and }deg(v_i)\neq0\\ -\frac{1}{deg(v_i)}&&\text{if }i\neq j\text{ and }v_i\text{ is adjacent to }v_j\\ 0&&\text{otherwise.} \end{cases} （打latex已经很熟练了....欣慰，感觉自己勉强达到了工科大学生大一水平） 微分坐标用于变形 微分坐标是一种内在的表示(Intrinsic surface representation),可以使得变形过程仍然保持局部细节，求解速度也很快。求解时满足两个条件： Detail constraints： Lx = \delta Modeling constraints: x_j = c_j, j\in {j_1,j_2,...,j_k} 具体矩阵的求解过程太多数学了不是特别理解..大体上我们需要在L$下方扩展k行（k为改变的锚点数），锚点所在点为1. 不过求解的框架老师已经给搭好了，我们就边读边理解吧~！ 求解算法 输入 变形前绝对坐标 V 相对坐标 \Delta 控制点集合 P_\text{anchors} 输出 变形后绝对坐标 V' 目标 保持 V’ 相对坐标与输入相对坐标 \Delta 尽可能一致 \Delta - \mathscr{L}(V') \rightarrow 0 要让控制点在变形后处于目标位置 \mathbf{v}_{i}^{\prime}-\mathbf{u}_{i}=0 \ , \quad i \in P_\text{anchors} 实现 提示1: 采用最小二乘法求解； 提示2: 采用稀疏矩阵表示提高效率。 Code 在Jupyter Notebook环境下，作业使用Trimesh处理三维网格，用scipy.sparse处理稀疏矩阵。 # 导入必要的库 from trimesh import Scene from mesh import TriMesh import numpy as np import io from trimesh.viewer.notebook import scene_to_notebook from scipy.sparse import coo_matrix from scipy.sparse.linalg import lsqr import matplotlib.pyplot as plt get_LeastSquareMatrix()函数需要求的就是上图中左侧的那一大坨矩阵，也是本次小作业中要实现的部分： # 目的: 定义目标方程的左手边矩阵(稀疏表示) def get_LeastSquareMatrix(adjacent_mat, anchors_indices): # 输入: 三角网格的邻接矩阵表示, 控制点的索引 n = len(adjacent_mat.keys()) # 节点数 k = len(anchors_indices) # 控制点数 I = [] # 稀疏矩阵的非零行 J = [] # 稀疏矩阵的非零列 V = [] # 稀疏矩阵的非零值 # 任务一: 构建拉普拉斯稀疏矩阵 for i in range(n): indices = adjacent_mat[i] z = len(indices) if(z!=0): I.append(i) J.append(i) V.append(1) for j in range(z): I.append(i) J.append(indices[j]) V.append(-1.0/z) # 任务二: 行向下扩增K行 -- 控制点的约束默认最小二乘权重系数为1 for i in range(k): anchor_idx = anchors_indices[i] I.append(n+i) J.append(anchor_idx) V.append(1) #输出: ((n+k) x n 的稀疏矩阵, n是连接节点数, k是控制点数量 L = coo_matrix((V, (I, J)), shape=(n + k, n)).tocsr() return L 使用上述的左侧矩阵构成方程，用最小二乘求解。 # 目的: 最小二乘求解求解得到变形后的节点位置 # 该函数不用修改 def solve_Laplacian(adjacent_mat, vertices_pos, anchors_indices, anchors_pos): n = len(adjacent_mat.keys()) # 节点数 k = len(anchors_indices) # 控制点数 V_prime = np.zeros((n,3)) L = get_LeastSquareMatrix(adjacent_mat,anchors_indices) # 最小二乘矩阵 delta = np.array(L.dot(vertices_pos)) # 求解微分坐标 for i in range(k): delta[n + i, :] = anchors_pos[i, :] for i in range(3): V_prime[:, i] = lsqr(L, delta[:, i])[0] # 求解最小二乘 return V_prime # 输出: 变形后的节点位置坐标 中间设置网格的代码省略一部分~实际求解的运用如下： mesh = TriMesh("./meshes/bunny.ply") # 导入三角网格文件 ori_mesh = mesh.get_mesh_copy() scene_to_notebook(Scene([ori_mesh])) ... adjacent_mat = mesh.get_neighbors() # 三角网格的邻接矩阵 vertices_pos = mesh.get_vertices() # 三角网格的节点坐标 ############### 求解 Laplacian ############### V_prime = solve_Laplacian(adjacent_mat,vertices_pos, anchors_idx, anchors_pos) new_mesh = mesh.update_vertices(V_prime,inplace=True) scene_to_notebook(Scene([new_mesh])) 参考文献 O. Sorkine, D. Cohen-Or, Y. Lipman, M. Alexa, C. Rössl, H.-P. Seidel. Laplacian surface editing. SGP '04: Proceedings of the 2004 Eurographics/ACM SIGGRAPH symposium on Geometry processing. July 2004, Pages 175–184.
定睛一看，已经12月了😵‍💫。 本来计划图形学基础总共写5个A类大作业，结果发现期末根本应付不过来。 看看剩下几个看着就秃头的A类作业（物理引擎、光追和网格加密&简化），还是先写B类把作业交上再说😪😪😪 这篇也是图形学的小作业，主要实现了半边结构网格的构建和读取。 实验原理 使用半边结构读入obj格式的三维网格模型，并配置VAO、EBO；通过glPolygenMode()设置点、线、面等显示模式。 半边结构 半边结构(The Half-Edge Data-Structure)是一种适用于流形多边形网格的数据结构。半边结构将一条边分解为两条半边(half-edge)，二者方向相反，各自属于相邻面的边环。其优点是各种查找的复杂度都只有O(1)。 半边结构中的顶点 Vertex 存储自身坐标 vec3(x,y,z) 存储出边(out-edge)指针 Halfedge* 出边一般为网格的边界。 在本次实验读取网格时，通过半边终点的出边查找twin半边是否存在。 半边结构中的半边 Halfedge 存储起点指针 Vertex* 存储所属面指针 Face* 存储上一条半边指针 Halfedge* 存储下一条半边指针 Halfedge* 存储相反的半边指针 Halfedge* 半边结构中面 Face 存储第一条半边指针 Halfedge* （图源cs184/284a） 点&线&面的绘制 通过glPolygonMode()实现了点(GL_POINT)、线(GL_LINE)、面(GL_FILL)绘制的切换。要点如下： 可编程管线下的shader配置：通过uniform vec3设置不同颜色，实现一shader多用； 点绘制：启用GL_VERTEX_PROGRAM_POINT_SIZE，可以于顶点着色器中通过gl_PointSize设置点模式下点的大小； 线绘制：作业样例中的网格显示只包含正面线框，但实验发现若在glPolygonMode()中配置只绘制正面（GL_FRONT)似乎不适用于可编程管线，因此通过启用GL_CULL_FACE以实现反面的剔除。 面+线绘制：先以GL_FILL模式绘制面，再以GL_LINE模式绘制线框。注意如果开启了深度测试，绘制的线会收到一定面片的遮挡，可以绘制线时关闭深度测试或将顶点向外进行一个小的偏移。 实验步骤 1.构造数据结构(见mesh.h) 除前面提到的Vertex、Halfedge、Face等结构外，EdgeKey结构用于构建哈希表，便于创建网格时查找半边是否已经存在。另外，Mesh类中indices、VAO、VBO、EBO用于可编程管线的网格绘制，不是半边结构的构成部分。 Mesh类中保存了顶点表、半边表和面表，其中后两者都是保存的指针，但顶点直接保存的结构体。这是因为struct的属性在内存中的存储顺序是连续的，可以直接将其作为AttributeArray设置给VAO，便于绘制。 2.读取文件(见Mesh::loadFile()) ​ 逐行读取obj文件。 obj 文件每一行为一条信息，开头字母代表信息类型； v 开头为顶点，三个值分别为x，y，z坐标； f 开头为顶点， 三个值为点索引。注意这里的索引值从1开始计数，注意使用时-1以防止数组越界。 void Mesh::loadFile(std::string path) { std::ifstream objFile; objFile.open(path); std::string line, keyword; std::stringstream stream; float x, y, z; int vert0, vert1, vert2; while (objFile && !objFile.eof()) { std::getline(objFile, line); if (line.size() == 0 || line[0] == '#' || isspace(line[0])) continue; stream.str(line); stream.clear(); stream >> keyword; //load vertex if (keyword == "v") { stream >> x >> y >> z; addVertex(x, y, z); } //load faces else if (keyword == "f") { stream >> vert0 >> vert1 >> vert2; addFace(--vert0, --vert1, --vert2); } } objFile.close(); setupMesh(); } 3.创建顶点(见Mesh::addVertex()) 首先通过坐标逐个创建顶点，出边outedge*暂时为空。 Vertex* Mesh::addVertex(float x, float y, float z) { Vertex* vertex= new Vertex(x, y, z); vertices.push_back(*vertex); return vertex; } 4.创建三角面(见Mesh::addFace()) 全部顶点读取完成后，开始逐个创建三角面和半边。 创建三角面时，还需要构建面内半边之间的连接关系。因此首先我们要获取面内的三条半边： ​ 4.1创建半边对(见Mesh::addEdge()) ​ 所有半边都是成对出现的，也会成对创建。因此有可能我们所需的半边在先前已经创建过了，为获知v1->v2的半边是否已经存在，我使用unordered map来保存顶点对和半边间的关系。并在创建半边前，首先查询哈希表，若半边存在则直接返回该半边。 Halfedge* Mesh::addEdge(Vertex* v1, Vertex* v2) { //查找v1-v2半边是否已经存在 EdgeKey _key(v1->index, v2->index); if (hashmap.find(_key) != hashmap.end()) { return hashmap[_key]; } //若不存在，创建新的半边对 Halfedge* innerEdge = new Halfedge(v1); edges.push_back(innerEdge); Halfedge* outterEdge = new Halfedge(v2); edges.push_back(outterEdge); innerEdge->twin = outterEdge; outterEdge->twin = innerEdge; v1->outEdge = innerEdge; v2->outEdge = outterEdge; //存入哈希表 hashmap[EdgeKey(v1->index, v2->index)] = innerEdge; hashmap[EdgeKey(v2->index, v1->index)] = outterEdge; return innerEdge; } ​ 4.2 构建半边间连接关系 ​ 设置各半边的next指针与Face的startEdge指针。 Face* Mesh::addFace(int v0, int v1, int v2) { Face* face = new Face(); Vertex* vertexs[3]; vertexs[0] = &vertices[v0]; vertexs[1] = &vertices[v1]; vertexs[2] = &vertices[v2]; Halfedge* _edges[3]; for (int i = 0; i < 3; i++) { _edges[i] = addEdge(vertexs[i % 3], vertexs[(i + 1) % 3]); } for (int i = 0; i < 3; i++) { _edges[i]->next = _edges[(i + 1) % 3]; _edges[i]->incFace = face; } face->startEdge = _edges[0]; faces.push_back(face); return face; } 5.配置网格(见Mesh::setupMesh()) 由于使用可编程管线，需要为网格配置VAO、VBO、EBO。 前文提到vertices保存的是Vertex struct本体，所以position、normal这些信息在内存空间中是连续的。 在设置VertexAttributeArray时，可以通过struct的offsetof()方法获取属性的开始位置，例如： // 法线 glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, normal)); （ps.这里卡了好久...debug的时候给Vertex加了一个index属性，还写在了最前面，然后就完全忘记这回事，结果后来就渲染不出来了....因为position的Pointer写的(void*)0.....) void Mesh::setupMesh() { computeIndices(); computeNormals(); glGenVertexArrays(1, &VAO); glGenBuffers(1, &VBO); glGenBuffers(1, &EBO); glBindVertexArray(VAO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), &indices[0], GL_STATIC_DRAW); // 顶点位置 glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); // 法线 glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, normal)); glBindVertexArray(0); } 6.按不同模式绘制(见Mesh::draw()) void Mesh::draw() { glBindVertexArray(VAO); glDrawElements(GL_TRIANGLES, static_cast<unsigned int>(indices.size()), GL_UNSIGNED_INT, 0); } 实验效果 四种绘制效果如下。通过键盘的1、2、3、4切换四种绘制模式。 实验环境 Window 10. Visual Studio 2022. 依赖库：GLFW，GLAD， GLM
很久没写shadertoy了，碰巧OpenGL的一个小作业可以纯shader完成，拿来水一篇博客好啦。 阿基米德螺旋其实之前写过👉ODOS丨大大泡泡糖👈 相当于这次坐标系统里的y坐标。 实验原理 主要效果于Fragment Shader实现。阿基米德螺旋运用坐标转换，将纹理坐标从笛卡尔坐标系转换为类极坐标系，通过时间偏移形成动态效果。 阿基米德螺旋实现 - 坐标转换 当一点P沿动射线OP以等速率运动的同时，这射线又以等角速度绕点O旋转，点P的轨迹称为“阿基米德螺线”。它的极坐标方程为：r=a+b\theta，这种螺线的每条臂的间距永远相等于2\pi b。 通过构建基于阿基米德螺旋的坐标系，我们将一个点的纹理坐标(x,y)转换为(d,a):其中a为重映射后点P到原点的距离，d为点P到原点连线截取的螺旋弦长。 a的计算公式为： a= \frac{\sqrt{x^2 + y^2}}{R} - \frac{\theta _0}{2\pi}\\ \text{with }\theta _0 = atan(y,x) 其中R为阿基米德螺线的间距。则ceil(a)为转动的周数，完整的转动角为：\theta =\theta _0 + 2\pi ceil(a). d的计算公式为: q = \theta *\sqrt{|1-\theta^2|}\\ d = \frac{0.5R}{\pi}(q + log(q)) 对转换后的(d,a)坐标缩放后通过fract()函数截取小数部分，即可获取采样纹理的“局部坐标系”，坐标系的可视化效果如下： 小数部分的xy做为纹理坐标，用于采样star.bmp，d的整数部分作为生成随机颜色的seed. 用转换过的坐标系采样纹理，就可以得到沿阿基米德螺旋旋转的星星（如左下）。 为了更高的体现“旋转并扩散”的效果，利用上述的d整数部分通过step()制作了一张随时间旋转扩散的“蒙版”（如中下），与星星相乘后即可获取中心部分（如右下）。 随机颜色实现 Shader中的伪随机函数选用了最常见的正弦伪随机。将上文提到的d整数部分作为随机种子，并对RGB三个通道添加了不同的偏移值，以获取随机色。 float random (vec2 uv) { return fract(sin(dot(uv.xy, vec2(12.9898,78.233))) * 43758.5453123); } 颜色与上文的灰度蒙版相乘即可得到彩色星星。 存在的问题和掩盖方式 通过Shader完成该题目的方式的优点在于不需要维护粒子系统的产生、死亡，由于只有一个四边形所以顶点极少，属于偷懒的小trick...但极坐标的扭曲会导致靠近螺旋中心的星星产生比较明显的扇形变形。 为了掩饰中心变形，在中心叠加了一颗同系统一起旋转的星星，伪装为“粒子发射源”。 实验步骤 通过GLFW与GLAD构建自定义渲染管线，Shader文件的编译参考了LearnOpenGL的Shader类，纹理图片的读取使用stb_image库。 由于主要效果于片元着色器实现，主函数仅需一个绘制覆盖窗口的四边形，并将纹理坐标、时间与指定纹理传入着色器即可。 实验环境 Window 10. Visual Studio 2022. 依赖库：GLFW，GLAD，stb_image 渲染全屏quad 这里贴一下全屏方块的代码，方便以后ctrlC&ctrlV~ unsigned int quadVAO = 0; unsigned int quadVBO; void renderQuad() { if (quadVAO == 0) { float quadVertices[] = { // positions // texture Coords -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, }; // setup plane VAO glGenVertexArrays(1, &quadVAO); glGenBuffers(1, &quadVBO); glBindVertexArray(quadVAO); glBindBuffer(GL_ARRAY_BUFFER, quadVBO); glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), &quadVertices, GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); } glBindVertexArray(quadVAO); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glBindVertexArray(0); } 以及顶点着色器: #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec2 aTexCoords; out vec2 TexCoords; void main() { TexCoords = aTexCoords; gl_Position = vec4(aPos, 1.0); } 片元着色器： #version 330 core out vec4 FragColor; in vec2 TexCoords; void main() { FragColor = vec4(color, 1.0); } ODOS！ Shadertoy版本没有材质，用了iq的一个十字星的SDF。然后稍微改了一下着色的规则！下班！