├── LICENSE ├── README.md ├── Unity实现Virtual Texture虚拟纹理 - 知乎.md ├── Unity技术美术日记——多种方式实现布料飘动 - 知乎.md ├── imgs ├── clipper-setting.png ├── collect-success.png ├── collect.png ├── githubtoken-setting.png ├── githubtoken.png ├── import.png ├── success.png └── webclipper.png ├── 游戏分包- 知乎.md └── 理解 C /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 目标 2 | 我们经常在浏览网页的时候要收藏一下网页内容,并且消化这些内容,这里面介绍如何一套方案帮我们实现: 3 | 1. 借助github库 + web clipper网页插件自动保存文章 4 | 2. 把第一步形成的网页链接接入google的 notebooklm,自动生成语音博客,学习指南等 5 | 6 | ### 如何保存网页实现 7 | - 登陆你的github账号,获取方法可以参考 [GitHub使用Personal access token](https://www.cnblogs.com/chenyablog/p/15397548.html) 如图:![alt text](imgs/githubtoken.png) 8 | - 在浏览器上安装![web clipper](imgs/webclipper.png) 9 | - 点击设置 web clipper:![alt text](imgs/clipper-setting.png) 10 | - 设置github token的默认知识库:![alt text](imgs/githubtoken-setting.png) 11 | 12 | 13 | ### 阶段性验证结果: 14 | - 在完成上面的步骤之后,你就可以保存到你要收藏的网页内容到github啦:![alt text](imgs/collect.png) ![alt text](imgs/collect-success.png) 15 | 16 | 17 | ## 如何把你保存的网页导入notebooklm 18 | - 登陆 https://notebooklm.google.com/ 大陆用户自备梯子 19 | - ![alt text](imgs/import.png) 用网站导入的方式,填写你保存的github的文章的地址 20 | 21 | ## 享受时刻: 22 | ![alt text](imgs/success.png) 23 | 24 | 25 | ## 备注: 26 | - 有些时候notebooklm 在导入 url的时候,如果url的内容过于复杂(有可能是自身的bug)会失败,你可以直接选择你github库的上的文章下载到本地,再上传到notebooklm上去 27 | - 亲自测试markdown文件导入notebooklm的成功率要远高于pdf,所以现阶段成功率最高的步骤: 网页内容 用webclipper 保存成markdown文件到 github之后,用githubdesktop更新到本地然后上传notebooklm 28 | - 这套方案是灵活的,你完全可以把webclipper 保存的内容,下载到本地以后,用其他知识库来读取 29 | 30 | -------------------------------------------------------------------------------- /Unity实现Virtual Texture虚拟纹理 - 知乎.md: -------------------------------------------------------------------------------- 1 | # Unity实现Virtual Texture虚拟纹理 - 知乎 2 | 技术背景 3 | ---- 4 | 5 | Virtual Texture(虚拟纹理)主要用于解决实时渲染中由于各种原因限制导致的无法直接显示超大分辨率纹理的问题。有可能是受限于内存,有可能是受限平台或图形API等。一般超大纹理不会完整被渲染到屏幕中,远景更多的情况是渲染低分辨率的Mipmap。 6 | 7 | VT的思路就是把这张超大纹理切分成多个块(Page)。渲染时,根据屏幕绘制的需要,去加载或绘制对应的Page到物理缓存中。使用的时候通过间接列表(PageTable)的方式去找到这个Page。 8 | 9 | VT有多种技术拓展: 10 | 11 | ### **Streaming Virtual Texture(SVT) 12 | 流式虚拟纹理(SVT)** 13 | 14 | 纹理是已有资产,如模型贴图等,需要离线切割Page。运行时按需要加载Page而不需要整个大纹理加载,节省内存。 15 | 16 | ![](https://pic1.zhimg.com/v2-d6cfd4ce62fa0d3a5489bceee8f89232_1440w.jpg) 17 | 18 | ### **[Runtime Virtual Texture](https://zhida.zhihu.com/search?content_id=251345179&content_type=Article&match_order=1&q=Runtime+Virtual+Texture&zhida_source=entity)(RVT): 19 | 运行时虚拟纹理(RVT):** 20 | 21 | 纹理是实时生成的,运行时按需要渲染Page,典型的案例是地型渲染,地型渲染需要混合多层纹理贴图开销较大,RVT可以把混合结果缓存起来,节省性能。 22 | 23 | ![](https://pica.zhimg.com/v2-7557b837ab672581666467f229bc00a0_1440w.jpg) 24 | 25 | ### **[Adaptive Virtual Texture](https://zhida.zhihu.com/search?content_id=251345179&content_type=Article&match_order=1&q=Adaptive+Virtual+Texture&zhida_source=entity)(AVT): 26 | 自适应虚拟纹理(AVT):** 27 | 28 | 解决追求高精度的VT时 Indirection Texture过大的问题。 在标准RVT上引入了Sectors概念,VirtureTexture被划分到多个Sectors,根据相机距离动态调整Sectors大小,每个Sectors对应一份固定大小的Indirection Texture 去索引物理页。相当于多套了一层转换。 29 | 30 | ![](https://pica.zhimg.com/v2-ac497681223fafe542f0ef2ba05375ac_1440w.jpg) 31 | 32 | ### **Clipmap Texture:剪影纹理:** 33 | 34 | 使用相机信息来收集需要渲染的Page,而不是屏幕回读方式。避免了的GPU回读慢的问题和异步回读带来的延迟性。指定了特定区域的精度,解决了IndirectionTexture过大的问题。 更适合低端设备使用。其实和Cascade Shadowmap类似。 35 | 36 | ![](https://pic1.zhimg.com/v2-32f19449e6dad0e6792e3c607693501c_1440w.jpg) 37 | 38 | 多种VT部分流程是大致相同的。 39 | 40 | 篇幅有限本文主要介绍和实现标准的RVT和Clipmap Texture来渲染地型。 41 | 42 | ![](https://pic1.zhimg.com/v2-570884b74c2c79c98c7ee4cad71374d0_1440w.jpg) 43 | 44 | Virtual Texture Terrain虚拟纹理地形 45 | 46 | ![](https://pic3.zhimg.com/v2-c6db6683485301ba16d3a06d02456534_1440w.jpg) 47 | 48 | Virtual Texture Terrain虚拟纹理地形 49 | 50 | VT流程 51 | ---- 52 | 53 | VT主要流程其实大同小异: 54 | 55 | ### 1.划分Virtual Texture Page1.划分虚拟纹理页 56 | 57 | 把整个巨大的虚拟纹理,每级mipmap都均匀划分成多个Virtual Texture Page。 58 | 59 | 越高级的Mip,Page数量越少,对应Virtual texture的像素区域越大。 60 | 61 | 如单个Pages Mip0:128 \* 128 , Mip1: 256 \* 256。 62 | 63 | ![](https://picx.zhimg.com/v2-e7fb8abd867b884f4a0d92cc2128e469_1440w.jpg) 64 | 65 | VT Mipmap PagesVT Mipmap 页 66 | 67 | ### 2.收集可视Virtual Texture Page 68 | 2.收集可视虚拟纹理页 69 | 70 | 目的是收集当前需要的VTPage,主要分CPU和GPU两种流派: 71 | 72 | **CPU流派:** 根据相机位置估算所需VTPage的Mipmap,ClipmapTexture用的方式。 73 | 74 | 优点:快速,没有延迟性。 75 | 76 | 缺点:VTPage没有遮挡剔除,没有准确的mip信息。 77 | 78 | ![](https://picx.zhimg.com/v2-210ccaf70d04add9de2a624b7f5ac79f_1440w.jpg) 79 | 80 | **GPU流派:** 在屏幕中渲染Feedback Pass(输出Page和mip信息)到指定Buffer,然后通过异步回读的方式把VTPage和mip传到CPU处理。通常为了加快回读速度,会额外对Buffer做降采样。 81 | 82 | 优点:可以比较精准获取可视的VTpage,享有遮挡剔除。 83 | 84 | 缺点:需要额外渲染Feedback Pass,异步回读比较慢,且有一定延迟性,需要解决降采样带来的信息丢失问题。 85 | 86 | ![](https://pic3.zhimg.com/v2-bc075830425dc5a12258ef072ae7cc52_1440w.jpg) 87 | 88 | Feedback反馈 89 | 90 | ### 2.更新Physical Texture2.更新物理纹理 91 | 92 | 获取到需要的Virtual Texture Page信息后,就可以在Physical Texture申请一块区域(Physical Page)来渲染生成对应的Virtual Texture Page,通常使用LRU(Least Recently Used)双向循环链表来维护这些物理页,经常看到的Page在链尾,分配时候尽量取链头的Physical Page,这些Physical Page是循环复用的。 93 | 94 | ![](https://picx.zhimg.com/v2-b9798f43de0466018d1bdbbcb98d1465_1440w.jpg) 95 | 96 | Physical Page(LRU)物理页面(LRU) 97 | 98 | 然后在Physical Texture上的Physical Page区域,通过混合各种地型Splat Map渲染对应Virtual Page的Virtual Texture区域。 99 | 100 | ![](https://picx.zhimg.com/v2-5e5bba17732e592811c0e85dc65aeda9_1440w.jpg) 101 | 102 | ### 3.更新IndirectTexture(PageTable) 103 | 3.更新间接纹理(页表) 104 | 105 | 绘制了物理页后,需要把Physical Page在 Physical Texture的位置信息,写入到对应的Indirection Texture里。Indirection Texture的每个纹素代表一个Page的寻址转换。这样实际用的时候我们采样Indirect Texture就可以找到实际对应的Physical Page。 106 | 107 | ![](https://pic4.zhimg.com/v2-ba74f065b14b57be6763904bf137c6b3_1440w.jpg) 108 | 109 | RVT实现 110 | ----- 111 | 112 | ### 1.初始化 113 | 114 | **Indirection Texture:** 115 | 116 | 我们需要先把VT划分多个Virtual Page,每个Page对于Indirection Texture的一个纹素。我们可以拟定一个mip0的Page大小是128个像素,Indirection Texture的大小1024(即1024个Page),那个VT的分辨率是1024 \*128。有了这些数据就可以初始化我们的Indirection Texture。 117 | 118 | ![](https://pic3.zhimg.com/v2-b6ddaf7a3d2336079734c904961b747c_1440w.jpg) 119 | 120 | Indirection Texture是带mipmap的,我们需要把Mipmap中的Page数据也初始化。每级的Mipmap中有对应分辨率的Page,越高级的Mip对应Page的数量就越少,单个Page对应VT的区域越大。 121 | 122 | ![](https://pic3.zhimg.com/v2-b8963929081101ae94586b15f762f1e0_1440w.jpg) 123 | 124 | ![](https://picx.zhimg.com/v2-87bf395076ac0283d18a3f5fc8b64fa9_1440w.jpg) 125 | 126 | VTPage的ID使用莫顿码来编码,可以让Page按照“Z”字排序的,用来后续快速查该页对应其他mip的对应位置。 127 | 128 | ![](https://pic2.zhimg.com/v2-8ce01d0d30fab4def07173e3b5cc90d9_1440w.jpg) 129 | 130 | Z形编码 131 | 132 | ```csharp 133 | public static class MortonCode 134 | { 135 | public static int MortonCode2(int x) 136 | { 137 | x &= 0x0000ffff; 138 | x = (x ^ (x << 8)) & 0x00ff00ff; 139 | x = (x ^ (x << 4)) & 0x0f0f0f0f; 140 | x = (x ^ (x << 2)) & 0x33333333; 141 | x = (x ^ (x << 1)) & 0x55555555; 142 | return x; 143 | } 144 | 145 | // Encodes two 16-bit integers into one 32-bit morton code 146 | public static int MortonEncode(int x,int y) 147 | { 148 | int Morton = MortonCode2(x) | (MortonCode2(y) << 1); 149 | return Morton; 150 | } 151 | public static int ReverseMortonCode2(int x) 152 | { 153 | x &= 0x55555555; 154 | x = (x ^ (x >> 1)) & 0x33333333; 155 | x = (x ^ (x >> 2)) & 0x0f0f0f0f; 156 | x = (x ^ (x >> 4)) & 0x00ff00ff; 157 | x = (x ^ (x >> 8)) & 0x0000ffff; 158 | return x; 159 | } 160 | 161 | public static void MortonDecode(int Morton,out int x,out int y) 162 | { 163 | x = ReverseMortonCode2(Morton); 164 | y = ReverseMortonCode2(Morton >> 1); 165 | } 166 | 167 | } 168 | 169 | ``` 170 | 171 | **Physical Texture:** 172 | 173 | 物理贴图是存放实际存放Page数据的地方,大小是我们指定的,一般地型渲染的话Physical Texture是4k - 8k左右的分辨率,有2张(基色图,法线图),这里代码中物理页我用Tile来表示,Tile的数量是 : 物理页分辨率 / 单页的分辨率,Tile是使用了上面提到的LRU管理。 174 | 175 | 本文的Physical Texture是使用的[TextureArray](https://zhida.zhihu.com/search?content_id=251345179&content_type=Article&match_order=1&q=TextureArray&zhida_source=entity)纹理数组格式,主要是想着不希望更新一小块的Page导致整张RT被Load/Store,还有就是提高实时压缩的效率(后面会提到)。 176 | 177 | ```csharp 178 | using System.Collections; 179 | using System.Collections.Generic; 180 | using UnityEngine; 181 | using UnityEngine.Experimental.Rendering; 182 | 183 | public class VTPhysicalTable 184 | { 185 | public class Tile 186 | { 187 | public Vector4 rect; 188 | public VTPage cachePage; 189 | public byte depth; 190 | public Tile prev; 191 | public Tile next; 192 | public bool SetCachePage(VTPage page, out VTPage oldPage) 193 | { 194 | if (cachePage != null && cachePage.alwayInCache) 195 | { 196 | oldPage = null; 197 | return false; 198 | } 199 | else 200 | { 201 | oldPage = ClearCache(); 202 | cachePage = page; 203 | return true; 204 | } 205 | } 206 | public VTPage ClearCache() 207 | { 208 | if (cachePage != null && !cachePage.alwayInCache) 209 | { 210 | var oldPage = cachePage; 211 | cachePage.loadFlag = VTPage.FLAG_NONE; 212 | cachePage.physTile = null; 213 | cachePage = null; 214 | return oldPage; 215 | 216 | } 217 | else 218 | { 219 | return null; 220 | } 221 | } 222 | 223 | } 224 | 225 | public int realWidth; 226 | public int realHeight; 227 | int tileCountX; 228 | int tileCountY; 229 | byte depth; 230 | public RenderTexture[] rts; 231 | public int[] matPass; 232 | public Tile lruFirst; 233 | public Tile lruLast; 234 | 235 | void AddLRU(Tile tile) 236 | { 237 | if (lruFirst == null) 238 | { 239 | lruFirst = tile; 240 | lruLast = tile; 241 | } 242 | else 243 | { 244 | var last = lruLast; 245 | tile.prev = last; 246 | last.next = tile; 247 | lruLast = tile; 248 | } 249 | } 250 | 251 | void RemoveLRU(Tile tile) 252 | { 253 | if (lruFirst == tile && lruLast == tile) 254 | { 255 | lruFirst = null; 256 | lruLast = null; 257 | } 258 | else if (lruFirst == tile) 259 | { 260 | lruFirst = tile.next; 261 | } 262 | else if (lruLast == tile) 263 | { 264 | lruLast = lruLast.prev; 265 | } 266 | else 267 | { 268 | tile.prev.next = tile.next; 269 | tile.next.prev = tile.prev; 270 | tile.prev = null; 271 | tile.next = null; 272 | } 273 | } 274 | 275 | public VTPhysicalTable(Vector2Int size,byte depth,int pageWidth,int rtCount = 1,GraphicsFormat rtFormat = GraphicsFormat.R8G8B8A8_UNorm) 276 | { 277 | tileCountX = Mathf.CeilToInt(size.x / (float)pageWidth); 278 | tileCountY= Mathf.CeilToInt(size.y / (float)pageWidth); 279 | realWidth = tileCountX * pageWidth; 280 | realHeight = tileCountY * pageWidth; 281 | this.depth = depth; 282 | for (int z = 0; z < depth; z++) 283 | { 284 | for (int y = 0; y < tileCountX; y++) 285 | { 286 | for (int x = 0; x < tileCountY; x++) 287 | { 288 | var t = new Tile(); 289 | t.rect = new Vector4(x * pageWidth, y * pageWidth, pageWidth, pageWidth); 290 | t.depth = (byte)z; 291 | AddLRU(t); 292 | } 293 | } 294 | } 295 | CreateRenderTexture(rtCount, rtFormat); 296 | } 297 | public virtual void CreateRenderTexture(int rtCount , GraphicsFormat rtFormat ) 298 | { 299 | rts = new RenderTexture[rtCount]; 300 | matPass = new int[rtCount]; 301 | for (int i = 0; i < rtCount; i++) 302 | { 303 | var rt = new RenderTexture(realWidth, realHeight, 0, rtFormat); 304 | rt.dimension = UnityEngine.Rendering.TextureDimension.Tex2DArray; 305 | rt.volumeDepth = depth; 306 | rt.filterMode = FilterMode.Bilinear; 307 | rt.name = "VTPhysicalMap" + i; 308 | rt.Create(); 309 | rts[i] = rt; 310 | } 311 | } 312 | 313 | /// 314 | /// 标识最近使用 315 | /// 316 | public void MarkRecently(Tile tile) 317 | { 318 | RemoveLRU(tile); 319 | AddLRU(tile); 320 | } 321 | 322 | /// 323 | /// 分配Tile 324 | /// 325 | public void AllocAddress(VTPage page,out VTPage oldPage) 326 | { 327 | oldPage = null; 328 | var tile = lruFirst; 329 | while (tile != null) 330 | { 331 | if (tile.SetCachePage(page,out oldPage)) 332 | { 333 | page.physTile = tile; 334 | MarkRecently(tile); 335 | return; 336 | } 337 | tile = tile.next; 338 | } 339 | #if UNITY_EDITOR 340 | Debug.LogError("物理贴图空间不足,可能导致渲染错误"); 341 | #endif 342 | } 343 | 344 | } 345 | 346 | ``` 347 | 348 | ### 2.Feedback 349 | 350 | 我们需要收集场景用到的VT Page。Feedback机制就是通过在GPU上计算出Page ID和Mipmap输出到Feedback Buffer。然后通过CPU的异步回读来解析这个Buffer,得到Page信息。 351 | 352 | **Feedback Buffer** 353 | 354 | 我们Buffer格式是32位RGBA8。一般VT划分的Page是1024或2048左右,所以PageID的xy坐标是大于256,那么8位是不够了,一般会把 B通道的 8位拆分到RG里去,即PageID的xy用12位来表示。然后A通道8位来存mipLevel。 355 | 356 | | IR8 | G8 | B8 | A8 | 357 | | --- | --- | --- | --- | 358 | | PageID X (低8位) | PageID Y (低8位) | 4位 PageID X(高4位) 359 | 4位 PageID Y(高4位) | mipLevel | 360 | 361 | 有了这个Feedback Buffer,还需要把数据输出的Buffer上。 362 | 363 | 一种老派的做法是把Feedback Buffer作为Gbuffer的其中一个渲染目标,利用MRT的特性来输出。 364 | 365 | 而UE则在PS直接输出到一个无序访问的UAV Buffer,这个Buffer的大小基于屏幕分辨率缩小的,主要是为了加快回读的速度。缩小了分辨率会导致信息丢失,所以看到代码用利用JitterOffset的随机偏移机制,利用多帧把丢失的信息补充回来。 366 | 367 | ![](https://pica.zhimg.com/v2-38606bf0e263ea501bd4e7c1a8dd14a0_1440w.jpg) 368 | 369 | UE Feedback 370 | 371 | 与 UE和MRT 的不同,为了减少代码的入侵性和方便展示,本文额外绘制了一次地型的Feedback Pass到Feed Buffer中。实际项目中不建议这么处理,因为需要额外的地型绘制,同时这样只会有地型的自遮挡而没有场景的遮挡信息,导致多余的Page被显示。 372 | 373 | **Feedback Pass** 374 | 375 | 本文Feedback Pass的Shader代码如下,UV \* PageCount 得出所属的PageID。然后通过DDX,DDY计算出MipLevel。 376 | 377 | ![](https://pic4.zhimg.com/v2-1f69320cfa29c8fe149fd1614916cac5_1440w.jpg) 378 | 379 | 输出Page信息 380 | 381 | ![](https://pic4.zhimg.com/v2-89ca9003134add63425ebb451377ec0f_1440w.jpg) 382 | 383 | 计算MipLevel 384 | 385 | ![](https://pic1.zhimg.com/v2-bf3d091ef6dd6b22b21cece49474ca94_1440w.jpg) 386 | 387 | 左:Feedback Buffer 388 | 389 | **Feedback 降采样** 390 | 391 | 需要注意的是Feedback Pass是需要在原始屏幕分辨率下渲染的,不能直接使用低分辨率的Buffer来渲染,因为我们的MipLevel是通过DDX,DDY计算的,分辨率改变了这个mip就不正确了。 392 | 393 | 如果像UE那样用全分辨率渲染,输出到低分辨率的UAV Buffer上就没有这个问题。但是我们并不是,所以需要额外做一次降采样到低分辨率的RenderTarget上。 394 | 395 | 这里降采样的方法是,在原始Feedback Buffer上圆盘随机采样,避免数据的丢失,随机数每帧变化,多帧后能接近原始数据。 396 | 397 | ![](https://pica.zhimg.com/v2-686634232cb0120536ede94c1e56d68a_1440w.jpg) 398 | 399 | 降采样 400 | 401 | ![](https://picx.zhimg.com/v2-8c49bd7a60b5283fe605918ed23b523b_1440w.jpg) 402 | 403 | 降采样后 404 | 405 | ### 3.收集可见Page 406 | 407 | 有了Feedback Buffer,我们就可以利用异步回读的方式获取他,在Unity里用的是CommandBuffer.RequestAsyncReadback 的API,这里加了同步回读的开关方便FrameDebuger调试 408 | 409 | ![](https://pic4.zhimg.com/v2-a153846fef8a1b76ed4a574c291a76df_1440w.jpg) 410 | 411 | 回读成功后,遍历这个Feedback Buffer的每个像素,解析出Page和MipLevel,加到处理列表里。处理列表按照Mip和出现次数排序,可以按照项目需要调整优先处理策略。 412 | 413 | ```csharp 414 | 415 | Dictionary pageReadPixelCounter = new Dictionary(); 416 | 417 | 418 | private void OnReadback(AsyncGPUReadbackRequest obj) 419 | { 420 | if (!obj.hasError && readyReadback) 421 | { 422 | ClearWaitList(); 423 | var colors = obj.GetData(); 424 | for (int i = 0; i < colors.Length; i++) 425 | { 426 | var color = colors[i]; 427 | UnpackPage(color.r, color.g, color.b,out int pageIndexX,out int pageIndexY); 428 | int pageMip = (int)color.a; 429 | 430 | //无效像素 431 | if (pageMip < 0 || pageIndexX < 0 || pageIndexY < 0 || pageIndexX >= indirectTable.texWidth || pageIndexY>= indirectTable.texHeight || pageMip > maxMip) 432 | continue; 433 | 434 | var page = indirectTable.GetPage(pageIndexX, pageIndexY, pageMip); 435 | 436 | if (pageReadPixelCounter.TryGetValue(page, out var count))//记录Page出现次数 437 | pageReadPixelCounter[page]++; 438 | else 439 | pageReadPixelCounter[page] = 0; 440 | 441 | if (page.loadFlag == VTPage.FLAG_LOADED) 442 | { 443 | physicalTable.MarkRecently(page.physTile);//加载过,更新LRU列表顺序 444 | } 445 | else if (page.loadFlag == VTPage.FLAG_NONE) 446 | { 447 | page.loadFlag = VTPage.FLAG_LOADING; 448 | waitLoadPageList.Add(page); 449 | } 450 | 451 | } 452 | waitLoadPageList.Sort(ReadbackSortComparer); 453 | pageReadPixelCounter.Clear(); 454 | } 455 | } 456 | 457 | private int ReadbackSortComparer(VTPage x, VTPage y) 458 | { 459 | if (y.mip != x.mip) 460 | return y.mip.CompareTo(x.mip);//优先高级 461 | return pageReadPixelCounter[y].CompareTo(pageReadPixelCounter[x]);//像素多的 462 | } 463 | /// 464 | /// 解析Page Buffer Color 465 | /// 466 | void UnpackPage(byte x, byte y, byte z,out int X,out int Y) 467 | { 468 | uint ix = x; 469 | uint iy = y; 470 | uint iz = z; 471 | // 8 bit in lo, 4 bit in hi 472 | uint hi = iz >> 4; 473 | uint lo = iz & 15; 474 | X = (int)(ix | lo << 8); 475 | Y = (int)(iy | hi << 8); 476 | } 477 | 478 | ``` 479 | 480 | ### 4.更新物理页 481 | 482 | 有了可视Page之后,我们就可以对可视的Page进行绘制了,为了防止单帧中生成的Page过多,需要限制单帧中最大绘制数量。 483 | 484 | 通过VTPage去申请Physical Texture的Tile,如果Tile是已经储存了内容,理论上已存内容已经是不需要的了,但是并不能暴力覆盖上面的内容。因为Feedback是异步和延迟的,如果当前帧又刚好渲染到了移除的地方,那么就会造成渲染错误。为了避免这种情况,需要把Indirection Texture旧的Page映射到其他的Mip上后,才能覆盖物理页上的内容。 485 | 486 | ```csharp 487 | private List remapPages = new List(); 488 | void CollectLoadPage(bool limit = true) 489 | { 490 | for (int i=0;i< physicalUpdateList.Length;i++) 491 | physicalUpdateList[i].Clear(); 492 | int curRenderSize = 0; 493 | //分批生成page 494 | { 495 | for (int i = 0; i < waitLoadPageList.Count; i++) 496 | { 497 | var page = waitLoadPageList[i]; 498 | curRenderSize += pageWidth; 499 | if (curRenderSize > maxRenderSize && !forceUpdate && limit) //性能限制导致分批 500 | { 501 | break; 502 | } 503 | else 504 | { 505 | physicalTable.AllocAddress(page, out var oldPage); 506 | page.loadFlag = VTPage.FLAG_LOADED; 507 | physicalUpdateList[page.physTile.depth].Add(page); 508 | if (oldPage != null) //替换掉旧的Page 509 | { 510 | remapPages.Add(oldPage); 511 | } 512 | } 513 | } 514 | } 515 | 516 | for (int i = 0; i < remapPages.Count; i++) 517 | { 518 | RemapSubPageIndirect(remapPages[i]); 519 | } 520 | remapPages.Clear(); 521 | } 522 | 523 | ``` 524 | 525 | 实际更新物理页的时候,使用Instance实例化渲染正方面片在Physical Texture上绘制对应的VT区域。 526 | 527 | ```csharp 528 | void UpdatePhysicalTexture() 529 | { 530 | if (compress) 531 | InitCompress(); 532 | //绘制数据 533 | int renderCount = 0; 534 | if (renderPageTBS == null) 535 | { 536 | renderPageTBS = new Matrix4x4[MAX_RENDER_BATCH]; 537 | for (int i = 0; i < renderPageTBS.Length; i++) 538 | renderPageTBS[i] = Matrix4x4.identity; 539 | 540 | renderPageData = new Vector4[MAX_RENDER_BATCH]; 541 | } 542 | float padding = border; 543 | float scalePadding = (pageWidth + padding * 2f) / pageWidth; 544 | float offsetPadding = padding / (pageWidth + padding * 2f); 545 | //绘制到物理贴图 546 | cmd.BeginSample(profilerTag); 547 | var rtArray = physicalTable.rts; 548 | float pScaleX = pageWidth / (float)physicalTable.realWidth; 549 | float pScaleY = pageWidth / (float)physicalTable.realHeight; 550 | cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity); 551 | for (int r = 0; r < rtArray.Length; r++) //rt (baseColor,normal) 552 | { 553 | var rt = physicalTable.rts[r]; 554 | int pass = physicalTable.matPass[r]; 555 | for (int t = 0; t < physicalUpdateList.Length; t++) // Texture array index 556 | { 557 | var list = physicalUpdateList[t]; 558 | if (list.Count == 0) 559 | continue; 560 | RenderTargetIdentifier depthIdentifier = new RenderTargetIdentifier(rt, 0, CubemapFace.Unknown, t); 561 | cmd.SetRenderTarget(depthIdentifier, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.DontCare); 562 | for (int i = 0, len = list.Count, lastIndex = list.Count - 1; i < len; i++) // pages per texture array slice 563 | { 564 | var page = list[i]; 565 | var mipmap = indirectTable.mips[page.mip]; 566 | float scaleX = (mipmap.virtualWidth) / (float)virtualTextureSize.x; 567 | float scaleY = (mipmap.virtualWidth) / (float)virtualTextureSize.y; 568 | AddIndirectUpatePage(page, page.mip); 569 | UpdateSubPageIndirect(page);//更新子页 570 | /* Page UV 转 VT UV 数据 571 | (uv * pageMipSize + pageStart) /virtualTextureSize 572 | pageStart = pageMipIndex * pageMipSize 573 | */ 574 | MortonCode.MortonDecode(page.mortonID,out var localIndexX,out var localIndexY); 575 | float biasX = localIndexX * scaleX; 576 | float baseY = localIndexY * scaleY; 577 | // (uv - offsetPadding) * scalePadding * S + B 578 | // uv * scalePadding * S - offsetPadding * scalePadding * S + B 579 | float vScaleX = scaleX * scalePadding; 580 | float vScaleY = scaleY * scalePadding; 581 | float vPosX = -offsetPadding * scalePadding * scaleX + biasX; 582 | float vPosY = -offsetPadding * scalePadding * scaleY + baseY; 583 | renderPageData[renderCount] = new Vector4(vScaleX, vScaleY, vPosX, vPosY); 584 | //物理页矩阵 585 | float pPosX = page.physTile.rect.x / (float)physicalTable.realWidth + pScaleX * 0.5f; 586 | float pPosY = page.physTile.rect.y / (float)physicalTable.realWidth + pScaleY * 0.5f; 587 | pPosX = pPosX * 2f - 1f; //[0,1] => [-1,1] 588 | pPosY = pPosY * 2f - 1f; 589 | ref var tbs = ref renderPageTBS[renderCount]; 590 | tbs.m03 = pPosX; 591 | tbs.m13 = pPosY; 592 | tbs.m00 = pScaleX; 593 | tbs.m11 = pScaleY; 594 | renderCount++; 595 | if ((renderCount == MAX_RENDER_BATCH || i == lastIndex) && renderCount > 0) 596 | { 597 | block.SetVectorArray(_VirtualTextureUVTransform, renderPageData); 598 | cmd.DrawMeshInstanced(RenderingUtils.fullscreenMesh, 0, renderPageMat, pass, renderPageTBS, renderCount, block); 599 | renderCount = 0; 600 | } 601 | } 602 | if(compress) 603 | gpuCompress.Compress4x4(cmd,rt,r == 0? pBaseColorCompreeTex : pNormalCompreeTex,true,t); 604 | } 605 | } 606 | cmd.EndSample(profilerTag); 607 | Graphics.ExecuteCommandBuffer(cmd); 608 | cmd.Clear(); 609 | for (int t = 0; t < physicalUpdateList.Length; t++) 610 | { 611 | var list = physicalUpdateList[t]; 612 | for(int i = 0; i < list.Count;i++) 613 | tempRemoveList.Add(list[i]); 614 | list.Clear(); 615 | } 616 | for (int i = 0; i < tempRemoveList.Count; i++) 617 | { 618 | var p = tempRemoveList[i]; 619 | waitLoadPageList.Remove(p); 620 | } 621 | tempRemoveList.Clear(); 622 | 623 | } 624 | 625 | ``` 626 | 627 | 物理页更新后,需要把Page添加到Indirection Texture更新列表里。 628 | 629 | ![](https://pic3.zhimg.com/v2-4e23bc1e7725ba871d5965c397a39056_1440w.jpg) 630 | 631 | 除了更新Page对应Mip的像素外,还要对其更低级的Mip做映射,否则在Feedback回读期间,如果渲染到没被映射的Page(如下图蓝线Page),也会导致渲染错误。本文的处理方案是让没被渲染的Page映射到已存在于物理贴图的更加高级Page的物理位置,如下图: 632 | 633 | ![](https://pic1.zhimg.com/v2-108d3294df1ebd2dd1c9248e916c79b6_1440w.jpg) 634 | 635 | 这里可以通过上面介绍的莫顿码就能快速找到低级mip的Page,然后每级Mip的pages遍历。这部开销其实比较大,或许存在更好的算法去处理这个问题。为了加速遍历和提高Indirect Texture的更新效率,本文使用了重叠绘制的方式,正方形会覆盖整个mip的区域,如果低级Mip已经存于物理贴图,那么会重新更新这个Page像素。 636 | 637 | 如上图,3个绿色像素不会分3个正方形更新,而是使用一个绿色正方形来更新,然后把红色正方形覆盖回去。 638 | 639 | ![](https://pic1.zhimg.com/v2-209466e86c76ac24b36967d117a31434_1440w.jpg) 640 | 641 | Physical Page绘制的Shader如下 642 | 643 | ![](https://pic1.zhimg.com/v2-b862546655d80f79f5db84c584450406_1440w.jpg) 644 | 645 | ![](https://pic4.zhimg.com/v2-32b3f5d4fa248d8f9217abfbcbcc308d_1440w.jpg) 646 | 647 | Physcial Texture 648 | 649 | ### 5.更新间接表 650 | 651 | Indirection Texture格式是RGBA32,每通道8位的纹理。通道信息如下: 652 | 653 | | R | G | B | A | 654 | | --- | --- | --- | --- | 655 | | Physical TileID X | Physical TileID Y | MipLevel | TextureArray Index | 656 | 657 | 上面提到了我们使用纹理数组的方式来实现Physical Texture Array,假设我们是1024 \* 1024 \* 8的大小,而每个Pages是128 \* 128,TileID XY = 1024 /128 = 8,即TileID是\[0,7\]区间,8位足够了。 658 | 659 | 更新Indirection Texture的方式和上面类似。 660 | 661 | ```csharp 662 | void UpdateIndirectTexture() 663 | { 664 | //绘制间接表信息 665 | int renderCount = 0; 666 | cmd.BeginSample(profilerTag); 667 | cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity); 668 | for (int m = 0; m < indirectUpdateList.Length; m++) 669 | { 670 | var list = indirectUpdateList[m]; 671 | if (list.Count == 0) 672 | continue; 673 | list.Sort(PageMipComparison);//高级的Mip先画,低级的mip覆盖 674 | var mipmap = indirectTable.mips[m]; 675 | cmd.SetRenderTarget(indirectTable.rt, mipmap.mipLevel); 676 | uint bitMask = ~(1u << m); 677 | for (int i = 0, len = list.Count, lastIndex = list.Count - 1; i < len; i++) 678 | { 679 | var page = list[i]; 680 | page.mipMask &= bitMask;//移除需要绘制mip标记 681 | { 682 | float phyBlockIndexX = (int)(page.physTile.rect.x / pageWidth) / 255f; //8bit 683 | float phyBlockIndexY = (int)(page.physTile.rect.y / pageWidth) / 255f; //8bit 684 | float mip = page.mip / 255f; //8bit 685 | float phyTexArrayIndex = page.physTile.depth / 255f; // 纹理数组Index 686 | ref var data = ref renderPageData[renderCount]; 687 | data.x = phyBlockIndexX; 688 | data.y = phyBlockIndexY; 689 | data.z = mip; 690 | data.w = phyTexArrayIndex; 691 | 692 | MortonCode.MortonDecode(page.mortonID,out var localIndexX,out var localIndexY); 693 | float vScaleX, vScaleY, vPosX, vPosY; 694 | if (page.mip != (byte)m) //处理回退的page,整个区域都映射到该Mip上 695 | { 696 | var pageMip = indirectTable.mips[page.mip]; 697 | float w = pageMip.virtualWidth; 698 | vScaleX = w / virtualTextureSize.x; 699 | vScaleY = w / virtualTextureSize.y; 700 | vPosX = (w * localIndexX) / virtualTextureSize.x; 701 | vPosY = (w * localIndexY) / virtualTextureSize.y; 702 | } 703 | else 704 | { 705 | vScaleX = 1f / mipmap.size; 706 | vScaleY = 1f / mipmap.size; 707 | vPosX = localIndexX * vScaleX; 708 | vPosY = localIndexY * vScaleY; 709 | } 710 | float vHalfScaleX = vScaleX * 0.5f; 711 | float vHalfScaleY = vScaleY * 0.5f; 712 | vPosX += vHalfScaleX; 713 | vPosY += vHalfScaleY; 714 | vPosX = vPosX * 2f - 1f; //[0,1] => [-1,1] 715 | vPosY = vPosY * 2f - 1f; 716 | ref var tbs = ref renderPageTBS[renderCount]; 717 | tbs.m03 = vPosX; 718 | tbs.m13 = vPosY; 719 | tbs.m00 = vScaleX; 720 | tbs.m11 = vScaleY; 721 | renderCount++; 722 | } 723 | if (renderCount == MAX_RENDER_BATCH || i == lastIndex) 724 | { 725 | block.SetVectorArray(_VirtualTextureUVTransform, renderPageData); 726 | cmd.DrawMeshInstanced(RenderingUtils.fullscreenMesh, 0, renderPageMat, 1, renderPageTBS, renderCount, block); 727 | renderCount = 0; 728 | } 729 | } 730 | list.Clear(); 731 | } 732 | cmd.EndSample(profilerTag); 733 | Graphics.ExecuteCommandBuffer(cmd); 734 | cmd.Clear(); 735 | } 736 | 737 | ``` 738 | 739 | Shader就很简单了,直接输出的是地址的缩放和映射。 740 | 741 | ![](https://picx.zhimg.com/v2-01de9ea30cb76aa863c5c6d8bc119b35_1440w.jpg) 742 | 743 | ![](https://pic3.zhimg.com/v2-47693b1807bfcee431f767546ab45644_1440w.jpg) 744 | 745 | IndirectionTexture Mipmap 746 | 747 | ### 6.使用VT 748 | 749 | 使用VT就比较简单了, 需要小心的是Physical UV的计算就是了。 750 | 751 | Physical UV = ( VTPageUV \* TileSize+ PhysicalTileIndex \* TileSize) / PhyscialTextureSize 752 | 753 | VTPageUV 利用MipLevel和 VTPage的大小就可以计算出来了。 这样RVT就基本完成了。 754 | 755 | ```glsl 756 | 757 | float2 ComputePhysicalUV(float4 physicalData,float2 px) 758 | { 759 | int2 phyBlockInexXY = physicalData.xy; 760 | int pageMip = physicalData.z; 761 | int pageWidth = _VTPhysTexParma.x; 762 | int vtBlockWidth = pageWidth << pageMip; 763 | int2 vtBlockIndex = px / vtBlockWidth; 764 | float2 tileUV = (px - vtBlockIndex * vtBlockWidth) / vtBlockWidth; 765 | tileUV = tileUV * _VTPaddingParam.xy + _VTPaddingParam.zw;//边界处理 766 | float2 physicalUV = (tileUV * pageWidth + phyBlockInexXY * pageWidth) * _VTPhysTexParma.y;//_VTPhysTexParma: 1.0/PhyscialTextureSize 767 | return physicalUV; 768 | } 769 | 770 | 771 | void SampleDiffuseVT(float2 positionSS,float2 uv, inout half3 diffuse,inout half3 normal) 772 | { 773 | float2 px = uv * _VTParam.y; 774 | float2 dx = ddx(px); 775 | float2 dy = ddy(px); 776 | float mip, nextMip, mipFrac; 777 | ComputeVTMipData(dx, dy, mip, nextMip, mipFrac); 778 | float4 physicalData = SAMPLE_TEXTURE2D_LOD(_VTIndirectTex, sampler_VTIndirectTex, uv, mip) * 255.5; 779 | float2 physicalUV = ComputePhysicalUV(physicalData, px); 780 | float4 color = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTDiffuse, sampler_VTDiffuse, physicalUV, physicalData.w, 0); 781 | #ifdef _NORMALMAP 782 | normal = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTNormal, sampler_VTNormal, physicalUV, physicalData.w, 0).xyz * 2.0 -1.0; 783 | #endif 784 | diffuse = color.xyz; 785 | } 786 | ``` 787 | 788 | 出来效果和直接混合SplatMap基本差不多。 789 | 790 | ![](https://pic3.zhimg.com/v2-7bb146ac31d25cf3d877f91898bf8f36_b.gif) 791 | 792 | VT开启 793 | 794 | 在SceneView下可以看出高精度的地方都是靠近相机且在视锥内的。 795 | 796 | ![](https://pic4.zhimg.com/v2-caf5495e3ef2a7bf1b0bd859318d7583_1440w.jpg) 797 | 798 | SceneView视角 799 | 800 | Clipmap实现 801 | --------- 802 | 803 | Clipmap和RVT差异主要在收集Page的步骤。 804 | 805 | Indirection Texture也有所不同,每级mip使用相同数量的Page表示,即Indirection Texture不再使用mipmap了,而是使用TextureArray来存放每级Mip信息,每级mip的Page数量是相同的。 806 | 807 | Clipmap可以是围绕相机或是围绕主角,也可以不是原定对称而是往相机方向前倾,毕竟灵活。 808 | 809 | ![](https://pic1.zhimg.com/v2-8b722ee2b43b595c88ac1fc79dead460_1440w.jpg) 810 | 811 | 左图mip2 右图mip1 812 | 813 | ### 1.数据准备 814 | 815 | 这里我用Layer来表示每级Mip。一般6-8级Layer就足够了,每级有 8 \* 8或 16 \* 16的pages。 816 | 817 | 由于pages数量毕竟少,Indirection Texture使用CPU来更新,所以会有一个Colors数组来存放贴图颜色数据。 818 | 819 | ![](https://pica.zhimg.com/v2-f3647b6283a2fa12416a2aea7ee8f5ba_1440w.jpg) 820 | 821 | ![](https://pic1.zhimg.com/v2-034ac26cd079d17f390242b6ea82417a_1440w.jpg) 822 | 823 | ### 2.滚动更新可见Page 824 | 825 | Clipmap一个核心思路是使用有限的格子(page)来表示有限的区域,因为格子是固定数量的,当我们移动的时候,有些格子是相同的不需要更新,有部分需要更新到新的位置,这样格子是可以循环使用的,如下图: 826 | 827 | ![](https://pica.zhimg.com/v2-4f025919f509046ad5a4ac9ae0f29098_1440w.jpg) 828 | 829 | ![](https://picx.zhimg.com/v2-261ae4f510e7f97e17f421257ba0d97f_1440w.jpg) 830 | 831 | 这是个循环寻址的过程,把废弃的Page围绕中心镜像到新的位置,移动后重新调整锚点,这样个过程一直重复,参考上图。思路简单,但是代码毕竟麻烦: 832 | 833 | ```csharp 834 | void CollectDirtyPages() 835 | { 836 | var terrainBounds = terrain.terrainData.bounds; 837 | var postion = Camera.main.transform.position; 838 | var terrainMin = terrain.transform.position;// terrainBounds.min; 839 | var terrainSize = terrainBounds.size; 840 | Vector2 terrainUV = new Vector2(postion.x -terrainMin.x,postion.z-terrainMin.z); 841 | terrainUV.x /= terrainSize.x; 842 | terrainUV.y /= terrainSize.z; 843 | terrainUV.x = Mathf.Clamp01(terrainUV.x); 844 | terrainUV.y = Mathf.Clamp01(terrainUV.y); 845 | bool anyLayerUpdate = false; 846 | for (int i = 0; i < layers.Length; i++) 847 | { 848 | var layer = layers[i]; 849 | int blockCount = Mathf.CeilToInt(virtualTextureSize / (float)layer.pixelSize); 850 | var curPos= new Vector2Int((int)(terrainUV.x * blockCount),(int)(terrainUV.y * blockCount)); 851 | var layerMinX = Mathf.Clamp(curPos.x - pagePerLayer / 2,0,blockCount-pagePerLayer); 852 | var layerMinY = Mathf.Clamp(curPos.y - pagePerLayer / 2,0,blockCount-pagePerLayer); 853 | var preRect = layer.prevRectXY; 854 | if (layerMinX != preRect.x || layerMinY != preRect.y || forceUpdate || !isInit) 855 | { 856 | var pages = layer.pages; 857 | var layerMaxX = layerMinX + pagePerLayer -1; 858 | var layerMaxY = layerMinY + pagePerLayer -1; 859 | 860 | int offsetX = layerMinX - preRect.x; 861 | int offsetY = layerMinY - preRect.y; 862 | if (forceUpdate || !isInit) 863 | { 864 | offsetX = pagePerLayer; 865 | offsetY = pagePerLayer; 866 | } 867 | offsetX = Mathf.Clamp(offsetX,-pagePerLayer,pagePerLayer); 868 | offsetY = Mathf.Clamp(offsetY,-pagePerLayer,pagePerLayer); 869 | if (offsetX >= 0) 870 | { 871 | for (int x = 0; x < offsetX; x++) 872 | { 873 | for (int y = 0; y < pagePerLayer; y++) 874 | { 875 | int localX = (x + layer.rectOffset.x) % pagePerLayer; 876 | int localY = (y + layer.rectOffset.y) % pagePerLayer; 877 | int pageIndex = localX + localY * pagePerLayer; 878 | var p = pages[pageIndex]; 879 | int newLocalX = pagePerLayer - offsetX + x; 880 | p.gx = layerMinX + newLocalX; //new global X 881 | AddLoadPadge(p); 882 | } 883 | } 884 | } 885 | else // offSetX <= 0 886 | { 887 | for (int x = pagePerLayer + offsetX; x < pagePerLayer; x++) 888 | { 889 | for (int y = 0; y < pagePerLayer; y++) 890 | { 891 | int localX = (x + layer.rectOffset.x) % pagePerLayer; 892 | int localY = (y + layer.rectOffset.y) % pagePerLayer; 893 | int pageIndex = localX + localY * pagePerLayer; 894 | var p = pages[pageIndex]; 895 | int newLocalX = x- (pagePerLayer + offsetX); 896 | p.gx = layerMinX + newLocalX; 897 | AddLoadPadge(p); 898 | } 899 | } 900 | } 901 | 902 | if (offsetY >= 0) 903 | { 904 | for (int y = 0; y < offsetY; y++) 905 | { 906 | for (int x = 0; x < pagePerLayer; x++) 907 | { 908 | int localX = (x + layer.rectOffset.x) % pagePerLayer; 909 | int localY = (y + layer.rectOffset.y) % pagePerLayer; 910 | int pageIndex = localX + localY * pagePerLayer; 911 | var p = pages[pageIndex]; 912 | int newLocalY = pagePerLayer - offsetY + y; 913 | p.gy = layerMinY + newLocalY; 914 | AddLoadPadge(p); 915 | } 916 | } 917 | } 918 | else // offSetY <= 0 919 | { 920 | for (int y = pagePerLayer + offsetY; y < pagePerLayer; y++) 921 | { 922 | for (int x = 0; x < pagePerLayer; x++) 923 | { 924 | int localX = (x + layer.rectOffset.x) % pagePerLayer; 925 | int localY = (y + layer.rectOffset.y) % pagePerLayer; 926 | int pageIndex = localX + localY * pagePerLayer; 927 | var p = pages[pageIndex]; 928 | int newLocalY = y- (pagePerLayer + offsetY); 929 | p.gy = layerMinY + newLocalY; 930 | AddLoadPadge(p); 931 | } 932 | } 933 | } 934 | 935 | if (!isInit) 936 | { 937 | layer.rectOffset.x = 0; 938 | layer.rectOffset.y = 0; 939 | } 940 | else 941 | { 942 | layer.rectOffset.x += offsetX; 943 | layer.rectOffset.y += offsetY; 944 | } 945 | 946 | if(layer.rectOffset.x<0) 947 | layer.rectOffset.x = layer.rectOffset.x % pagePerLayer + pagePerLayer; 948 | if(layer.rectOffset.y<0) 949 | layer.rectOffset.y = layer.rectOffset.y % pagePerLayer + pagePerLayer; 950 | layer.prevRectXY = new RectInt(layerMinX, layerMinY,layerMaxX,layerMaxY); 951 | float pageMinPX = layerMinX * layer.pixelSize; 952 | float pageMinPY = layerMinY * layer.pixelSize; 953 | 954 | float pageMaxPX = (layerMaxX+1) * layer.pixelSize; 955 | float pageMaxPY = (layerMaxY+1) * layer.pixelSize; 956 | clipmapLayerRectArray[i] = new Vector4(pageMinPX, pageMinPY, pageMaxPX, pageMaxPY); 957 | float clipXS = 1f/ (layer.pixelSize * pagePerLayer); 958 | float clipYS = 1f/ (layer.pixelSize); 959 | float clipTX = -layerMinX / (float)pagePerLayer; 960 | float clipTY = -layerMinY / (float)pagePerLayer; 961 | clipmapLayerUVSTArray[i] = new Vector4(clipXS, clipYS, clipTX, clipTY); 962 | if (!anyLayerUpdate) 963 | anyLayerUpdate = true; 964 | } 965 | } 966 | 967 | if (anyLayerUpdate|| true) 968 | { 969 | var mat = terrain.materialTemplate; 970 | Shader.SetGlobalVectorArray(_CLIPMAP_LAYER_RECT_ARRAY, clipmapLayerRectArray); 971 | Shader.SetGlobalVectorArray(_CLIPMAP_LAYER_UVST_ARRAY, clipmapLayerUVSTArray); 972 | } 973 | if(!isInit) 974 | isInit = true; 975 | } 976 | 977 | ``` 978 | 979 | ### 3.更新间接贴图 980 | 981 | 通过上述步骤知道更新的Page后,直接调度Job来更新间接贴图。 982 | 983 | ```csharp 984 | void ScheduleJob() 985 | { 986 | for (int i = 0; i < layers.Length; i++) 987 | { 988 | var layer = layers[i]; 989 | if(layer.needUpdate == false) 990 | continue; 991 | var job = new IndirectTexColorJob() 992 | { 993 | colors = layer.colors, 994 | layerRect = layer.prevRectXY, 995 | pagesDatas = layer.pageJobDatas, 996 | pageWidth = pageWidth, 997 | pagePerLayer = pagePerLayer 998 | }; 999 | layer.colorJobHandle = job.Schedule(layer.pages.Length,layer.pages.Length); 1000 | } 1001 | } 1002 | 1003 | struct IndirectTexColorJob : IJobParallelFor 1004 | { 1005 | [WriteOnly] 1006 | public NativeArray colors; 1007 | [ReadOnly] 1008 | public NativeArray pagesDatas; 1009 | public RectInt layerRect; 1010 | public int pagePerLayer; 1011 | public int pageWidth; 1012 | public void Execute(int index) 1013 | { 1014 | var pageData = pagesDatas[index]; 1015 | int x = pageData.virturalPageIndex.x - layerRect.x; 1016 | int y = pageData.virturalPageIndex.y - layerRect.y; 1017 | int pageIndex = y*pagePerLayer + x; 1018 | byte phyBlockIndexX = (byte)(pageData.physicalRect.x / pageWidth) ; //8bit 1019 | byte phyBlockIndexY = (byte)(pageData.physicalRect.y / pageWidth) ; //8bit 1020 | byte phyTexArrayIndex = pageData.physicalIndex ; // 纹理数组Index 1021 | colors[pageIndex] = new Color(phyBlockIndexX/255f,phyBlockIndexY/255f,1f,phyTexArrayIndex/255f); 1022 | } 1023 | } 1024 | 1025 | ``` 1026 | 1027 | 在Job更新的同时,我们可以并行开始更新Physical Texture,物理贴图更新步骤和RVT部分大同小异,不再赘述了。Physical Texture更新完成后,就可以同步Job的数据,填充给TextureArray了。 1028 | 1029 | ![](https://pic4.zhimg.com/v2-37bc97e4518d8d630e58a5b05ca88cb9_1440w.jpg) 1030 | 1031 | 调度Job 1032 | 1033 | ![](https://pic4.zhimg.com/v2-3bdc71cf4d1cbecee049ef60ab9df58d_1440w.jpg) 1034 | 1035 | 更新Indirection Texture 1036 | 1037 | ### 4.渲染Clipmap Texture 1038 | 1039 | 采样的方法很简单,先确定像素在哪个Layer上,然后采样对应Layer的 Indriection Texture Index。 1040 | 1041 | ```glsl 1042 | #define MAX_LAYER_COUNT 6 1043 | 1044 | float4 _CLIPMAP_LAYER_RECT_ARRAY[MAX_LAYER_COUNT]; 1045 | float4 _CLIPMAP_LAYER_UVST_ARRAY[MAX_LAYER_COUNT]; 1046 | 1047 | void SampleClipmapVT(float2 positionSS,float2 uv, inout half3 diffuse,inout half3 normal) 1048 | { 1049 | 1050 | float2 px = uv * _VTParam.y; 1051 | int layer = MAX_LAYER_COUNT - 1; 1052 | float4 rect; 1053 | //判断所属Layer 1054 | for(int i=0;i= rect.xy && px <= rect.zw)) 1058 | { 1059 | layer = i; 1060 | break; 1061 | } 1062 | } 1063 | layer = min(layer, _ClipmapParm.y);//_ClipmapParm.y是实际Layer数量,防止越界 1064 | float4 indirectUVST =_CLIPMAP_LAYER_UVST_ARRAY[layer]; 1065 | float2 indirectUV = px * indirectUVST.xx +indirectUVST.zw; 1066 | float4 physicalData = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTIndirectTexArray, sampler_VTIndirectTexArray, indirectUV,layer,0) * 255.5; 1067 | int2 phyBlockInexXY = physicalData.xy; 1068 | int pageWidth = _VTPhysTexParma.x; 1069 | float2 tileUV = px * indirectUVST.y; 1070 | tileUV = tileUV - floor(tileUV);//取余 1071 | tileUV = tileUV * _VTPaddingParam.xy + _VTPaddingParam.zw;//padding 1072 | float2 physicalUV = (tileUV * pageWidth + phyBlockInexXY * pageWidth) * _VTPhysTexParma.y; 1073 | float4 color = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTDiffuse, sampler_VTDiffuse, physicalUV, physicalData.w, 0); 1074 | #ifdef _NORMALMAP 1075 | normal = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTNormal, sampler_VTNormal, physicalUV, physicalData.w, 0).xyz * 2.0 -1.0; 1076 | #endif 1077 | 1078 | diffuse = color.xyz; 1079 | } 1080 | ``` 1081 | 1082 | 这样Clipmap Texture就大致完成了 1083 | 1084 | ![](https://pic2.zhimg.com/v2-7d1190b75987a874b90f54190ab64165_1440w.jpg) 1085 | 1086 | Clipmap Texture地型 1087 | 1088 | 纹理过滤 1089 | ---- 1090 | 1091 | ### 1.双线插值 1092 | 1093 | 因为PhysicalTexture是多张Page并存的,当采样边界时如果没有没有Padding边界就会错误采样到隔壁的Page,导致错误。 1094 | 1095 | 一种解决方法就是加的边界,即Page如果是128加入4边界后是132。 1096 | 1097 | 本文采样另外一种方法是UV缩放来解决,这招之前ProbeBaseGI文章已经用过。 1098 | 1099 | ```text 1100 | //生成Page时候缩小 1101 | float2 scalePadding = ((_BlitPaddingSize.xy + float(_BlitPaddingSize.z)) / _BlitPaddingSize.xy); 1102 | float2 offsetPadding = (float(_BlitPaddingSize.z) / 2.0) / (_BlitPaddingSize.xy + _BlitPaddingSize.z); 1103 | uv = (uv - offsetPadding) * scalePadding; 1104 | 1105 | 1106 | //采样时候放大回来 1107 | float2 scalePadding = ((_BlitPaddingSize.xy + float(_BlitPaddingSize.z)) / _BlitPaddingSize.xy); 1108 | float2 offsetPadding = (float(_BlitPaddingSize.z) / 2.0) / (_BlitPaddingSize.xy + _BlitPaddingSize.z); 1109 | uv = uv / scalePadding + offsetPadding; 1110 | ``` 1111 | 1112 | ### 2.三线性插值 1113 | 1114 | 三线性指Mipmap之间的插值。 1115 | 1116 | 一种方案利用Dither噪点采样,省性能但有噪点,配合TAA可以把噪点干掉。 1117 | 1118 | ![](https://pic3.zhimg.com/v2-684befc4c19e2bf8799fd65680de98a6_1440w.jpg) 1119 | 1120 | 这里我把Mipmap的精度差异放大,方便观察效果。 1121 | 1122 | ![](https://pica.zhimg.com/v2-701ea984492d1abc827156e3e4db4466_1440w.jpg) 1123 | 1124 | 左:Clipmap过滤区域 右:噪点伪过度 1125 | 1126 | 另一种是老老实实采样两次(mip和mip+1)VT做插值: 1127 | 1128 | ![](https://pica.zhimg.com/v2-3f1adcbafd6f46a723986ea8b2e18f76_1440w.jpg) 1129 | 1130 | ### 3.各项异性过滤 1131 | 1132 | TODO 1133 | 1134 | VT压缩 1135 | ---- 1136 | 1137 | 为了提高渲染效率,Physical Texture可以压缩成对应平台的格式,所以我们把Physical Texture设置成TextureArray压缩效率也更高了,只需对应更新过的单层做压缩。 1138 | 1139 | 本文压缩使用的是UE源码中的ETCCompressionCommon.ush 和 BCCompressionCommon.ush,然后使用的PS 来压缩。需要注意的是格式需要时Uint的。 1140 | 1141 | ![](https://pic2.zhimg.com/v2-96f017f6da85b5e2703705f63580d3f3_1440w.jpg) 1142 | 1143 | ![](https://picx.zhimg.com/v2-523201f6dcfd204c11bbdb99a873b07b_1440w.jpg) 1144 | 1145 | ![](https://pic3.zhimg.com/v2-66e9396e9d57ae3dcb559cef84f1a4e6_1440w.jpg) 1146 | 1147 | 还有如果用PS来压缩,采样中心在原点,4x4压缩,需要-1.5回起始点。 1148 | 1149 | ![](https://pic1.zhimg.com/v2-0f08bde7099af2159e794e4a541b8ad6_1440w.jpg) 1150 | 1151 | 压缩后可以通过Renderdoc和FramgeDebuger看到贴图是BC7的了。 1152 | 1153 | ![](https://pica.zhimg.com/v2-f2774fa96a679d8d131929dfabf00e1a_1440w.jpg) 1154 | 1155 | ![](https://pica.zhimg.com/v2-d65222758664b31ad515948a7617c7e0_1440w.jpg) 1156 | 1157 | 总结 1158 | -- 1159 | 1160 | ![](https://picx.zhimg.com/v2-6dcf2f8bd1b9c9e5f751eb98b1c2c5df_1440w.jpg) 1161 | 1162 | SVT可以解决纹理对内存压力, 1163 | 1164 | RVT可以为程序纹理生成缓存。 1165 | 1166 | 不得不说整套下来涉及的工程细节真的太多了。 1167 | 1168 | 本文仅实现了RVT,想过去SVT似乎也可以实现,把图片切割后打成一个Assetbundle,通过LoadObject("page\_"+N)的方式来管理。 1169 | 1170 | ### 参考 1171 | 1172 | GPU Pro1 :Virtual Texture Mapping 1173 | 1174 | GPU Pro7 :Adaptive Virtual Textures 1175 | 1176 | [Terrain Rendering in 'Far Cry 5'](https://link.zhihu.com/?target=https%3A//gdcvault.com/play/1025480/Terrain-Rendering-in-Far-Cry) 1177 | 1178 | [Terrain in Battlefield3](https://link.zhihu.com/?target=https%3A//www.gamedevs.org/uploads/battlefield-3-terrain.pdf) 1179 | 1180 | [Chen Ka AdaptiveVirtualTexture](https://link.zhihu.com/?target=https%3A//ubm-twvideo01.s3.amazonaws.com/o1/vault/gdc2015/presentations/Chen_Ka_AdaptiveVirtualTexture.pdf) 1181 | 1182 | [The Clipmap: A Virtual Mipmap](https://link.zhihu.com/?target=https%3A//notkyon.moe/vt/Clipmap.pdf) 1183 | 1184 | [Software-Virtual-Textures](https://link.zhihu.com/?target=https%3A//notkyon.moe/vt/Software-Virtual-Textures.pdf) 1185 | 1186 | [Virtual Texture(虚拟纹理)的理解和应用 | Epic 李文磊](https://link.zhihu.com/?target=https%3A//www.bilibili.com/video/BV1KK411L7Rg/%3Fspm_id_from%3D333.337.search-card.all.click%26vd_source%3D07d4f665c85998941c935676c2e50d81) -------------------------------------------------------------------------------- /Unity技术美术日记——多种方式实现布料飘动 - 知乎.md: -------------------------------------------------------------------------------- 1 | # Unity技术美术日记——多种方式实现布料飘动 - 知乎 2 | 0\. 前言 3 | ------ 4 | 5 | 在游戏制作中经常会遇到一个问题:在茫茫多的技术方案中如何选择最适合项目的方案? 6 | 7 | 我总结了以下几种我自己做过的技术方案(本文将不考虑常规的绑定蒙皮K动画的布料方案) 8 | 9 | 1.[顶点动画](https://zhida.zhihu.com/search?content_id=239920798&content_type=Article&match_order=1&q=%E9%A1%B6%E7%82%B9%E5%8A%A8%E7%94%BB&zhida_source=entity) 10 | 11 | 2.Magica Cloth 12 | 13 | 3.VAT (Vertex Animation Texture) 14 | 15 | **1\. 顶点动画** 16 | ------------ 17 | 18 | 顶点动画的方案在网上有很多,一般指通过顶点着色器对模型每个顶点进行位移操作从而模拟不同的运动效果。在游戏中遇到大量顶点需要进行规则或有规律的运动时,顶点动画就是一种非常有效率的技术方案 19 | 20 | 首先我们给旗面绘制顶点色,控制旗子飘动的区域 21 | 22 | ![](https://pic1.zhimg.com/v2-55000b66718f22c5ba6d0734ec61f6c2_1440w.jpg) 23 | 24 | 首先我们先对旗子本身添加一个简单的自飘动效果 25 | 26 | ```glsl 27 | //自身的顶点位移 28 | half sinOff = positionOS.x + positionOS.y + positionOS.z; 29 | half t = -_Time.x * 50; 30 | 31 | #if defined(_Swing_ON)//自摆动keyword 32 | positionOS.x += sin(t * 1.45 + sinOff) * vertexColor.r * (_ClothAmplitudeX * 0.5); 33 | positionOS.z += sin(t * 2 + sinOff) * vertexColor.r * (_ClothAmplitudeY * 0.5 ) * 0.3; 34 | #endif 35 | 36 | //给一个固定位移值,防止旗子与旗杆穿插穿帮 37 | positionOS.x -= vertexColor.r * _xOffset; 38 | positionOS.z -= vertexColor.r * _yOffset; 39 | ``` 40 | 41 | 让我们来看看效果: 42 | 43 | ![](https://pica.zhimg.com/v2-b25ce5a5479f3de31d1fb55fe5918f2e_b.gif) 44 | 45 | 基础的效果有了,但当存在多个搭配同材质球的旗子一起出现时会出现飘动轨迹完全一样的效果,会显得重复感太强: 46 | 47 | ![](https://picx.zhimg.com/v2-cd22b554380b0d213e95742784a325bb_b.gif) 48 | 49 | 因此我们来给旗子再加一层额外的基于世界空间的顶点位移: 50 | 51 | ```glsl 52 | //基于世界空间的顶点位移 53 | float3 worldPos = TransformObjectToWorld(positionOS.xyz); 54 | 55 | float2 sampleUV = float2(worldPos.x *rcp(_WaveSize) , worldPos.z *rcp(_WaveSize)); 56 | sampleUV.x+= _Time.x ; 57 | 58 | //用一张noise贴图扰动世界空间的顶点位移 59 | float4 windTex = tex2Dlod(_NoiseRipplingMap, float4(sampleUV,0,0)); 60 | half summedBlend = 0.33 * windTex.r + 0.33 * windTex.g + 0.33 * windTex.b ; 61 | worldPos += sin(0.1 * summedBlend) * _WindOffset.rgb * vertexColor.r; 62 | 63 | positionOS.xyz = TransformWorldToObject(worldPos.xyz); 64 | ``` 65 | 66 | 在Substance Design里找了一张Noise贴图用来扰动世界空间的顶点位移效果 67 | 68 | ![](https://pic2.zhimg.com/v2-552df4a4cf21421a1d96aff47d8455e3_1440w.jpg) 69 | 70 | 最终效果如图: 71 | 72 | ![](https://picx.zhimg.com/v2-06e6369d941070c60dd2adf80bc94631_b.gif) 73 | 74 | 完整HLSL代码如下: 75 | 76 | ```csharp 77 | void VertexMove_Cloth(half4 vertexColor,half2 uv,inout half4 positionOS, half3 normalOS) 78 | { 79 | #if defined (Cloth_Render) 80 | 81 | //自身的顶点位移 82 | half sinOff = positionOS.x + positionOS.y + positionOS.z; 83 | half t = -_Time.x * 50; 84 | 85 | #if defined(_Swing_ON) 86 | positionOS.x += sin(t * 1.45 + sinOff) * vertexColor.r * (_ClothAmplitudeX * 0.5); 87 | positionOS.z += sin(t * 2 + sinOff) * vertexColor.r * (_ClothAmplitudeY * 0.5 ) * 0.3; 88 | #endif 89 | 90 | positionOS.x -= vertexColor.r * _xoffset; 91 | positionOS.z -= vertexColor.r * _yoffset; 92 | 93 | //基于世界空间的风力效果 94 | float3 worldPos = TransformObjectToWorld(positionOS.xyz); 95 | 96 | float2 sampleUV = float2(worldPos.x *rcp(_WaveSize) , worldPos.z *rcp(_WaveSize)); 97 | sampleUV.x+= _Time.x ; 98 | 99 | //用一张noise贴图扰动全局风力 100 | float4 windTex = tex2Dlod(_NoiseRipplingMap, float4(sampleUV,0,0)); 101 | half summedBlend = 0.33 * windTex.r + 0.33 * windTex.g + 0.33 * windTex.b ; 102 | worldPos += sin(0.1 * summedBlend) * _WindOffset.rgb * vertexColor.r; 103 | 104 | positionOS.xyz = TransformWorldToObject(worldPos.xyz); 105 | 106 | #endif 107 | } 108 | 109 | ``` 110 | 111 | P.S. 如果觉得旗子的飘动比较规律,可以更改用到Sin函数的地方,使其飘动距离变得不规律 112 | 113 | ![](https://picx.zhimg.com/v2-a4d8fe15494398b2df0ed99a233cf5bd_1440w.jpg) 114 | 115 | y = Sin(a \* x) + Sin (x) 116 | 117 | 2.Magica Cloth 118 | -------------- 119 | 120 | Magica Cloth 是一种由 Unity DOTS(面向数据的技术堆栈)提供支持的快速布料模拟。 它可用于变换和网格,BoneCloth 控制变换,MeshCloth 控制网格顶点。Unity内置Cloth组件和MagicaCloth这两种方法的在该案例下的美术表现基本一样,区别是Magica Cloth参数配置更简单,更适合新手上手学习,开发效率更高。因此本次我们就简单介绍MagicaCloth的制作方式和效果。 121 | 122 | 事实上MagicaCloth已经有很完善的官方技术文档了,可以很轻松的学习如何开始制作动态布料和每一个参数的具体含义。 123 | 124 | 首先先把这五个组件加上,从上到下分别是:Magica Render Deformer,Magica Virtual Deformer,Magica Mesh Cloth,Magica Capsule Collider和Magica Directional Wind。组件的具体功能在此[Component - Magica Soft](https://link.zhihu.com/?target=https%3A//magicasoft.jp/en/magicaclothcomponent-2/) 125 | 126 | ![](https://pic3.zhimg.com/v2-45f7d954d218c735c575d726ee6b79c2_1440w.jpg) 127 | 128 | 分别点击Magica Render Deformer和Magica Virtual Deformer内的Create来获取模型顶点数据,注意模型需要打开读写才能被Magica组件获取到数据。 129 | 130 | 接下来点击Magica Mesh Cloth组件里的Start Point Selection开始为模型设置固定点,并为模型调整它的物理属性参数。 131 | 132 | ![](https://pic1.zhimg.com/v2-695955669a27ece60edf12bc0cb4aaa8_1440w.jpg) 133 | 134 | ![](https://pic2.zhimg.com/v2-7c9edc3188644919590455755b433ffd_1440w.jpg) 135 | 136 | 为了防止旗面穿过旗杆,我们用Magica Capsule Collider来覆盖旗杆的模型,并在最后调整Magica Directional Wind组件中的风力方向和强度,最终效果如下 137 | 138 | ![](https://pic3.zhimg.com/v2-66024f6ebec9919c8d398a5b484dace4_b.gif) 139 | 140 | 其实能很明显的看到使用Magica Cloth插件模拟的布料效果相比第一种顶点动画要更真实,更像真正的布料,Magica Cloth提供了大量的参数,满足了不同对物理属性物体的模拟需求。理论上DynamicBone也可以做出类似的效果,但其技术原理更接近于常规的骨骼绑定蒙皮,因此不做过多介绍。 141 | 142 | 3.VAT (Vertex Animation Texture) 143 | -------------------------------- 144 | 145 | Magica虽然更真实效果更好,但相比于顶点动画来说性能消耗也是大大提高的,哪有没有一种办法既可以满足高品质的布料飘动效果并且在性能上也有所优化呢? 146 | 147 | 虽然游戏是实时渲染的艺术,但并非所有的物体都需要实时渲染,VAT就是一种把离线渲染融合进游戏的渲染技术,这是一种用空间换时间的做法,其运动的技术原理和第一节提到的顶点动画基本相同,只不过他将实时计算的顶点位置存到一张图里,相当于预烘焙了顶点动画。 148 | 149 | 本节我将使用[Houdini](https://zhida.zhihu.com/search?content_id=239920798&content_type=Article&match_order=1&q=Houdini&zhida_source=entity)+Unity+VAT的流程来制作旗帜飘动的效果,本文中使用的houdini版本是20.0.547。 150 | 151 | 首先我们先在houdini里制作旗帜飘动的效果: 152 | 153 | **1.布料模拟** 154 | 155 | 将模型导入后,先给旗子添加一个group,方便后续制作风力动画时固定这部分顶点 156 | 157 | ![](https://pic4.zhimg.com/v2-bec49fdc65bfdb386a0192e47f871d51_1440w.jpg) 158 | 159 | 为了表现布料细微的褶皱表现,需要对原有模型进行加面,这里remesh一下模型。 160 | 161 | ![](https://pic4.zhimg.com/v2-0f31af91f6e6152e8587823b3b679e51_1440w.jpg) 162 | 163 | 接下来开始模拟布料的运动轨迹,首先先把vellum相关节点加上,在vellum Constraints里来调整一下mesh的相关属性,并在Pin to Animation里把我们之前准备好的Group加上,用于固定顶点。 164 | 165 | 一些布料参数可以参考: 166 | 167 | ![](https://pic1.zhimg.com/v2-f8683dfb33345c75fda530d15b50cc1e_1440w.jpg) 168 | 169 | 然后在solver里调整一下风力的相关参数,不断调试到我们想要的样子 170 | 171 | ![](https://pic1.zhimg.com/v2-3a4a1b54f539b0537566418b2262d51c_1440w.jpg) 172 | 173 | 做到这步时发现了一个问题,当我们循环播放这段动画时,动画的首帧和尾帧无法很好的过渡,也就是无法做成循环动画 174 | 175 | ![](https://pica.zhimg.com/v2-98b64803575b5ac11d1c335c6e63befc_b.gif) 176 | 177 | 因此我们需要为其加个过度节点,这里我们用一个自定义节点来为首尾帧做插值[\[1\]](#ref_1) 178 | 179 | ![](https://picx.zhimg.com/v2-f656112a38840bfa05c695513e688b7d_1440w.jpg) 180 | 181 | 输入端我们来定义选用哪帧为首尾帧,和插值迭代次数 182 | 183 | ![](https://pic3.zhimg.com/v2-2deb5c77b21fff26a5bc69a9cdb08258_1440w.jpg) 184 | 185 | 看一下结果 186 | 187 | ![](https://pic4.zhimg.com/v2-98b645a003810cc3b57a92eeb8a06b3d_b.gif) 188 | 189 | **2.Unity导入** 190 | 191 | 接下来我们来导入到unity,首先在后面添加一个out节点,作为整段模拟的输出节点 192 | 193 | ![](https://pic2.zhimg.com/v2-90da398d33d41585e429a6314d38be19_1440w.jpg) 194 | 195 | houdini已经为VAT提供了简化的导入引擎流程,首先要将Houdini的[VertexAnimationTexture](https://zhida.zhihu.com/search?content_id=239920798&content_type=Article&match_order=1&q=VertexAnimationTexture&zhida_source=entity)的Package导入到Unity引擎中,具体方式可以看节点里的guide 196 | 197 | ![](https://pic1.zhimg.com/v2-111ecffc7b5055a9eab7f2b6f7bea51c_1440w.jpg) 198 | 199 | Unity导入正常后,需要在VertexAnimationTexture的节点里设置我们导入的节点,和导出的资源位置 200 | 201 | ![](https://pic1.zhimg.com/v2-988d0c09fd12bbc2cf0e0901c23d7226_1440w.jpg) 202 | 203 | 导出后我们会得到三个文件夹,分别是模型,贴图和材质球,把它们导入到unity内。 204 | 205 | ![](https://pic3.zhimg.com/v2-39da1c23e6e3887d80ea1382f5575854_1440w.jpg) 206 | 207 | 根据我们导出的图,可以看到图的高度像素数正好等于我们的帧数(1680-60),也就是说高度代表动画总帧数,宽度代表我们旗帜的顶点数,像素内的RGB值则代表了坐标轴的XYZ偏移值,这样通过一张图我们就得到了每一个顶点在每一帧的偏移值。 208 | 209 | ![](https://picx.zhimg.com/v2-09bb3a1e8ffbc0d55e986f67357c6887_1440w.jpg) 210 | 211 | **3.在Unity中实现** 212 | 213 | 根据houdini提供的说明文档,先将刚导入的贴图和模型的import setting按照下图设置好 214 | 215 | ![](https://pic4.zhimg.com/v2-987d43c137a250fa3285c65b75f5afdb_1440w.jpg) 216 | 217 | 接下来就简单了,把houdini提供给我们的贴图和材质球结合起来,可以看到在导出的材质球里,顶点动画的最大最小值已经设置好了 218 | 219 | ![](https://pica.zhimg.com/v2-a615ab1bd7970e0233d91fa4a974a12e_1440w.jpg) 220 | 221 | 在材质面板上并未发现basemap和法线贴图的位置,根据houdini提供的shader我们稍作修改,最终结果如下 222 | 223 | ![](https://pic1.zhimg.com/v2-394e5fc543a94e1a94c00af1e2aa2f30_b.gif) 224 | 225 | P.S. 搞个碰撞玩一下:) 226 | 227 | ![](https://pic3.zhimg.com/v2-c909e70761505f1d634f48aba812488a_b.gif) 228 | 229 | **结语** 230 | ------ 231 | 232 | 游戏中的技术在实现的过程中都有很多种备选方案,在本文中我尝试了三种方式实现布料模拟,不同的实现方式有不同的优点: 233 | 234 | 画面表现(从好到坏):VAT ≥ Magica Cloth > 顶点动画 235 | 236 | 性能表现(以帧耗时为参考依据,从低到高):顶点动画 > VAT ≥ Magica Cloth 237 | 238 | 效果调整难度(从易到难):顶点动画 > Magica Cloth >>> VAT 239 | 240 | 对模型面数的需求(从低到高):顶点动画 > Magica Cloth >>> VAT 241 | 242 | 另外不同技术还有许多可以扩展的方向,比如Magica Cloth对于游戏过程中进行实时布料模拟会有更高的适配性[\[2\]](#ref_2) 243 | 244 | ![](https://picx.zhimg.com/v2-cabeab474f1218996512748c2245dbed_b.gif) 245 | 246 | VAT也可以做除了布料模拟以外的顶点动画[\[3\]](#ref_3) 247 | 248 | ![](https://picx.zhimg.com/v2-43fd80a181548e699a40cf0a1ba7a453_b.gif) 249 | 250 | ![](https://pic1.zhimg.com/v2-2956adc805f29382c0bbabf30412d5a0_b.gif) 251 | 252 | 本文仅实现了基础的工作流,在美术效果上还有很大的优化空间。总之,根据项目需求选择最合适的技术方案才是最重要的。 253 | 254 | 参考 255 | -- 256 | 257 | 1. [^](#ref_1_0)过度节点参考 [https://blog.csdn.net/u013412391/article/details/120439373](https://blog.csdn.net/u013412391/article/details/120439373) 258 | 2. [^](#ref_2_0)MagicaCloth案例 [https://www.youtube.com/watch?time\_continue=83&v=dPJYg8kTZjM&embeds\_referring\_euri=https%3A%2F%2Fwww.gameassetdeals.com%2F&source\_ve\_path=MTM5MTE3LDEzOTExNywyODY2MywxMzc3MjEsMzY4NDIsMzY4NDIsMzY4NDIsMjg2NjY&feature=emb\_logo](https://www.youtube.com/watch?time_continue=83&v=dPJYg8kTZjM&embeds_referring_euri=https%3A%2F%2Fwww.gameassetdeals.com%2F&source_ve_path=MTM5MTE3LDEzOTExNywyODY2MywxMzc3MjEsMzY4NDIsMzY4NDIsMzY4NDIsMjg2NjY&feature=emb_logo) 259 | 3. [^](#ref_3_0)VAT案例 [https://github.com/Bonjour-Interactive-Lab/Unity3D-VATUtils?tab=readme-ov-file](https://github.com/Bonjour-Interactive-Lab/Unity3D-VATUtils?tab=readme-ov-file) -------------------------------------------------------------------------------- /imgs/clipper-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/clipper-setting.png -------------------------------------------------------------------------------- /imgs/collect-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/collect-success.png -------------------------------------------------------------------------------- /imgs/collect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/collect.png -------------------------------------------------------------------------------- /imgs/githubtoken-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/githubtoken-setting.png -------------------------------------------------------------------------------- /imgs/githubtoken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/githubtoken.png -------------------------------------------------------------------------------- /imgs/import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/import.png -------------------------------------------------------------------------------- /imgs/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/success.png -------------------------------------------------------------------------------- /imgs/webclipper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killop/notebooks/57f8f642bd5457a233a28a20a397f962de6711e5/imgs/webclipper.png -------------------------------------------------------------------------------- /游戏分包- 知乎.md: -------------------------------------------------------------------------------- 1 | # (99+ 封私信 / 41 条消息) 夜莺 - 知乎 2 | 大概三年前,厂子里一个项目由于买量过高,急需通过降低游戏初始包体大小来提高下载转换率,要求将出初始包控制在300MB以下(整包大致是2G+),并实现一个分包下载功能;这活跨度较长,中间断断续续,经历了2个大阶段,虽然现在来看有些做法已经过时,但整个过程感觉还是蛮有意义的,遂记录下来。 3 | 4 | 阶段一、接入第三方分包下载功能 5 | --------------- 6 | 7 | 商务同事接触到一个第三方厂子专门做分包下载(下面简称X变),有一套完整的SDK下载功能、以及后台管理(甚至是静默下载),非常专业;本着能买现成就别自己做的原则,厂子里准备签合同接入;初始是由另一个同事负责接入SDK,但中途老哥去负责其他事情,就让本人去接手。 8 | 9 | 本以为只是接入一个SDK,了解了产品同事的需求,才知道是想借X变提供的功能实现类似AFK1的边玩边下功能;感受了下AFK1的边玩边下,大致看到和猜测有以下一些功能组成: 10 | 11 | 1. 初始包也进行会进行热更,不知是在进一步补全初始包资源还是对齐最新版本资源; 12 | 2. 初始包进入后,自动开始后台下载,切wifi自动开启下载,切流量自动停止,前台界面可查看进入; 13 | 3. 打开某个界面或某个场景时,能提前检查该界面涉及的资源是否齐备,不齐备就弹出全屏转圈圈即时下载完整,之后再弹出完整界面; 14 | 4. 当进入某个界面时,界面上的图片是一个一个加载出来的,此现象只在初始包未下载完整时观察到,猜测此时也发起了零散的资源下载。 15 | 16 | 经过讨论与产品同事确定了**两个大目标**: 17 | 18 | * **目标1:包体大小低于300M** 19 | 20 | 一开始要求能低于iOS的200M大小,以触发无提示的流量下载;但要做到的话,包体其实只能有150M左右,要求太严格了,且难以维护,经过掰头可以在300M上下; 21 | 22 | * **目标2:新玩家顺畅玩到触发首充的主线关卡** 23 | 24 | 市场基本就是看导量到首充付费率这个数据是否有变化,来判断这次小包的体验是否有保持以往水准,所以分界点就是主线触发首充这个点。 25 | 26 | **两个大原则**: 27 | 28 | * **侧重新玩家的体验** 29 | 30 | 根据AFK1的做法,他们不是单纯的后台下载,而是可以做到即玩即下(要玩什么内容下载什么内容),很明显的例子是一个老玩家高级号用初始包登录,他们也能做到相对较为顺畅的体验(这可能能侧面说明他们的资源粒度拆分、下载控制、资源解耦做得很好)。 31 | 32 | 而当下要接入的X变SDK,他们提供的服务还是一个后台下载功能,目的是尽快让玩家从初始包下载到完整包,开始完整的游戏体验;我们或许可以通过自己改造项目以达到AFK1的即玩即下体验,但对一个已上线项目的试错成本较大;更主要是产品同事的重点不在此,而在于降低买量成本,所以重点关注新玩家体验即可。最终产品的需求是,尽量在wifi下10~15分钟下完整包,此间保证新玩家体验新手流程的流畅。 33 | 34 | * **用于投放的小包能尽量稳定** 35 | 36 | 由于上线项目的版本更新很频繁,平均两周就一个版本,资源量也不少(平均50-100M新增),市场同事提及不能让新玩家用小包进入游戏,一下子还要进行多100M的热更,所以要求小包应该尽量稳定,少改动。 37 | 38 | ### 具体的策略和做法 39 | 40 | 分析了项目现状和竞品的手法,我与另一个老大哥制定了下策略,一起开搞: 41 | 42 | **策略1:区分前期体验和后期体验资源** 43 | 44 | 既然小包是应对新玩家,后期资源不需要的应该都往包外砍,新版本的资源也多是后期资源,也是尽量往包外砍;正常前期流程调整完,留存相对固定,不怎么会再变化(再随便调整前期流程,只有留存不好的情况),所以前期资源理应比较固定才对;我们初次出小包时,先跑了半小时的游戏流程,拿到一份资源清单(在资源底层加了AB包加载接口的日志,打印出所有加载成功的ab包路径);这份清单有两个用处: 45 | 46 | 1. 跑到首充的时间点的这部分资源清单,可用做初始小包的清单资源,以此为临界线,剩下的资源都放到包外下载; 47 | 2. 资源清单中的资源有先后下载顺序,在包外下载清单中也大致维持这部分顺序,使得小包运行时,后台下载的资源都是优先下载新玩家流程中可能最早会被用到的资源;这样还能进一步压缩小包大小(比如玩第一章时下第二章)。 48 | 49 | **策略2:砍比重大的资源、部分业务异步下载** 50 | 51 | 比重大资源其实就几块:3D模型跟UI,基本就超过50%了; 52 | 53 | X变有一个保底的底层资源判断,它应该是在原生层去Hook了Unity的底层下载API,如AssetBundle.LoadFromFile,当发现本地缺失正在加载的这个资源时,它会立即发起网络请求去远端下载;但此时整个游戏进程会处于僵死状态(画面卡主),可能会让玩家误以为是进程死了;如果下载的资源碎又多,就会感受到卡顿一阵一阵的,令人恶心; 54 | 55 | 为了防止这种情况尽量少出现,最好能自己去提前Hook提前检查资源的缺失,然后主动调用X变的下载接口去异步下载,下载完成后异步通知业务层继续业务;所以和老大哥讨论了下,在lua层(项目大部分业务逻辑在lua层)实现了一个简单的下载管理,包括下载任务管理(可包含多个资源文件、可回调、可中断),资源状态记录,各类同步异步下载接口、检查接口、调用X变的接口等。 56 | 57 | 结合项目现状,仿照AFK1等其他竞品,做了以下几种下载支持,包括以下机制: 58 | 59 | 1. **UI异步下载** 60 | 61 | 基本抄袭AFK1的做法,提前先去搜罗这个UI相关的所用到的AssetBundle包,去做异步下载;这个过程加一个转圈的UI遮罩给玩家看,下载完成后再把UI展示出来;这样这部分UI就可以从小包中剔除了,实际运行时再去下载; 62 | 63 | 遇到的问题: 64 | 65 | * 业务系统UI一般都有一个明确的主体prefab,其对应的较为明确的AssetBundle包和其依赖包,逻辑中能比较明确定位到要等待加载哪些AssetBundle包; 66 | * 有一部分资源是在UI逻辑代码中间接加载的,并无法通过第一步的方式拿到,我们没有去管这部分资源,而是在前期流程体验时,这部分资源被跑到了就被就到小包中,后续就算有缺,也是小部分资源,再根据同步下载的日志给加到小包问题不大; 67 | * 由于项目中的业务大部分都是同步的,且业务量大,项目也不接受都全部改装成异步;好在一部分UI打开并不需要等待操作返回,这部分逻辑刚好有一个通用的入口可以改造;综合下来大概改了30%的UI打开代码,但基本可以覆盖新手前期的大部分操作,也算是妥协处理了。 68 | 69 | **2、3D模型展示异步下载** 70 | 71 | 因为项目是卡片,3D模型基本都是通过卡面入口打开的,加上3D模型的资源较为集中,大部分引用也较为简单,将其归类起来做异步加载,具体做法也是进入卡面后,先展示场景(场景本身是异步加载的,所以好办),展示一个不会中断和隔离玩家操作的小转圈在3D场景中,玩家看不到3D模型,但是依然可以做各种界面操作;等资源都加载完,再把英雄的展示动画播放出来; 72 | 73 | 遇到的问题: 74 | 75 | * 除了3D模型外,比较棘手的是要找出其他资源,包括几块:3D高模、挂载特效、语音、展示时间轴(Timeline)以及其中所引用到的其他资源; 76 | * Timeline所引用的资源,通过实现生成一份配置文件(另外一个老哥负责动画的帮忙实现),以供运行时获取;该配置文件当Timeline资源被重新导入工程时会去重新生成。 77 | 78 | **3\. UI中的公用资源** 79 | 80 | * 比如卡面、物品、头像这种一般是放公用目录做图集,只能用笨办法,将前期会用到的资源单独一个目录,比如前期用到的卡面的AssetBundle包(如card\_early这种命名),前期用到的物品 item\_early 这种;只有early的包会被放到小包中,一般确定完就不怎么会变动了; 81 | * 其他的公用UI的资源,也只能往小包中塞;—— 这部分比较蛋疼的是要去解引用,去理清公用资源与业务系统UI资源之间的关系,这块下面有详细记录(**策略3:资源依赖拆分的部分**); 82 | * 个别UI中会根据逻辑切换的立绘、背景(大图)等;完成了上面的UI异步下载后,感觉有些界面的等待时间较长,也想有一个类似AFK1的分步下载机制;一起的老大哥结合项目的代码搞了一个机制:lua中xxx.sprite = "xxxx" 图片时,先用一个替代图片(肯定在小包中的资源,比如一个问号),然后让lua的UI对象记录这个组件的资源是临时的,后台异步去加载正式的图片资源;UI对象自己的update中每隔一段时间去检查这类组件所需的资源加载好了没有,如果好了,就把正确的图片替换上去;—— 实际机制使用起来问题不大,问题主要是替代资源只有两种,一种是不带问号,一种是带背景的问号,没有办法适配所有动态加载的头像、立绘、背景,拉伸会显得很糊很丑;加上其实这部分资源下载速度很快,机制实际应用不多(玩家也感知不怎么出来) 83 | 84 | **策略3:资源依赖解除和划分(细活)** 85 | 86 | 解引用分两部分,主要都是为了让小包中的资源保持稳定,不会因为随便做点版本更新,都往小包中引入一些新的资源,导致小包膨胀: 87 | 88 | **1、前后期资源解引用** 89 | 90 | * 之前提到的card.bundle拆分出一份card\_early.bundle就是其中一个例子;将前期有用的拆分出来; 91 | * 还有比如背景音乐,剧情音乐,前期战斗和剧情会用到,但是其实这部分展示时间是固定的(玩家不能控制),而这些bgm动不动上1分钟,完全没必要这么长;就将这类bgm截取一段断的,替换给前期资源使用,并丢小包中;除此之外还将其修改为单声道,这个也跟策划掰头了很久,因为音乐是花钱外包的,策划硬是觉得要给带耳机的玩家感受下双声道的区别。 92 | 93 | **2、公用、私有资源解引用** 94 | 95 | **(1)业务UI引用其他业务UI** 96 | 97 | * 之前由于项目规范执行不到位,各业务系统的UI经常会跨目录引用其他业务系统的UI,导致小包加载前期资源UI时,必须要加载其他莫名其妙引入进来的特殊业务UI的AssetBundle包(项目的AssetBundle包大部分是基于目录规则打包的);后面老大哥加了一个规则,就是业务功能UI某个业务目录中的prefab跨目录引用其他目录的资源,只能引用公用资源的UI(在save Prefab时做判断,有跨目录引用不给save成功)。 98 | * 不过还是有一些公用界面,因为一开始没有规划分块,导致也无法解开,比如战斗界面,比如卡面展示界面,其上硬是绑定了很多后期业务系统涉及的子界面节点,这块就先放弃了。—— 想来应该有更好的基于规则的划分方式,比如UI模板,比如一套自动的异步Prefab加载规则。 99 | 100 | **(2)战斗情景的资源整理** 101 | 102 | * 这里先做了一个事情就是把3D模型的高模和低模在打包时分开,这样上一步做3D模型展示时,不需要去下载低模和相关状态机(项目当时也没做AnimationController的override,这也算是一个可改进点);而做战斗场景下载时也不需要去下载高模和相关资源,下载的东西也会少一些; 103 | * 至于战斗场景中的低模,我们没有去做战报分析,提前把一场战斗用到的低模资源、音频、特效等都搜罗好,加转圈和异步下载完再去播放战报; 104 | * 战斗涉及的资源,唯一有做的事情就是在将其在包外下载清单的顺序调整到前面一些,因为差不多10-15分钟后玩家就会进入竞技场,而竞技场开始有各种3D战斗模型,如不提前下载,绝对会卡顿; 105 | 106 | **策略4:调整包外资源下载顺序进一步优化包体** 107 | 108 | 经过第二步的调整,可能包体还没发满足产品的大小要求,后面再做了一波极限操作,进一步把小包体验中后半部才会用到资源砍出来,这部分资源放到包外下载清单的最前面; 109 | 110 | 这样小包可能正在玩第一章时,后台正在优先下载第二章的资源,等到了第二章,相关的资源早就准备好了; 111 | 不过这个方法要实际测过才准,看砍到什么程度,玩家包体和体验才能大致平衡; 112 | 113 | **策略5:调整压缩策略** 114 | 115 | 这调整就多了,英雄贴图、UI纹理,音频、英雄动作文件等基本都跟策划美术扯皮了一遍、调整了一遍压缩策略,调整完基本就固定了;这样下来,原来包体从2G+砍到1.4G,小包也跟着缩小不少。最终终于勉强达到产品对包体的要求。 116 | 117 | ### 一些印象深刻的点 118 | 119 | 还是要说一说跟X变的合作:这里比较痛苦的点跟X变的沟通,以及在X变的通用方案下为了满足我厂需求的各种挣扎;可能因为X变没有遇到这种细致的定制需求(大部分厂商都是直接提供包体给X变跑,X变跑完给一个小包回给,然后厂商去投放),而我厂的需求实在太过细致和不一样: 120 | 121 | * X变帮忙跑包需要一定时间,还有档次,要提前预约;我厂要求分包后台开放上传清单功能,我们自己跑完首包资源自己上传清单自己分包... 122 | * X变的单文件下载之前没被猛调用过,或者说基本没啥厂商自己去调用(都是靠底层Hook缺资源直接回调),结果出了性能问题,有时还回调补全,还得优化一把.... 123 | * X变的已下载资源管理与我厂的热更资源管理是互不知晓,目录还是分开的,导致互相不知道可能会下载相同的文件,我厂要求给我个接口,可以让我对齐两个目录的资源,以确定热更究竟要下载哪些缺失资源...这里涉及到的项目的热更机制与X变的资源管理机制冲突,又是另一堆问题了.... 124 | 125 | 沟通中能感觉到业务人员有时较为烦躁(玛德需求怎么这么细这么龟毛,但是毕竟付了钱,不得不帮助解决问题) 126 | 127 | **X变分包的弊端总结** 128 | 129 | 1、X变整体实现对于我们是黑盒,实现手段大致能看懂,但细节难以了解,出了问题只能靠X变技术支持。遇到问题如: 130 | 131 | * 运行时使用X变手动下载指定资源,并轮询下载进度时引发主线程卡顿; 132 | * 同样是运行时,轮询是否整包下载完成引发X变下载线程卡顿,使得整体下载速度放缓; 133 | * 下载缓慢或部分下载失败问题,基本只能归结于网络环境差,X变早期无法控制后台下载速度,可能也会影响玩家联网游戏体验; 134 | * X变后台跟踪玩家下载完成度的日志记录不够详细不够及时,只能对照跟踪,或者自定添加X变UI上的打点日志,实际运营跟踪时较为不方便;—— 出了问题也说不清是谁的,怎么解决 135 | * 所幸问题基本踩了一遍,加上同样有友商使用,也能提前规避不少未爆发问题。如遇到一个Android12闪退问题,以及渠道审核中隐私相关问题。X变方的技术支持也算较为及时。 136 | 137 | 2、X变这套机制属于边玩边下,并不是即玩即下,必须要依托良好的wifi网络,在游戏前10-20分钟把整包下完 138 | 139 | * 流量网络下对玩家费用消耗较大较快,需要提前提示玩家; 140 | * 大部分首日流失玩家基本也下完整包,对我方CDN流量是种浪费(不少钱); 141 | * 只要度过前20分钟的下载,玩家就有完整流畅的体验。 142 | * X变的机制其实是只保证新手游戏流程的前期阶段流畅体验,来争取剩余资源的下载时间,专门面向新人玩家;当一个老号删端回流重新下端,各种地方都会因缺少资源会触发卡住主线程的同步下载,体验较差;再比如商店由整包更换成小包,一部分老玩家开启自动更新商店包的开关,也会遇到此类情况。 143 | 144 | 3、X变分包与项目本身热更机制有冲突 145 | 146 | * X变下载内容与项目热更内容独立,互不知晓,底包更新时需做额外逻辑共享对齐两方资源包,以避免重复下载资源。—— X变方定制支持 147 | * X变小包在未下载完整情况下,无法与项目热更使用的diff机制结合(项目热更有一个diff机制,可将本地资源包与diff包融合成新资源包,但在无基础包的情况下,无法与远端diff包融合成新包),导致新小包遇到热更时更新量级更大,影响新人转化率。—— 不过后续项目有一些方案可以解决,只是还要费一些功夫 148 | 149 | 终于,接入了第三方分包插件,配套的工具也全了,基本流程也跑通了,选了渠道上线测试也通过了,然而这个任务还远远没有结束..... 150 | 151 | 一年后,由于项目营收下降,厂子觉得没必要投入这么多费用搞这个分包,为了降本增效,要求先办法自己搞一套这个玩意儿,以砍掉这部分支出.... -------------------------------------------------------------------------------- /理解 C: -------------------------------------------------------------------------------- 1 | # 理解 C# 中的各类指针 - 黑洞视界 - 博客园 2 | 变量可以理解成是一块内存位置的别名,访问变量也就是访问对应内存中的数据。 3 | 4 | 指针是一种特殊的变量,它存储了一个内存地址,这个内存地址代表了另一块内存的位置。 5 | 6 | 指针指向的可以是一个变量、一个数组元素、一个对象实例、一块非托管内存、一个函数等。 7 | 8 | 截止到发文为止,.NET 最新正式版本为 .NET 9,C# 最新正式版本为 C# 13。文中提及的 `IL` 代码可能会随编译器版本的不同而有所差异,仅供参考。 9 | 10 | 本文将介绍到发文为止 C# 中的各类指针,并对比差异: 11 | 12 | * 对象引用(Object Reference) 13 | 14 | * 指针(Pointer,一些资料中称为非托管指针) 15 | 16 | * IntPtr(表示指针或句柄的值,用于管理非托管资源或非托管代码交互) 17 | 18 | * 函数指针(Function Pointer) 19 | 20 | * 托管指针(Managed Pointer) 21 | 22 | 23 | 本文旨在为读者建立对各类指针的概念认知,不会每个细节都展开,读者可以参考 C# 的官方文档,了解更多用法。 24 | 25 | 涉及的知识点较多,如果存在纰漏和错误,还请谅解。 26 | 27 | 对象引用,也就是我们常说的引用类型变量,是一个类型安全的指针,指向引用类型实例的 MethodTable 指针,通过偏移和计算可以访问对象头和字段。 28 | 29 | [![](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224303898-1876930133.png) 30 | ](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224303898-1876930133.png) 31 | 32 | 对象实例被分配在托管堆上,引用类型变量存储了一个指向该对象实例的引用。对象引用可以被赋值为 null,表示没有指向任何对象实例。通过 null 的对象引用访问不存在的对象会导致 `NullReferenceException`。 33 | 34 | 对象引用可以存在栈或者堆上,作为局部变量时,存储在栈上;作为值类型字段时,跟随值类型的位置存储;作为引用类型字段时,存储在堆上。 35 | 36 | 指针的声明和使用[#](#指针的声明和使用) 37 | ---------------------- 38 | 39 | 指针允许用户直接操作内存地址,提供了更高的性能和灵活性,但也带来了更高的风险。因此,C# 只允许在用 `unsafe` 关键字标记的代码块中使用指针,并且需要在项目中启用 `true`。 40 | 41 | `unsafe` 关键字可以用于方法、代码块、字段、类、结构体等。 42 | 43 | 一些资料中将这边的指针(Pointer)称为非托管指针(Unmanaged Pointer),因为它们不受 `GC` 的管理。 44 | 45 | 我们需要使用 `* ptr` 的语法来声明指针类型的变量。 46 | 47 | 通过 `&` 运算符获取变量的地址,通过 `*` 运算符访问指针指向的数据。 48 | 49 | `&` 通常被称为寻址运算符,`*` 通常被称为解引用运算符或间接寻址运算符。 50 | 51 | ```null 52 | unsafe class Program 53 | { 54 | static void Main() 55 | { 56 | int* p = null; 57 | int a = 10; 58 | p = &a; 59 | Console.WriteLine(*p); 60 | } 61 | } 62 | 63 | ``` 64 | 65 | 指针可以指向的位置[#](#指针可以指向的位置) 66 | ------------------------ 67 | 68 | 指针可以指向以下几种位置: 69 | 70 | * 值类型变量:也就是指向值类型的数据本体。 71 | 72 | * 引用类型变量:因为引用类型变量存储的是对象实例的引用,所以这边相当于一个二级指针。 73 | 74 | * 值类型或者引用类型的实例字段:readonly 也可以修改。 75 | 76 | * 值类型或者引用类型的静态字段:readonly 也可以修改。 77 | 78 | * 数组元素:数组在内存中是连续存储的,所以可以通过指针和指针算法来访问数组元素。 79 | 80 | * 非托管内存:使用 `Marshal` 分配非托管内存。 81 | 82 | * 另一个指针(Pointer):可以实现多级指针。 83 | 84 | * null:表示没有指向任何有效的内存地址,通过 null 指针访问不存在的数据会导致 `NullReferenceException`。 85 | 86 | 87 | **注意:在声明指向实例字段,静态字段以及数组元素的指针时,需要使用 `fixed` 关键字。** 88 | 89 | 可以声明指针的位置[#](#可以声明指针的位置) 90 | ------------------------ 91 | 92 | 指针可以在以下位置声明: 93 | 94 | * 局部变量:可以在方法中声明指针变量。 95 | 96 | * 方法参数:可以将指针作为方法参数传递。 97 | 98 | * 方法返回值:可以将指针作为方法的返回值。 99 | 100 | * 实例字段:可以在类或结构体中声明指针类型的字段。 101 | 102 | * 静态字段:可以在类或者结构体中声明指针类型的静态字段。 103 | 104 | * 只读属性:包含只读索引(indexer),但不支持自动属性(Automatically implemented properties)。 105 | 106 | 107 | 指向值类型变量的指针[#](#指向值类型变量的指针) 108 | -------------------------- 109 | 110 | 指针可以指向值类型变量,直接访问值类型的数据本体,并且可以修改值类型变量的值。 111 | 112 | [![](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224303477-1228482318.png) 113 | ](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224303477-1228482318.png) 114 | 115 | ```null 116 | unsafe class Program 117 | { 118 | static void Main() 119 | { 120 | int a = 10; 121 | int* p = &a; 122 | Console.WriteLine(*p); 123 | 124 | *p = 20; 125 | Console.WriteLine(a); 126 | } 127 | } 128 | 129 | ``` 130 | 131 | 指向对象引用的指针[#](#指向对象引用的指针) 132 | ------------------------ 133 | 134 | 指针可以指向对象引用,相当于一个二级指针。 135 | 136 | [![](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224303087-33976861.png) 137 | ](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224303087-33976861.png) 138 | 139 | 在下面的示例代码中,关键的部分标注了编译后的 IL 代码。 140 | 141 | ```null 142 | class Program 143 | { 144 | static void Main() 145 | { 146 | var foo = new Foo 147 | { 148 | Bar = 1 149 | }; 150 | 151 | unsafe 152 | { 153 | 154 | 155 | 156 | Foo* fooPtr = &foo; 157 | 158 | 159 | 160 | 161 | 162 | Console.WriteLine(fooPtr->Bar); 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | *fooPtr = new Foo 172 | { 173 | Bar = 2 174 | }; 175 | 176 | 177 | 178 | 179 | Console.WriteLine(foo.Bar); 180 | 181 | 182 | 183 | 184 | 185 | fooPtr->Bar = 3; 186 | Console.WriteLine(foo.Bar); 187 | } 188 | } 189 | } 190 | 191 | class Foo 192 | { 193 | public int Bar { get; set; } 194 | } 195 | 196 | ``` 197 | 198 | 关键的三个IL 指令: 199 | 200 | * `conv.u`:将对象引用(foo)的地址转换为 unsigned native int,并存储到指针(fooPtr)中。 201 | 202 | * `ldind.ref`:将指针(fooPtr)指向的对象引用(foo)加载到栈上。 203 | 204 | * `stind.ref`:将栈上的对象引用(新的foo实例的引用)存储到指针指向的地址(foo)上。 205 | 206 | 207 | [![](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224302703-916170210.png) 208 | ](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224302703-916170210.png) 209 | 210 | 指向 GC Heap 的指针[#](#指向-gc-heap-的指针) 211 | ---------------------------------- 212 | 213 | 如果指针指向 GC Heap 上的数据,例如指向数组元素或者引用类型实例字段,指针需要通过 `fixed` 关键字固定对象的地址,防止 `GC` 移动对象的位置。 214 | 215 | ```null 216 | class Program 217 | { 218 | static void Main() 219 | { 220 | Foo foo = new Foo 221 | { 222 | Bar = 1 223 | }; 224 | 225 | unsafe 226 | { 227 | fixed (int* p = &foo.Bar) 228 | { 229 | Console.WriteLine(*p); 230 | 231 | *p = 2; 232 | } 233 | } 234 | 235 | Console.WriteLine(foo.Bar); 236 | } 237 | } 238 | 239 | class Foo 240 | { 241 | public int Bar; 242 | } 243 | 244 | ``` 245 | 246 | **注意:不应在 `fixed` 语句块结束后,继续使用指针变量,因为 `GC` 可能会移动对象的位置,导致指针指向无效的内存地址。** 247 | 248 | ```null 249 | class Program 250 | { 251 | static void Main() 252 | { 253 | Foo foo = new Foo 254 | { 255 | Bar = 1 256 | }; 257 | 258 | var weakReference = new WeakReference(foo); 259 | 260 | unsafe 261 | { 262 | int* p2; 263 | fixed (int* p1 = &foo.Bar) 264 | { 265 | Console.WriteLine(*p1); 266 | 267 | p2 = p1; 268 | 269 | *p1 = 2; 270 | } 271 | 272 | Console.WriteLine(*p2); 273 | 274 | 275 | for (int i = 0; i < 1_000_000; i++) 276 | { 277 | var arr = new int[1000]; 278 | } 279 | 280 | GC.Collect(); 281 | 282 | Console.WriteLine(weakReference.IsAlive); 283 | Console.WriteLine(*p2); 284 | } 285 | } 286 | } 287 | 288 | class Foo 289 | { 290 | public int Bar; 291 | } 292 | 293 | ``` 294 | 295 | 指向数组元素的指针[#](#指向数组元素的指针) 296 | ------------------------ 297 | 298 | 当指针指向数组元素时,可以通过指针算法遍历数组元素,指针的单次偏移量为元素类型的大小。 299 | 300 | [![](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224301911-1219750162.png) 301 | ](https://img2023.cnblogs.com/blog/1201123/202505/1201123-20250512224301911-1219750162.png) 302 | 303 | 指针算法支持的操作有: 304 | 305 | 对指针进行加法和减法运算时,p + n 是将指针 p 向后移动 n 个元素的大小,p - n 是将指针 p 向前移动 n 个元素的大小。 306 | 307 | 本文会讨论三种数组类型: 308 | 309 | * 在栈上分配的数组 310 | * 在托管堆上分配的数组 311 | * 在非托管堆上分配的数组 312 | 313 | 本小节先讨论前两种,指向非托管堆上分配的数组的指针会在后面讨论。 314 | 315 | 栈上和非托管堆上分配的数组时,指针可以直接访问数组元素。在托管堆上分配的数组时,指针需要通过 `fixed` 关键字固定数组元素的地址,防止 `GC` 移动数组元素的位置。 316 | 317 | 在栈上分配的数组的示例代码: 318 | 319 | ```null 320 | unsafe class Program 321 | { 322 | static void Main() 323 | { 324 | 325 | int* arr = stackalloc int[5] { 0, 1, 2, 3, 4 }; 326 | 327 | 328 | 329 | 330 | 331 | 332 | for (int i = 0; i < 5; i++) 333 | { 334 | Console.WriteLine(*(arr + i)); 335 | } 336 | 337 | for (int i = 0; i < 5; i++) 338 | { 339 | Console.WriteLine(arr[i]); 340 | } 341 | } 342 | } 343 | 344 | ``` 345 | 346 | 在托管堆上分配的数组的示例代码: 347 | 348 | ```null 349 | unsafe class Program 350 | { 351 | static void Main() 352 | { 353 | int[] arr = new int[5] { 0, 1, 2, 3, 4 }; 354 | fixed (int* p = arr) 355 | { 356 | for (int i = 0; i < 5; i++) 357 | { 358 | Console.WriteLine(*(p + i)); 359 | } 360 | } 361 | 362 | fixed (int* p = &arr[0]) 363 | { 364 | for (int i = 0; i < 5; i++) 365 | { 366 | *(p + i) = i * 10; 367 | } 368 | } 369 | 370 | foreach (var item in arr) 371 | { 372 | Console.WriteLine(item); 373 | } 374 | } 375 | } 376 | 377 | ``` 378 | 379 | 在 `fixed` 语句块结束后,数组元素的地址会被释放,指针变量将不再有效。 380 | 381 | 在 `fixed` 语句块中,指针变量可以直接访问数组元素的地址,并且可以修改数组元素的值。 382 | 383 | `int* p = arr` 和 `int* p = &arr[0]` 是等效的,都是获取数组第一个元素的地址。 384 | 385 | **注意: `int[]* p = &arr` 是创建一个指向数组变量的指针,并不是指向数组元素的指针。** 386 | 387 | 指向静态字段的指针[#](#指向静态字段的指针) 388 | ------------------------ 389 | 390 | 静态字段位于托管堆上,但非 `GC` 管理的内存区域,理论上内存地址应该是固定的,但不排除某些平台实现或某些情况下会被移动。 391 | 392 | 在.NET的规范以及C#语言规范中,编译器并不能完全确定某个字段是否可移动,必须通过 `fixed` 修饰保证安全。 393 | 394 | 统一使用 `fixed` 也可以避免特例导致的复杂性或bug。如果静态保存的是值类型还好。但如果静态字段保存的是一个对象引用,那就和方法的局部变量一样,指针必定需要通过 `fixed` 关键字固定对象的地址,防止 `GC` 移动对象的位置。静态字段如果存的是数组的引用,也是必须使用 `fixed` 关键字固定对象的地址才能访问数组元素。 395 | 396 | ```null 397 | unsafe class Program 398 | { 399 | static void Main() 400 | { 401 | 402 | Foo.ValueTypeField = 1; 403 | 404 | 405 | fixed (int* valueTypeFieldPtr = &Foo.ValueTypeField) 406 | { 407 | *valueTypeFieldPtr = 2; 408 | } 409 | 410 | Console.WriteLine(Foo.ValueTypeField); 411 | 412 | 413 | Foo.ReferenceTypeField = new Bar { Baz = 1 }; 414 | 415 | 416 | fixed (Bar* referenceTypeFieldPtr = &Foo.ReferenceTypeField) 417 | { 418 | *referenceTypeFieldPtr = new Bar { Baz = 2 }; 419 | } 420 | 421 | Console.WriteLine(Foo.ReferenceTypeField.Baz); 422 | 423 | 424 | Foo.ArrayField = [1, 2, 3]; 425 | 426 | 427 | fixed (int* arrayFieldPtr = Foo.ArrayField) 428 | { 429 | arrayFieldPtr[0] = 4; 430 | } 431 | 432 | Console.WriteLine(Foo.ArrayField[0]); 433 | } 434 | } 435 | 436 | class Foo 437 | { 438 | public static int ValueTypeField; 439 | 440 | public static Bar ReferenceTypeField; 441 | 442 | public static int[] ArrayField; 443 | } 444 | 445 | class Bar 446 | { 447 | public int Baz; 448 | } 449 | 450 | ``` 451 | 452 | 指向非托管内存的指针[#](#指向非托管内存的指针) 453 | -------------------------- 454 | 455 | 使用 `Marshal.AllocHGlobal` 分配非托管内存,返回一个指向非托管内存的指针,最后使用 `Marshal.FreeHGlobal` 释放非托管内存。 456 | 457 | `Marshal` 提供的方法的参数和返回值都是 `IntPtr` 类型,但可以和指针互换转换。 458 | 459 | ```null 460 | public static class Marshal 461 | { 462 | public static IntPtr AllocHGlobal(int cb); 463 | public static void FreeHGlobal(IntPtr hglobal); 464 | } 465 | 466 | ``` 467 | 468 | ```null 469 | using System.Runtime.InteropServices; 470 | 471 | unsafe class Program 472 | { 473 | static void Main() 474 | { 475 | 476 | int size = 10; 477 | var ptr = (int*)Marshal.AllocHGlobal(size * sizeof(int)); 478 | 479 | 480 | for (int i = 0; i < size; i++) 481 | { 482 | ptr[i] = i; 483 | } 484 | 485 | 486 | for (int i = 0; i < size; i++) 487 | { 488 | Console.WriteLine(ptr[i]); 489 | } 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | Marshal.FreeHGlobal((IntPtr)ptr); 501 | } 502 | } 503 | 504 | ``` 505 | 506 | 作为方法参数的指针[#](#作为方法参数的指针) 507 | ------------------------ 508 | 509 | 指针可以作为方法参数传递,允许在方法中修改指针指向的数据,但指针本身的传递是值传递,无法在传入的方法中修改指针的值,也就是无法修改指针指向的地址。 510 | 511 | ```null 512 | unsafe class Program 513 | { 514 | static void Main() 515 | { 516 | int a = 10; 517 | int b = 20; 518 | 519 | int* p1 = &a; 520 | int* p2 = &b; 521 | Console.WriteLine(*p1); 522 | Console.WriteLine(*p2); 523 | 524 | ModifyPointer(p1, p2); 525 | Console.WriteLine(*p1); 526 | } 527 | 528 | static void ModifyPointer(int* p1, int* p2) 529 | { 530 | *p1 = 11; 531 | 532 | p1 = p2; 533 | } 534 | } 535 | 536 | ``` 537 | 538 | 作为方法返回值的指针[#](#作为方法返回值的指针) 539 | -------------------------- 540 | 541 | **当指针作为方法的返回值时,需要注意不能返回局部变量的指针,因为局部变量在方法结束后会被销毁,指针将指向无效的内存地址。** 542 | 543 | ```null 544 | unsafe class Program 545 | { 546 | static void Main() 547 | { 548 | Foo* p = GetPointer(); 549 | 550 | Console.WriteLine(p->Bar); 551 | Console.WriteLine(p->Bar); 552 | } 553 | 554 | static Foo* GetPointer() 555 | { 556 | Foo a = new Foo 557 | { 558 | Bar = 10 559 | }; 560 | return &a; 561 | } 562 | } 563 | 564 | struct Foo 565 | { 566 | public int Bar; 567 | } 568 | 569 | ``` 570 | 571 | 上述代码中,`GetPointer` 方法返回了一个指向局部变量 `a` 的指针,但 `a` 在方法结束后会被销毁,所以返回的指针将指向无效的内存地址。 572 | 573 | 之所以第一次输出 10,是因为 `a` 的内存数据没有被覆盖,第二次输出随机值是因为 `a` 的内存数据已经被覆盖。 574 | 575 | 在打印 `p->Bar` 之前,将一些别的数据载入到栈上,就会覆盖 `a` 的内存数据。下面的代码只打印了一次 `p->Bar`,但在打印之前,已经将 20 到过栈上(被 `Console.WriteLine` 消费了),所以 `a` 的内存数据被覆盖了。 576 | 577 | ```null 578 | unsafe class Program 579 | { 580 | static void Main() 581 | { 582 | Foo* p = GetPointer(); 583 | Console.WriteLine(20); 584 | Console.WriteLine(p->Bar); 585 | } 586 | 587 | static Foo* GetPointer() 588 | { 589 | Foo a = new Foo 590 | { 591 | Bar = 10 592 | }; 593 | return &a; 594 | } 595 | } 596 | 597 | struct Foo 598 | { 599 | public int Bar; 600 | } 601 | 602 | ``` 603 | 604 | 改为返回字段的指针也是一样的结果 605 | 606 | ```null 607 | unsafe class Program 608 | { 609 | static void Main() 610 | { 611 | int* p = GetPointer(); 612 | 613 | Console.WriteLine(*p); 614 | Console.WriteLine(*p); 615 | } 616 | 617 | static int* GetPointer() 618 | { 619 | Foo a = new Foo 620 | { 621 | Bar = 10 622 | }; 623 | return &a.Bar; 624 | } 625 | } 626 | 627 | struct Foo 628 | { 629 | public int Bar; 630 | } 631 | 632 | ``` 633 | 634 | 多级指针[#](#多级指针) 635 | -------------- 636 | 637 | 下面是一个三级指针的例子 638 | 639 | ```null 640 | { 641 | int x = 1; 642 | int* p1 = &x; 643 | int** p2 = &p1; 644 | int*** p3 = &p2; 645 | 646 | ***p3 = 2; 647 | 648 | Console.WriteLine(x); 649 | } 650 | 651 | ``` 652 | 653 | 进一步理解 fixed 关键字[#](#进一步理解-fixed-关键字) 654 | ------------------------------------ 655 | 656 | `fixed` 关键字用于固定对象的地址,防止 `GC` 移动对象的位置。 657 | 658 | 查看下面代码编译成的 IL 代码。 659 | 660 | ```null 661 | unsafe class Program 662 | { 663 | static void Main() 664 | { 665 | 666 | Foo.ReferenceTypeField = new Bar { Baz = 1 }; 667 | 668 | 669 | fixed (Bar* referenceTypeFieldPtr = &Foo.ReferenceTypeField) 670 | { 671 | *referenceTypeFieldPtr = new Bar { Baz = 2 }; 672 | } 673 | 674 | Console.WriteLine(Foo.ReferenceTypeField.Baz); 675 | 676 | 677 | Foo.ArrayField = [1, 2, 3]; 678 | 679 | 680 | fixed (int* arrayFieldPtr = Foo.ArrayField) 681 | { 682 | arrayFieldPtr[0] = 4; 683 | } 684 | 685 | Console.WriteLine(Foo.ArrayField[0]); 686 | } 687 | } 688 | 689 | class Foo 690 | { 691 | public static Bar ReferenceTypeField; 692 | 693 | public static int[] ArrayField; 694 | } 695 | 696 | class Bar 697 | { 698 | public int Baz; 699 | } 700 | 701 | ``` 702 | 703 | ```null 704 | .class private auto ansi beforefieldinit 705 | Program 706 | extends [System.Runtime]System.Object 707 | { 708 | 709 | .method private hidebysig static void 710 | Main() cil managed 711 | { 712 | .entrypoint 713 | .maxstack 4 714 | .locals init ( 715 | [0] class Bar* referenceTypeFieldPtr, 716 | [1] class Bar& pinned V_1, 717 | [2] int32* arrayFieldPtr, 718 | [3] int32[] pinned V_3 719 | ) 720 | // ... 省略方法体 721 | } 722 | } 723 | 724 | ``` 725 | 726 | 在 IL 代码中,`Bar& pinned V_1` 和 `int32[] pinned V_3` 表示固定的指向对象引用的托管指针和固定的数组的对象引用。 727 | 728 | `pinned` 表示这个对象引用是固定的,`GC` 会识别到这个标记,并不会移动其指向的对象的位置。 729 | 730 | 在 `fixed` 语句块内,对 `Bar* referenceTypeFieldPtr` 的读写将转换为 `Bar& pinned V_1` 的读写。对 `int32* arrayFieldPtr` 的读写将转换为 `int32[] pinned V_3` 的读写。 731 | 732 | 基本概念[#](#基本概念) 733 | -------------- 734 | 735 | `IntPtr` 是一个结构体,表示指针或句柄的值,用于管理非托管资源或非托管代码交互。 736 | 737 | 在部分场景,可以和指针互换使用,但 `IntPtr` 不能直接进行指针运算。 738 | 739 | `IntPtr` 是一个平台相关的类型,在 32 位平台上是 4 字节,在 64 位平台上是 8 字节。 740 | 741 | 在使用 `IntPtr` 时,不需要使用 `unsafe` 关键字,也不需要启用 `true`(如果使用 P/Invoke 调用非托管函数时,仍然需要启用)。 742 | 743 | 指向非托管内存的 IntPtr[#](#指向非托管内存的-intptr) 744 | ------------------------------------ 745 | 746 | 在使用 `IntPtr` 管理非托管内存时,不能直接读取和写入内存,需要使用 `Marshal` 提供的`ReadXXX` 和 `WriteXXX` 方法。 747 | 748 | ```null 749 | using System.Runtime.InteropServices; 750 | 751 | class Program 752 | { 753 | static void Main() 754 | { 755 | 756 | int size = 10; 757 | IntPtr ptr = Marshal.AllocHGlobal(size * sizeof(int)); 758 | 759 | 760 | for (int i = 0; i < size; i++) 761 | { 762 | Marshal.WriteInt32(ptr + i * sizeof(int), i); 763 | } 764 | 765 | 766 | for (int i = 0; i < size; i++) 767 | { 768 | Console.WriteLine(Marshal.ReadInt32(ptr + i * sizeof(int))); 769 | } 770 | 771 | 772 | Marshal.FreeHGlobal(ptr); 773 | } 774 | } 775 | 776 | ``` 777 | 778 | 保存句柄的 IntPtr[#](#保存句柄的-intptr) 779 | ------------------------------ 780 | 781 | `IntPtr` 也可以用于存储句柄,例如文件句柄、窗口句柄等。 782 | 783 | 句柄可以理解为一个指向资源的引用,通常是一个整数值,用于唯一标识和访问由操作系统管理的资源。本质上它是一个资源标识符,而不是资源在内存中的实际地址。 784 | 785 | 下面是一个 windows 平台的例子 786 | 787 | ```null 788 | using System.Runtime.InteropServices; 789 | 790 | public static partial class Program 791 | { 792 | 793 | private delegate bool EnumWC(IntPtr hwnd, IntPtr lParam); 794 | 795 | 796 | 797 | [LibraryImport("user32.dll")] 798 | private static partial int EnumWindows(EnumWC lpEnumFunc, IntPtr lParam); 799 | 800 | 801 | private static bool OutputWindow(IntPtr hwnd, IntPtr lParam) 802 | { 803 | Console.WriteLine(hwnd.ToInt64()); 804 | return true; 805 | } 806 | 807 | public static void Main(string[] args) 808 | { 809 | 810 | EnumWindows(OutputWindow, IntPtr.Zero); 811 | } 812 | } 813 | 814 | ``` 815 | 816 | 上面的代码使用了 `LibraryImport` 特性来导入 `user32.dll` 中的 `EnumWindows` 函数,并定义了一个委托 `EnumWC` 来对应这个函数的回调函数。`EnumWindows` 函数会枚举所有顶级窗口,并调用 `OutputWindow` 函数来输出每个窗口的句柄。 817 | 818 | `OutputWindow` 函数的参数 `hwnd` 是一个 `IntPtr` 类型的句柄,表示窗口的句柄。可以使用 `hwnd.ToInt64()` 将其转换为长整型值进行输出。 819 | 820 | 基本概念[#](#基本概念-1) 821 | ---------------- 822 | 823 | 函数指针是一个指向函数的指针,分为托管函数指针和非托管函数指针。 824 | 825 | 这是一个 C# 9 新增的特性,建议读者阅读官方文档地址加深理解: 826 | [https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/proposals/csharp-9.0/function-pointers](https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/proposals/csharp-9.0/function-pointers) 827 | 828 | 在 IL 层面,调用方法的指令分为三种: 829 | 830 | * `call`:直接调用静态方法或非虚方法。 831 | 832 | * 常用于静态方法、私有实例方法、构造函数、基类方法等。 833 | * 不会进行虚方法表查找,故不能用于虚方法调用。 834 | * `callvirt`:用于调用虚方法(virtual)、接口方法,或者有时也用来调用非虚实例方法。 835 | 836 | * 会进行虚方法表(vtable)查找,确保调用最终派生类的实现(多态)。 837 | * 调用前自动检测 this 是否为 null,如果是则抛出 NullReferenceException。所以 C# 编译器的常见做法是对非虚方法也使用 `callvirt`,以保证 null 检查。 838 | * `calli`:间接调用,通过函数指针进行调用。 839 | 840 | * 性能开销更低,但安全性、类型检查弱。 841 | * 通常只有在编写 IL 代码,或者使用 Emit 动态生成代码时才会使用。 842 | * 新增的函数指针语法允许在 C# 中使用 `calli` 指令,提供了更好的类型安全性。 843 | 844 | 早期 C# 为我们提供了委托(Delegate)来封装方法的引用,委托可以看作是一个类型安全的函数指针。所有的委托类型都继承自 `System.Delegate` 类。我们在调用委托时,实际上是调用了委托的 `Invoke` 这个虚方法,IL 指令是 `callvirt`。 845 | 846 | 在后期新增的函数指针语法中,编译器使用 `calli` 指令来调用函数,而不是实例化委托对象并调用 `Invoke` 方法。 847 | 848 | 函数指针的声明和使用[#](#函数指针的声明和使用) 849 | -------------------------- 850 | 851 | 和指针一样,函数指针也需要在 `unsafe` 代码块中使用,并且需要启用 `true`。 852 | 853 | 声明函数指针的语法如下: 854 | 855 | ```null 856 | delegate*<[parameter type list], return type> variableName 857 | 858 | ``` 859 | 860 | `delegate*` 是一个关键字,表示函数指针类型。 861 | 862 | `` 是参数类型列表,可以是空的,也可以是一个或多个参数类型,用逗号分隔。 863 | `return type` 是返回值类型,可以是 `void` 或者其他类型。 864 | 865 | 下面是几个例子: 866 | 867 | * `delegate* ptr`:表示一个不带参数和返回值的函数指针。 868 | 869 | * `delegate* ptr`:表示一个不带参数,返回值为 `int` 的函数指针。 870 | 871 | * `delegate* ptr`:表示一个带两个 `int` 参数,返回值为 `int` 的函数指针。 872 | 873 | * `delegate* ptr`:表示一个带两个 `int` 参数,无返回值的函数指针。 874 | 875 | 876 | 函数指针的声明和使用示例: 877 | 878 | ```null 879 | unsafe class Program 880 | { 881 | static void Main() 882 | { 883 | 884 | delegate* addPtr = &Add; 885 | 886 | 887 | int result = addPtr(1, 2); 888 | Console.WriteLine(result); 889 | } 890 | 891 | static int Add(int a, int b) 892 | { 893 | return a + b; 894 | } 895 | } 896 | 897 | ``` 898 | 899 | 使用 `&` 运算符获取函数的地址,并赋值给函数指针变量。 900 | 901 | 函数指针只能指向静态方法,不能指向实例方法或者委托。 902 | 903 | 可以指向静态的本地函数(local function),也就是说这个本地函数不是闭包。 904 | 905 | 下面对比函数指针和委托,用 `BenchmarkDotNet` 做个简单的性能测试 906 | 907 | ```null 908 | public class Program 909 | { 910 | public static void Main(string[] args) 911 | { 912 | BenchmarkRunner.Run(); 913 | } 914 | } 915 | 916 | [MemoryDiagnoser] 917 | public class Benchmark 918 | { 919 | private delegate int AddDelegate(int a, int b); 920 | private static AddDelegate addDelegate = Add; 921 | 922 | private unsafe delegate* addPtr = &Add; 923 | 924 | [Benchmark] 925 | public void Delegate() 926 | { 927 | for (int i = 0; i < 1000000; i++) 928 | { 929 | var result = addDelegate(1, 2); 930 | } 931 | } 932 | 933 | [Benchmark] 934 | public unsafe void FunctionPointer() 935 | { 936 | for (int i = 0; i < 1000000; i++) 937 | { 938 | var result = addPtr(1, 2); 939 | } 940 | } 941 | 942 | private static int Add(int a, int b) 943 | { 944 | return a + b; 945 | } 946 | } 947 | 948 | ``` 949 | 950 | 运行结果如下: 951 | 952 | ```null 953 | | Method | Mean | Error | StdDev | Allocated | 954 | |---------------- |---------:|----------:|----------:|----------:| 955 | | Delegate | 1.530 ms | 0.0054 ms | 0.0048 ms | 1 B | 956 | | FunctionPointer | 1.409 ms | 0.0042 ms | 0.0039 ms | 1 B | 957 | 958 | ``` 959 | 960 | 虽然此处例子差距不是很明显,但还是能看到函数指针的性能更好一些。 961 | 962 | 托管函数指针和非托管函数指针[#](#托管函数指针和非托管函数指针) 963 | ---------------------------------- 964 | 965 | 在声明函数指针时,可以在 `delegate*` 后面加上 `managed` 或 `unmanaged` 关键字,表示托管函数指针或非托管函数指针。 966 | 967 | 不加关键字时,默认是托管函数指针。 968 | 969 | 下面是一个可以在 macOS 上运行的例子: 970 | 971 | ```null 972 | unsafe class Program 973 | { 974 | 975 | private delegate* unmanaged[Cdecl] GetPidDelegate; 976 | 977 | static void Main() 978 | { 979 | var prog = new Program(); 980 | prog.Run(); 981 | } 982 | 983 | public void Run() 984 | { 985 | 986 | IntPtr lib = NativeLibrary.Load("/usr/lib/libc.dylib"); 987 | 988 | 989 | IntPtr pidFuncPtr = NativeLibrary.GetExport(lib, "getpid"); 990 | 991 | 992 | GetPidDelegate = (delegate* unmanaged[Cdecl])pidFuncPtr; 993 | 994 | 995 | int pid = GetPidDelegate(); 996 | 997 | Console.WriteLine($"Current PID from libc.getpid(): {pid}"); 998 | 999 | 1000 | NativeLibrary.Free(lib); 1001 | } 1002 | } 1003 | 1004 | ``` 1005 | 1006 | 上面的代码中,`delegate* unmanaged[Cdecl]` 声明了一个非托管函数指针类型,指向一个返回 `int` 的函数。 1007 | 1008 | `Cdecl` 是调用约定,表示使用 C 语言的调用约定。 1009 | 1010 | 通过获取 `getpid` 函数的地址,并将其转换为函数指针类型,最后调用该函数获取当前进程的 PID。 1011 | 1012 | `NativeLibrary` 是一个用于加载和调用非托管库的类,提供了 `Load` 和 `GetExport` 方法来加载库和获取函数地址。 1013 | 1014 | 使用完后,使用 `NativeLibrary.Free` 方法释放库。 1015 | 1016 | 托管指针的声明和使用[#](#托管指针的声明和使用) 1017 | -------------------------- 1018 | 1019 | 托管指针并非一个新的特性,在早期的 C# 版本中,我们在方法参数上使用的 `ref` 和 `out` 就是声明了托管指针。 1020 | 1021 | 在 IL 中,用 `*` 来表示前面说的指针(pointer,有些资料中称为 非托管指针)。 1022 | 1023 | 而 `ref` 和 `out` 在 IL 中对应的是 `&`,也就是托管指针(managed pointer)。 1024 | 1025 | `out` 相当于 `ref` 的一种特殊情况,表示参数是一个输出参数,方法内部必须对其赋值。 1026 | 1027 | 另外还有一个 `in` 可以把方法参数声明为只读的托管指针,方法内部不能对其赋值。 1028 | 1029 | 使用托管指针时,我们不需要使用 `unsafe` 关键字,也不需要启用 `true`。 1030 | 1031 | **注意:托管指针相关的语法会在几个位置用到 `ref` 关键字,但作用和意义是不同的。** 1032 | 1033 | * 我们使用 `ref ptr` 来声明一个托管指针。 1034 | 1035 | * 同时也用 `ref` 关键字来获取变量的地址,`ref ptr = ref a`。 1036 | 1037 | * 访问托管指针指向的数据时,语法上只需直接访问不带 `ref` 的指针变量名 `ptr` 即可。 1038 | 1039 | * 复制托管指针的值时,需要在指针变量前面加上 `ref` 关键字。`ref ptr2 = ref ptr`。 1040 | 1041 | * 修改托管指针指向的数据时,语法上只需直接访问不带 `ref` 的指针变量名 `ptr` 即可,`ptr = ref b`。 1042 | 1043 | 1044 | ```null 1045 | class Program 1046 | { 1047 | static void Main() 1048 | { 1049 | int a = 10; 1050 | ref int p1 = ref a; 1051 | Console.WriteLine(p1); 1052 | 1053 | p1 = 20; 1054 | Console.WriteLine(a); 1055 | 1056 | ref int p2 = ref p1; 1057 | 1058 | p2 = 30; 1059 | Console.WriteLine(a); 1060 | 1061 | int b = 40; 1062 | p1 = ref b; 1063 | Console.WriteLine(p1); 1064 | 1065 | p1 = 50; 1066 | Console.WriteLine(b); 1067 | Console.WriteLine(p2); 1068 | } 1069 | } 1070 | 1071 | ``` 1072 | 1073 | 托管指针可以指向的位置[#](#托管指针可以指向的位置) 1074 | ---------------------------- 1075 | 1076 | * 值类型变量:也就是指向值类型的数据本体。 1077 | 1078 | * 引用类型变量:和上文指向对象引用的指针(Pointer)一样,相当于一个二级指针,但不支持指向另一个托管指针。 1079 | 1080 | * 值类型或者引用类型的实例字段。 1081 | 1082 | * 值类型或者引用类型的静态字段 1083 | 1084 | * 数组元素:但不支持指针算法。 1085 | 1086 | * null:表示没有指向任何有效的内存地址,尝试访问 null 指针会导致 `NullReferenceException`。目前只有作为 `ref struct` 的 `ref` 字段时,可能出现这个情况,需使用 `Unsafe.IsNullRef(T)` 方法确定 ref 字段是否为 null。 1087 | 1088 | 1089 | 可以声明托管指针的位置[#](#可以声明托管指针的位置) 1090 | ---------------------------- 1091 | 1092 | * 局部变量:可以在方法中声明托管指针变量。 1093 | 1094 | * 方法参数:可以将托管指针作为方法参数传递。 1095 | 1096 | * 方法返回值:可以将托管指针作为方法的返回值。 1097 | 1098 | * ref struct 的实例字段:`ref struct` 的 `ref` 不代表这种 `struct` 是按引用传递的,是指其具有类似托管指针的限制。 1099 | 1100 | * 只读属性:包含只读索引(indexer),但不支持自动属性(Automatically implemented properties)。 1101 | 1102 | 1103 | 托管指针的限制[#](#托管指针的限制) 1104 | -------------------- 1105 | 1106 | 出于安全的设计目的,相较于指针(Pointer),托管指针只允许存在于栈上,不允许在存在于堆上。主要的限制如下: 1107 | 1108 | * 不能作为类或者非 `ref struct` 的结构体的字段。 1109 | 1110 | * 不能作为静态字段,因为静态字段在保存在托管堆上(非 GC Heap)。 1111 | 1112 | * 不能作为 async方法 或 迭代器方法 的参数,因为参数会被状态机捕获,并保存在堆上。 1113 | 1114 | * 不能在 await 和 yield 语句中使用,因为相关的变量会被状态机捕获,并保存在堆上。 1115 | 1116 | * 不能被闭包捕获,因为编译器会将闭包转换为一个类,并将捕获的变量作为类的字段。 1117 | 1118 | 1119 | 作为能保存托管指针的的 `ref struct`,也只允许在栈上分配内存。C# 对 `ref struct` 的限制主要如下: 1120 | 1121 | * 不能作为类或者非 `ref struct` 的结构体的字段。 1122 | 1123 | * 不能作为静态字段。 1124 | 1125 | * 不能装箱。无法将 `ref struct` 装箱为 `object` 或者接口类型。也无法将 `ref struct` 作为数组元素。 1126 | 1127 | * 不能作为 async方法 的参数,因为参数会被状态机捕获,并保存在堆上。但可以作为迭代器方法的参数。 1128 | 1129 | * 不能在 await 和 yield 语句中使用,因为相关的变量会被状态机捕获,并保存在堆上。 1130 | 1131 | * 不能被闭包捕获,因为编译器会将闭包转换为一个类,并将捕获的变量作为类的字段。 1132 | 1133 | 1134 | 指向对象引用的托管指针[#](#指向对象引用的托管指针) 1135 | ---------------------------- 1136 | 1137 | 托管指针指向对象引用时,和指针(Pointer)一样,都类似于一个二级指针。 1138 | 1139 | 下面是一个简单的例子,演示了如何使用托管指针指向对象引用: 1140 | 1141 | ```null 1142 | class Program 1143 | { 1144 | static void Main() 1145 | { 1146 | Foo foo = new Foo 1147 | { 1148 | Bar = 1 1149 | }; 1150 | 1151 | 1152 | 1153 | 1154 | ref Foo fooPtr = ref foo; 1155 | 1156 | 1157 | 1158 | 1159 | 1160 | 1161 | Console.WriteLine(fooPtr.Bar); 1162 | 1163 | 1164 | 1165 | 1166 | 1167 | 1168 | 1169 | 1170 | 1171 | fooPtr = new Foo 1172 | { 1173 | Bar = 2 1174 | }; 1175 | 1176 | 1177 | Console.WriteLine(foo.Bar); 1178 | 1179 | 1180 | 1181 | 1182 | 1183 | 1184 | 1185 | fooPtr.Bar = 3; 1186 | Console.WriteLine(foo.Bar); 1187 | } 1188 | } 1189 | 1190 | public struct Foo 1191 | { 1192 | public int Bar { get; set; } 1193 | } 1194 | 1195 | ``` 1196 | 1197 | 上面的代码中,`ref Foo fooPtr = ref foo;` 声明了一个托管指针 `fooPtr`,指向 `foo` 的地址。 1198 | 1199 | `fooPtr` 是一个托管指针,指向 `foo` 的地址,虽然语法可以直接访问 `fooPtr.Bar` 的属性,但其过程是先将 `fooPtr` 指向的对象引用加载到栈上,然后调用 `get_Bar()` 方法获取属性值。 1200 | 1201 | `fooPtr = new Foo { Bar = 2 };` 修改了 `fooPtr` 指向的对象引用,也就是修改了 `foo` 的值。 1202 | 1203 | 和指针(Pointer)那一章节生成的 IL 代码进行对比,你会发现,唯一的区别是将变量地址保存到指针时,指针比托管指针多了一个 `conv.u` 指令。 1204 | 1205 | ```null 1206 | class Program 1207 | { 1208 | static unsafe void Main() 1209 | { 1210 | Foo foo = new Foo 1211 | { 1212 | Bar = 1 1213 | }; 1214 | 1215 | 1216 | 1217 | 1218 | Foo* fooPtr1 = &foo; 1219 | 1220 | 1221 | 1222 | ref Foo fooPtr2 = ref foo; 1223 | } 1224 | } 1225 | 1226 | public struct Foo 1227 | { 1228 | public int Bar { get; set; } 1229 | } 1230 | 1231 | ``` 1232 | 1233 | 可以看出唯一的区别就是 指针(Pointer)和托管指针(Managed Pointer)在保存变量地址时,指针(Pointer)需要转换为 unsigned native int,而托管指针(Managed Pointer)不需要转换。 1234 | 1235 | 在获取对象引用时 `ldind.ref` 同时支持两种指针格式。 1236 | 1237 | 指向 GC Heap 的托管指针[#](#指向-gc-heap-的托管指针) 1238 | -------------------------------------- 1239 | 1240 | 托管指针受 GC 管理,不用关注指向的数据是否在 GC 过程中被移动。在 GC 过程中,托管指针会被自动更新为新的地址。 1241 | 1242 | 下面是一个简单的例子,演示了如何使用托管指针指向引用类型的实例字段: 1243 | 1244 | ```null 1245 | class Program 1246 | { 1247 | static void Main() 1248 | { 1249 | Foo foo = new Foo 1250 | { 1251 | Bar = 1 1252 | }; 1253 | 1254 | ref int p = ref foo.Bar; 1255 | 1256 | Console.WriteLine(p); 1257 | 1258 | p = 2; 1259 | 1260 | Console.WriteLine(foo.Bar); 1261 | } 1262 | } 1263 | 1264 | public class Foo 1265 | { 1266 | public int Bar; 1267 | } 1268 | 1269 | ``` 1270 | 1271 | 指向数组元素的托管指针[#](#指向数组元素的托管指针) 1272 | ---------------------------- 1273 | 1274 | 托管指针可以指向数组元素,但不支持指针算法。 1275 | 1276 | ```null 1277 | class Program 1278 | { 1279 | static void Main() 1280 | { 1281 | int[] arr = new int[5] { 0, 1, 2, 3, 4 }; 1282 | 1283 | 1284 | ref int p = ref arr[0]; 1285 | 1286 | Console.WriteLine(p); 1287 | 1288 | p = 10; 1289 | 1290 | Console.WriteLine(arr[0]); 1291 | } 1292 | } 1293 | 1294 | ``` 1295 | 1296 | 指向静态字段的托管指针[#](#指向静态字段的托管指针) 1297 | ---------------------------- 1298 | 1299 | ```null 1300 | class Program 1301 | { 1302 | static void Main() 1303 | { 1304 | 1305 | ref int p = ref Foo.StaticField; 1306 | 1307 | Console.WriteLine(p); 1308 | 1309 | p = 20; 1310 | 1311 | Console.WriteLine(Foo.StaticField); 1312 | } 1313 | } 1314 | 1315 | public class Foo 1316 | { 1317 | public static int StaticField; 1318 | } 1319 | 1320 | ``` 1321 | 1322 | 作为方法参数的托管指针[#](#作为方法参数的托管指针) 1323 | ---------------------------- 1324 | 1325 | 目前,我们有下面几种方法可以声明托管指针作为方法参数: 1326 | 1327 | **注意:托管指针本身是值传递,无法在方法内修改外部的托管指针的指向** 1328 | 1329 | 1. `ref` 关键字:表示参数是一个引用类型的托管指针,方法内部可以修改托管指针指向的外部变量。 1330 | 1331 | ```null 1332 | class Program 1333 | { 1334 | static void Main() 1335 | { 1336 | int a = 10; 1337 | int b = 20; 1338 | 1339 | ref int p1 = ref a; 1340 | ref int p2 = ref b; 1341 | 1342 | Modify(ref p1, ref p2); 1343 | 1344 | Console.WriteLine(a); 1345 | Console.WriteLine(b); 1346 | } 1347 | 1348 | static void Modify(ref int p1, ref int p2) 1349 | { 1350 | p1 = 11; 1351 | 1352 | p1 = ref p2; 1353 | 1354 | p1 = 22; 1355 | } 1356 | } 1357 | 1358 | ``` 1359 | 1360 | 2. `in` 关键字:表示参数是一个只读的托管指针,方法内部不能修改托管指针指向的外部变量。 1361 | 1362 | ```null 1363 | class Program 1364 | { 1365 | static void Main() 1366 | { 1367 | int a = 10; 1368 | int b = 20; 1369 | 1370 | ref int p1 = ref a; 1371 | ref int p2 = ref b; 1372 | 1373 | Modify(ref p1, ref p2); 1374 | 1375 | Console.WriteLine(a); 1376 | Console.WriteLine(b); 1377 | } 1378 | 1379 | static void Modify(in int p1, in int p2) 1380 | { 1381 | 1382 | p1 = ref p2; 1383 | } 1384 | } 1385 | 1386 | ``` 1387 | 1388 | 3. `out` 关键字:表示参数是一个输出参数,方法内部必须通过托管指针对其指向的外部变量赋值。 1389 | 1390 | ```null 1391 | class Program 1392 | { 1393 | static void Main() 1394 | { 1395 | int a = 10; 1396 | int b = 20; 1397 | 1398 | Modify(out a, out b); 1399 | 1400 | Console.WriteLine(a); 1401 | Console.WriteLine(b); 1402 | } 1403 | 1404 | static void Modify(out int p1, out int p2) 1405 | { 1406 | p1 = 11; 1407 | p2 = 22; 1408 | 1409 | p1 = ref p2; 1410 | } 1411 | } 1412 | 1413 | ``` 1414 | 1415 | 4. `readonly ref` 关键字:按目前的标准,作为参数时和 `in` 关键字的效果是一样的。 1416 | 1417 | ```null 1418 | class Program 1419 | { 1420 | static void Main() 1421 | { 1422 | int a = 10; 1423 | 1424 | ref int p = ref a; 1425 | 1426 | ModifyRef(ref p); 1427 | ModifyRefReadonly(ref p); 1428 | ModifyInt(in p); 1429 | } 1430 | 1431 | static void ModifyRef(ref int p) 1432 | { 1433 | Console.WriteLine(p); 1434 | p = 11; 1435 | } 1436 | 1437 | static void ModifyInt(in int p) 1438 | { 1439 | Console.WriteLine(p); 1440 | p = 11; 1441 | } 1442 | 1443 | static void ModifyRefReadonly(ref readonly int p) 1444 | { 1445 | Console.WriteLine(p); 1446 | p = 11; 1447 | } 1448 | } 1449 | 1450 | ``` 1451 | 1452 | 1453 | ref readonly 托管指针[#](#ref-readonly-托管指针) 1454 | ---------------------------------------- 1455 | 1456 | 在声明作为局部变量的托管指针时,可以使用 `ref readonly` 关键字,表示无法通过这个托管指针修改其指向的数据,但是可以修改托管指针的指向。 1457 | 1458 | ```null 1459 | class Program 1460 | { 1461 | static void Main() 1462 | { 1463 | int a = 10; 1464 | 1465 | 1466 | ref readonly int p1 = ref a; 1467 | 1468 | 1469 | 1470 | int b = 20; 1471 | 1472 | p1 = ref b; 1473 | 1474 | Console.WriteLine(p1); 1475 | Console.WriteLine(a); 1476 | } 1477 | } 1478 | 1479 | ``` 1480 | 1481 | 作为 ref struct 的字段的托管指针[#](#作为-ref-struct-的字段的托管指针) 1482 | -------------------------------------------------- 1483 | 1484 | `ref struct` ref struct 并非表示引用传递的结构体,而是表示具有类似于托管指针的限制,依然和普通的结构体一样是值传递。 1485 | 1486 | 在 `ref struct` 可以声明托管指针作为字段。 1487 | 1488 | **注意:只能在 `ref struct` 的构造函数中对 `ref 字段` 进行初始化,不支持初始化器初始化或者实例化完成之后的初始化,否则将触发 `NullReferenceException`。** 1489 | 1490 | ```null 1491 | using System.Runtime.CompilerServices; 1492 | 1493 | var foo = new Foo(); 1494 | 1495 | 1496 | 1497 | 1498 | 1499 | Console.WriteLine(Unsafe.IsNullRef(foo.Value)); 1500 | 1501 | 1502 | 1503 | 1504 | 1505 | int value = 1; 1506 | var bar = new Bar(ref value); 1507 | 1508 | Console.WriteLine(bar.Value); 1509 | 1510 | ref struct Foo 1511 | { 1512 | public ref int Value; 1513 | } 1514 | 1515 | ref struct Bar 1516 | { 1517 | public Bar(ref int value) 1518 | { 1519 | Value = ref value; 1520 | } 1521 | 1522 | public ref int Value; 1523 | } 1524 | 1525 | ``` 1526 | 1527 | 有几种方式可以声明 `ref struct` 的字段: 1528 | 1529 | 1. `ref` 关键字:表示字段是一个引用类型的托管指针,可以修改指针指向的数据以及修改指针的指向。 1530 | 1531 | ```null 1532 | var a = 1; 1533 | var foo = new Foo(ref a); 1534 | 1535 | Console.WriteLine(foo.Value); 1536 | 1537 | 1538 | foo.Value = 11; 1539 | 1540 | Console.WriteLine(a); 1541 | 1542 | 1543 | var b = 2; 1544 | 1545 | 1546 | foo.Value = ref b; 1547 | 1548 | Console.WriteLine(foo.Value); 1549 | 1550 | ref struct Foo 1551 | { 1552 | 1553 | public ref int Value; 1554 | 1555 | public Foo(ref int value) 1556 | { 1557 | 1558 | Value = ref value; 1559 | } 1560 | } 1561 | 1562 | ``` 1563 | 1564 | 2. `ref readonly` 关键字:表示字段是一个指向只读数据的托管指针,不能修改指针指向的数据,但可以修改指针的指向。 1565 | 1566 | ```null 1567 | var a = 1; 1568 | var foo = new Foo(ref a); 1569 | 1570 | Console.WriteLine(foo.Value); 1571 | 1572 | 1573 | 1574 | 1575 | var b = 2; 1576 | 1577 | foo.Value = ref b; 1578 | Console.WriteLine(foo.Value); 1579 | 1580 | ref struct Foo 1581 | { 1582 | 1583 | public ref readonly int Value; 1584 | 1585 | public Foo(ref int value) 1586 | { 1587 | 1588 | Value = ref value; 1589 | } 1590 | } 1591 | 1592 | ``` 1593 | 1594 | 3. `readonly ref` 关键字:表示字段是一个只读的托管指针,不能修改指针的指向,但可以修改指针指向的数据。 1595 | 1596 | ```null 1597 | var a = 1; 1598 | var foo = new Foo(ref a); 1599 | 1600 | Console.WriteLine(foo.Value); 1601 | 1602 | 1603 | foo.Value = 11; 1604 | Console.WriteLine(a); 1605 | 1606 | 1607 | var b = 2; 1608 | 1609 | 1610 | 1611 | ref struct Foo 1612 | { 1613 | 1614 | public readonly ref int Value; 1615 | 1616 | public Foo(ref int value) 1617 | { 1618 | 1619 | Value = ref value; 1620 | } 1621 | } 1622 | 1623 | ``` 1624 | 1625 | 4. `readonly ref readonly` 关键字:表示字段是一个指向只读数据的只读托管指针,不能修改指针的指向,也不能修改指针指向的数据。 1626 | 1627 | ```null 1628 | var a = 1; 1629 | var foo = new Foo(ref a); 1630 | 1631 | Console.WriteLine(foo.Value); 1632 | 1633 | 1634 | 1635 | int b = 2; 1636 | 1637 | 1638 | 1639 | ref struct Foo 1640 | { 1641 | 1642 | public readonly ref readonly int Value; 1643 | 1644 | public Foo(ref int value) 1645 | { 1646 | 1647 | Value = ref value; 1648 | } 1649 | } 1650 | 1651 | ``` 1652 | 1653 | 1654 | 托管指针受 GC 管理[#](#托管指针受-gc-管理) 1655 | ---------------------------- 1656 | 1657 | 托管指针受 GC 管理,不用关注指向的数据是否在 GC 过程中被移动。在 GC 过程中,托管指针会被自动更新为新的地址。 1658 | 1659 | 下面的例子中演示了用 指针(Pointer)和 托管指针(Managed Pointer)分别指向数组元素的情况。 1660 | 1661 | `GetArrayElementPointer` 方法中的数组对象在方法结束后失去了根引用,GC 会在下一次回收时将其回收。 1662 | 1663 | `GetArrayElementManagedPointer` 方法中的数组对象在方法结束后仍然**有托管指针作为根引用**,GC 不会回收它。 1664 | 1665 | ```null 1666 | unsafe class Program 1667 | { 1668 | static void Main() 1669 | { 1670 | Console.WriteLine("before GC"); 1671 | 1672 | 1673 | int* p1 = GetArrayElementPointer(out var wr1); 1674 | 1675 | 1676 | Console.WriteLine($"wr1.IsAlive: {wr1.IsAlive}"); 1677 | 1678 | Console.WriteLine($"*p1: {*p1}"); 1679 | 1680 | 1681 | ref int p2 = ref GetArrayElementManagedPointer(out var wr2); 1682 | 1683 | 1684 | Console.WriteLine($"wr2.IsAlive: {wr2.IsAlive}"); 1685 | 1686 | Console.WriteLine($"p2: {p2}"); 1687 | 1688 | GC.Collect(); 1689 | 1690 | Console.WriteLine(); 1691 | Console.WriteLine("after GC"); 1692 | 1693 | 1694 | Console.WriteLine($"wr1.IsAlive: {wr1.IsAlive}"); 1695 | 1696 | Console.WriteLine($"*p1: {*p1}"); 1697 | 1698 | 1699 | Console.WriteLine($"wr2.IsAlive: {wr2.IsAlive}"); 1700 | 1701 | Console.WriteLine($"p2: {p2}"); 1702 | } 1703 | 1704 | static int* GetArrayElementPointer(out WeakReference wr) 1705 | { 1706 | int[] arr = [1]; 1707 | 1708 | wr = new WeakReference(arr); 1709 | 1710 | fixed (int* p = &arr[0]) 1711 | { 1712 | return p; 1713 | } 1714 | } 1715 | 1716 | static ref int GetArrayElementManagedPointer(out WeakReference wr) 1717 | { 1718 | int[] arr = [2]; 1719 | 1720 | wr = new WeakReference(arr); 1721 | 1722 | return ref arr[0]; 1723 | } 1724 | } 1725 | 1726 | ``` 1727 | 1728 | Unsafe.AsRef 方法[#](#unsafeasref-方法) 1729 | ----------------------------------- 1730 | 1731 | `Unsafe.AsRef` 有两个重载: 1732 | 1733 | 1. `AsRef(Void*)`: 将非托管指针转换为指向 类型的 T值的托管指针。 1734 | 1735 | ```null 1736 | using System.Runtime.CompilerServices; 1737 | 1738 | unsafe class Program 1739 | { 1740 | static void Main() 1741 | { 1742 | int a = 10; 1743 | int* p = &a; 1744 | 1745 | 1746 | ref int p1 = ref Unsafe.AsRef(p); 1747 | 1748 | Console.WriteLine(p1); 1749 | 1750 | p1 = 20; 1751 | 1752 | Console.WriteLine(a); 1753 | } 1754 | } 1755 | 1756 | ``` 1757 | 1758 | 2. `AsRef(T)`: 将给定的 `ref readonly` 托管指针重新解释为可以修改指向的值的托管指针。 1759 | 1760 | 可以修改 `ref readonly` 托管指针指向的值。 1761 | 1762 | ```null 1763 | using System.Runtime.CompilerServices; 1764 | 1765 | class Program 1766 | { 1767 | static void Main() 1768 | { 1769 | int a = 10; 1770 | 1771 | 1772 | ref readonly int p1 = ref a; 1773 | 1774 | 1775 | ref int p2 = ref Unsafe.AsRef(p1); 1776 | 1777 | Console.WriteLine(p2); 1778 | 1779 | p2 = 20; 1780 | 1781 | Console.WriteLine(a); 1782 | Console.WriteLine(p1); 1783 | } 1784 | } 1785 | 1786 | ``` 1787 | 1788 | 也可以修改 `ref struct` 的 `ref readonly` 或 `readonly ref readonly` 字段的值。 1789 | 1790 | ```null 1791 | using System.Runtime.CompilerServices; 1792 | 1793 | var a = 1; 1794 | var foo = new Foo(ref a); 1795 | 1796 | Console.WriteLine(foo.Value); 1797 | 1798 | ref int p = ref Unsafe.AsRef(foo.Value); 1799 | 1800 | p = 11; 1801 | 1802 | Console.WriteLine(a); 1803 | 1804 | ref struct Foo 1805 | { 1806 | 1807 | public readonly ref readonly int Value; 1808 | 1809 | public Foo(ref int value) 1810 | { 1811 | 1812 | Value = ref value; 1813 | } 1814 | } 1815 | 1816 | ``` 1817 | 1818 | 1819 | 欢迎关注个人技术公众号 1820 | [![](https://img2023.cnblogs.com/blog/1201123/202303/1201123-20230302194546214-138980196.png) 1821 | ](https://img2023.cnblogs.com/blog/1201123/202303/1201123-20230302194546214-138980196.png) --------------------------------------------------------------------------------