2025年1月

一:背景

1. 讲故事

前段时间训练营里的一位朋友提了一个问题,我用ReadAsync做文件异步读取时,我知道在Win32层面会传 lpOverlapped 到内核层,那在内核层回头时,它是如何通过这个 lpOverlapped 寻找到 ReadAsync 这个异步的Task的呢?

这是一个好问题,这需要回答人对异步完整的运转流程有一个清晰的认识,即使有清晰的认识也不能很好的口头表述出来,就算表述出来对方也不一定能听懂,所以干脆开两篇文章来尝试解读一下吧。

二:lpOverlapped 如何映射

1. 测试案例

为了能够讲清楚,我们先用
fileStream.ReadAsync
方法来写一段异步读取来产生Overlapped,参考代码如下:


        static void Main(string[] args)
        {
            UseAwaitAsync();
            Console.ReadLine();
        }

        static async Task<string> UseAwaitAsync()
        {
            string filePath = "D:\\dumps\\trace-1\\GenHome.DMP";
            Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} 请求发起...");
            FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 16, useAsync: true);
            {
                byte[] buffer = new byte[fileStream.Length];

                int bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length);

                string content = Encoding.UTF8.GetString(buffer, 0, bytesRead);

                var query = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} 获取到结果:{content.Length}";

                Console.WriteLine(query);

                return query;
            }
        }

很显然上面的方法会调用 Win32 中的 ReadFile,接下来上一下它的签名和 _OVERLAPPED 结构体。


BOOL ReadFile(
  [in]                HANDLE       hFile,
  [out]               LPVOID       lpBuffer,
  [in]                DWORD        nNumberOfBytesToRead,
  [out, optional]     LPDWORD      lpNumberOfBytesRead,
  [in, out, optional] LPOVERLAPPED lpOverlapped
);

typedef struct _OVERLAPPED {
  ULONG_PTR Internal;
  ULONG_PTR InternalHigh;
  union {
    struct {
      DWORD Offset;
      DWORD OffsetHigh;
    } DUMMYSTRUCTNAME;
    PVOID Pointer;
  } DUMMYUNIONNAME;
  HANDLE    hEvent;
} OVERLAPPED, *LPOVERLAPPED;

2. 寻找映射的两端

既然是映射嘛,肯定要找到两个端口,即非托管层的 NativeOverlapped 和 托管层的 ThreadPoolBoundHandleOverlapped。

  1. 非托管 _OVERLAPPED

在 C# 中用 NativeOverlapped 结构体表示 Win32 的 _OVERLAPPED 结构,参考如下:


public struct NativeOverlapped
{
	public nint InternalLow;
	public nint InternalHigh;
	public int OffsetLow;
	public int OffsetHigh;
	public nint EventHandle;
}

  1. 托管 ThreadPoolBoundHandleOverlapped

ReadAsync 所产生的
Task<int>
在底层是经过ValueTask, OverlappedValueTaskSource 一阵痉挛后弄出来的,最后会藏匿在 Overlapped 子类的 ThreadPoolBoundHandleOverlapped 中,参考代码和模型图如下:


        public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
        {
            ValueTask<int> valueTask = this.ReadAsync(new Memory<byte>(buffer, offset, count), cancellationToken);
            if (!valueTask.IsCompletedSuccessfully)
            {
                return valueTask.AsTask();
            }
            return this._lastSyncCompletedReadTask.GetTask(valueTask.Result);
        }

        private unsafe static ValueTuple<SafeFileHandle.OverlappedValueTaskSource, int> QueueAsyncReadFile(SafeFileHandle handle, Memory<byte> buffer, long fileOffset, CancellationToken cancellationToken, OSFileStreamStrategy strategy)
        {
            SafeFileHandle.OverlappedValueTaskSource overlappedValueTaskSource = handle.GetOverlappedValueTaskSource();
            
            NativeOverlapped* ptr = overlappedValueTaskSource.PrepareForOperation(buffer, fileOffset, strategy);
            if (Interop.Kernel32.ReadFile(handle, (byte*)overlappedValueTaskSource._memoryHandle.Pointer, buffer.Length, IntPtr.Zero, ptr) == 0)
            {
                overlappedValueTaskSource.RegisterForCancellation(cancellationToken);
            }
            overlappedValueTaskSource.FinishedScheduling();
            return new ValueTuple<SafeFileHandle.OverlappedValueTaskSource, int>(overlappedValueTaskSource, -1);
        }

最后就是两端的映射关系了,先通过 malloc 分配了一块私有内存,中间隔了一个refcount 的 8byte大小,模型图如下:

3. 眼见为实

要想眼见为实,可以从C#源码中的
Overlapped.AllocateNativeOverlapped
方法寻找答案。


    public unsafe class Overlapped
    {
        private NativeOverlapped* AllocateNativeOverlapped(object? userData)
        {
            NativeOverlapped* pNativeOverlapped = null;

            nuint handleCount = 1;

            pNativeOverlapped = (NativeOverlapped*)NativeMemory.Alloc((nuint)(sizeof(NativeOverlapped) + sizeof(nuint)) + handleCount * (nuint)sizeof(GCHandle));

            GCHandleCountRef(pNativeOverlapped) = 0;

            pNativeOverlapped->InternalLow = default;
            pNativeOverlapped->InternalHigh = default;
            pNativeOverlapped->OffsetLow = _offsetLow;
            pNativeOverlapped->OffsetHigh = _offsetHigh;
            pNativeOverlapped->EventHandle = _eventHandle;

            GCHandleRef(pNativeOverlapped, 0) = GCHandle.Alloc(this);
            GCHandleCountRef(pNativeOverlapped)++;

            return pRet;
        }

        private static ref nuint GCHandleCountRef(NativeOverlapped* pNativeOverlapped)
                               => ref *(nuint*)(pNativeOverlapped + 1);

        private static ref GCHandle GCHandleRef(NativeOverlapped* pNativeOverlapped, nuint index)
                              => ref *((GCHandle*)((nuint*)(pNativeOverlapped + 1) + 1) + index);
    }

卦中代码先用
NativeMemory.Alloc
方法分配了一块私有内存,随后还把 Overlapped 给 GCHandle.Alloc 住了,这是防止异步期间对象被移动,有了代码接下来上windbg去眼见为实,在
Kernel32!ReadFile
中下断点观察方法的第五个参数。


0:000> bp Kernel32!ReadFile
0:000> g
Breakpoint 0 hit
KERNEL32!ReadFile:
00007ffd`fa2f56a0 ff25caca0500    jmp     qword ptr [KERNEL32!_imp_ReadFile (00007ffd`fa352170)] ds:00007ffd`fa352170={KERNELBASE!ReadFile (00007ffd`f85c5520)}
0:000> k 5
 # Child-SP          RetAddr               Call Site
00 000000ff`8837e1c8 00007ffd`96229ce3     KERNEL32!ReadFile
01 000000ff`8837e1d0 00007ffd`96411a4a     System_Private_CoreLib!Interop.Kernel32.ReadFile+0xa3 [/_/src/coreclr/System.Private.CoreLib/Microsoft.Interop.LibraryImportGenerator/Microsoft.Interop.LibraryImportGenerator/LibraryImports.g.cs @ 6797] 
02 000000ff`8837e2d0 00007ffd`96411942     System_Private_CoreLib!System.IO.RandomAccess.QueueAsyncReadFile+0x8a
03 000000ff`8837e350 00007ffd`96433677     System_Private_CoreLib!System.IO.RandomAccess.ReadAtOffsetAsync+0x112 [/_/src/libraries/System.Private.CoreLib/src/System/IO/RandomAccess.Windows.cs @ 238] 
04 000000ff`8837e3f0 00007ffd`9642d5f8     System_Private_CoreLib!System.IO.Strategies.OSFileStreamStrategy.ReadAsync+0xb7 [/_/src/libraries/System.Private.CoreLib/src/System/IO/Strategies/OSFileStreamStrategy.cs @ 290] 
0:000> uf 00007ffd`96229ce3
...
 6797 00007ffd`96229c98 4c8b7d30        mov     r15,qword ptr [rbp+30h]
 6797 00007ffd`96229c9c 4c897c2420      mov     qword ptr [rsp+20h],r15
 6797 00007ffd`96229ca1 498bce          mov     rcx,r14
 6797 00007ffd`96229ca4 48894dac        mov     qword ptr [rbp-54h],rcx
 6797 00007ffd`96229ca8 488bd3          mov     rdx,rbx
 6797 00007ffd`96229cab 488955a4        mov     qword ptr [rbp-5Ch],rdx
 6797 00007ffd`96229caf 448bc6          mov     r8d,esi
 6797 00007ffd`96229cb2 448945b4        mov     dword ptr [rbp-4Ch],r8d
 6797 00007ffd`96229cb6 4c8bcf          mov     r9,rdi
 6797 00007ffd`96229cb9 4c894d9c        mov     qword ptr [rbp-64h],r9
 6797 00007ffd`96229cbd 488d8d40ffffff  lea     rcx,[rbp-0C0h]
 6797 00007ffd`96229cc4 ff159e909e00    call    qword ptr [System_Private_CoreLib!Interop.CallStringMethod+0x5ab9c8 (00007ffd`96c12d68)]
 6797 00007ffd`96229cca 488b055708a100  mov     rax,qword ptr [System_Private_CoreLib!Interop.CallStringMethod+0x5d3188 (00007ffd`96c3a528)]
 6797 00007ffd`96229cd1 488b4dac        mov     rcx,qword ptr [rbp-54h]
 6797 00007ffd`96229cd5 488b55a4        mov     rdx,qword ptr [rbp-5Ch]
 6797 00007ffd`96229cd9 448b45b4        mov     r8d,dword ptr [rbp-4Ch]
 6797 00007ffd`96229cdd 4c8b4d9c        mov     r9,qword ptr [rbp-64h]
 6797 00007ffd`96229ce1 ff10            call    qword ptr [rax]
 6797 00007ffd`96229ce3 8bd8            mov     ebx,eax

仔细阅读卦中的汇编代码,通过这句
r15,qword ptr [rbp+30h]
可知 pNativeOverlapped 是保存在
r15
寄存器中。


0:000> r r15
r15=00000241ca2d4d70
0:000> dp 00000241ca2d4d70
00000241`ca2d4d70  00000000`00000000 00000000`00000000
00000241`ca2d4d80  00000000`00000000 00000000`00000000
00000241`ca2d4d90  00000000`00000001 00000241`c8761358

根据上面的模型图,
00000241ca2d4d90
保存的是引用计数,
00000241c8761358
就是我们的
ThreadPoolBoundHandleOverlapped
,可以 !do 它一下便知。

最后用 dnspy 在
Overlapped.GetOverlappedFromNative
方法中下一个断点,这个方法会在异步处理完成后,执行NativeOverlapped寻址ThreadPoolBoundHandleOverlapped 的逻辑,截图如下,那个 ReadAsync保存在内部的 _continuationState 字段里。

三:总结

C#的传统做法大多都是采用传参数的方式来建议映射关系,而本篇中用 malloc 开辟一块私有区域来映射两者的关系也真是独一份,实属无奈!
图片名称

1.简介

本来按照计划这一系列的文章应该介绍Context和Page两个内容的,但是宏哥看了官方文档和查找资料发现其实和宏哥在Python+Playwright系列文章中的大同小异,差不了多少,再在这一个系列介绍就有点画蛇添足,索性就不介绍和讲解了,有兴趣的自己可以看宏哥之前写的,或者自己查找资料和官方文档进行了解和学习。今天讲解和分享的标签操作其实也是基于浏览器上下文(
BrowserContext
)进行操作的,而且宏哥在之前的BrowserContext也有提到过,但是有的童鞋或者小伙伴还是不清楚怎么操作,或者思路有点模糊,因此今天单独来对其进行讲解和分享一下,希望您有所帮助。

2.什么是tab标签页

Tabs 标签页又称选项卡(以下简称标签页),它是一种高效的屏幕空间利用手段,映射非常接近卡片的目录索引,用户可以基于索引标签,快速定位到目标中内容中去,这也是大多数用户来自现实世界的经验。

在 Web 页面中,它的使用场景也较为简单,当页面的内容信息量较多,用标签页可以对其分类,一方面可以提升查找信息的效率,另一方面可以精简用户单次获取到的信息量,用户更能够专注于当前已显示的内容。

‌标签页(Tab)是一种用户界面元素,用于组织和管理网页或应用程序中的内容,允许用户在不同的视图或数据集之间轻松切换。‌

标签页的设计灵感来源于现实生活中文件夹上的标签,通过隐喻的方式,设计师希望用户能够通过直观的方式理解和使用这一交互形式。

3.单标签页

单个标签操作这个是最简单的,之前讲的绝大多数都是单个标签的操作。通过context.new_page()就可以创建一个页面。

每个BrowserContext可以有多个页面。页面是指浏览器上下文中的单个选项卡或弹出窗口。它应该用于导航到URL并与页面内容交互。

//Create a page.
Page page =context.newPage();//Navigate explicitly, similar to entering a URL in the browser.
page.navigate("http://example.com");//Fill an input.
page.locator("#search").fill("query");//Navigate implicitly by clicking a link.
page.locator("#submit").click();//Expect a new url.
System.out.println(page.url());

实战举例:以度娘为例,首先启动浏览器,然后再设置浏览器的大小。查询“北京宏哥”后,刷新页面后执行回退操作到百度首页,然后有执行前进操作进入到搜索“北京宏哥”页面,最后退出浏览器。

3.1代码设计

按照上边的步骤进行代码设计,如下图所示:

3.2参考代码

packagecom.bjhg.playwright;importcom.microsoft.playwright.Browser;importcom.microsoft.playwright.BrowserContext;importcom.microsoft.playwright.BrowserType;importcom.microsoft.playwright.Locator;importcom.microsoft.playwright.Page;importcom.microsoft.playwright.Playwright;/***@author北京-宏哥
*
* @公众号:北京宏哥(微信搜索,关注宏哥,提前解锁更多测试干货)
*
* 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-10- 标签页(tab)操作 (详细教程)
*
* 2024年8月26日
*/ public classTest_Search {public static voidmain(String[] args) {try (Playwright playwright =Playwright.create()) {//1.使用chromium浏览器,# 浏览器配置,设置以GUI模式启动Chrome浏览器(要查看浏览器UI,在启动浏览器时传递 headless=false 标志。您还可以使用 slowMo 来减慢执行速度。 Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(500));//2.设置浏览器窗口大小 BrowserContext context = browser.newContext(new Browser.NewContextOptions().setViewportSize(1280, 1024));//创建page Page page =context.newPage();//3.浏览器打开百度 page.navigate("https://www.baidu.com/");//判断title是不是 百度一下,你就知道 try{
String baidu_title
= "百度一下,你就知道";assert baidu_title ==page.title();
System.out.println(
"Test Pass");

}
catch(Exception e){
e.printStackTrace();
}
//使用xpath属性定位百度首页输入框 ,并输入搜索内容:北京-宏哥 page.locator("//*[@id='kw']").type("北京-宏哥");//使用路径与属性结合定位“百度一下”按钮,并点击 。 page.locator("//span/input[@id='su']").click();//5.刷新页面 page.reload();//6.浏览器后退 page.goBack();//7.浏览器前进 page.goForward();//关闭page page.close();//关闭browser browser.close();
}
}

}

3.3运行代码

1.运行代码,右键Run'Test',就可以看到控制台输出,如下图所示:

2.运行代码后电脑端的浏览器的动作。如下图所示:

4.多标签页

每个浏览器上下文可以承载多个页面(选项卡)。

  • 每个页面都像一个聚焦的活动页面。不需要将页面置于最前面。
  • 上下文中的页面遵循上下文级别的模拟,例如视口大小、自定义网络路由或浏览器区域设置。

// Create two pages
Page pageOne = context.newPage();
Page pageTwo = context.newPage();

// Get pages of a browser context
List<Page> allPages = context.pages();

实战举例:在page_one 标签页打开百度,输入“北京-宏哥”, 在page_two 标签页打开百度,输入“宏哥”。

4.1代码设计

按照上边的步骤进行代码设计,如下图所示:

4.2参考代码

packagecom.bjhg.playwright;importcom.microsoft.playwright.Browser;importcom.microsoft.playwright.BrowserContext;importcom.microsoft.playwright.BrowserType;importcom.microsoft.playwright.Locator;importcom.microsoft.playwright.Page;importcom.microsoft.playwright.Playwright;/***@author北京-宏哥
*
* @公众号:北京宏哥(微信搜索,关注宏哥,提前解锁更多测试干货)
*
* 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-10- 标签页(tab)操作 (详细教程)
*
* 2024年8月26日
*/ public classTest_Search {public static voidmain(String[] args) {try (Playwright playwright =Playwright.create()) {//1.使用chromium浏览器,# 浏览器配置,设置以GUI模式启动Chrome浏览器(要查看浏览器UI,在启动浏览器时传递 headless=false 标志。您还可以使用 slowMo 来减慢执行速度。 Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(500));//2.设置浏览器窗口大小 BrowserContext context = browser.newContext(new Browser.NewContextOptions().setViewportSize(1280, 1024));//创建page Page page1 =context.newPage();//3.浏览器打开百度 page1.navigate("https://www.baidu.com/");//使用xpath属性定位百度首页输入框 ,并输入搜索内容:北京-宏哥 page1.locator("//*[@id='kw']").type("北京-宏哥");

Page page2
=context.newPage();//3.浏览器打开百度 page2.navigate("https://www.baidu.com/");//使用xpath属性定位百度首页输入框 ,并输入搜索内容:北京-宏哥 page2.locator("//*[@id='kw']").type("宏哥");//关闭page page1.close();
page2.close();
//关闭browser browser.close();
}
}

}

4.3运行代码

1.运行代码,右键Run'Test',就可以看到控制台输出,如下图所示:

2.运行代码后电脑端的浏览器的动作。如下图所示:

5.小结

好了,关于标签页(tab)的相关操作非常简单,时间不早了今天就分享到这里,感谢你耐心地阅读!

AIModelRouter

AI模型路由,模型的能力有大小之分,有些简单任务,能力小一点的模型也能很好地完成,而有些比较难的或者希望模型做得更好的,则可以选择能力强的模型。为什么要这样做呢?可以降低AI模型的使用成本,毕竟能力强的模型会更贵一点,省着用挺好的。

Semantic Kernel中可以很简便地使用一个AIModelRouter。

实践

先来一个简单的例子

来自https://github.com/microsoft/semantic-kernel/tree/main/dotnet/samples/Demos/AIModelRouter

新建一个CustomRouter类,如下所示:

internal sealed class CustomRouter()
{
    internal string GetService(string lookupPrompt, List<string> serviceIds)
    {
        // The order matters, if the keyword is not found, the first one is used.
        foreach (var serviceId in serviceIds)
        {
            if (Contains(lookupPrompt, serviceId))
            {
                return serviceId;
            }
        }

        return serviceIds[0];
    }

    // Ensure compatibility with both netstandard2.0 and net8.0 by using IndexOf instead of Contains
    private static bool Contains(string prompt, string pattern)
        => prompt.IndexOf(pattern, StringComparison.CurrentCultureIgnoreCase) >= 0;
}

新建一个SelectedServiceFilter类用于打印一些信息:

 internal sealed class SelectedServiceFilter : IPromptRenderFilter
 {
     /// <inheritdoc/>
     public Task OnPromptRenderAsync(PromptRenderContext context, Func<PromptRenderContext, Task> next)
     {
         Console.ForegroundColor = ConsoleColor.Yellow;
         Console.WriteLine($"Selected service id: '{context.Arguments.ExecutionSettings?.FirstOrDefault().Key}'");

         Console.ForegroundColor = ConsoleColor.White;
         Console.Write("Assistant > ");
         return next(context);
     }
 }

使用多个模型:

image-20250106101815911

为捕获路由器选择的服务 ID 添加自定义过滤器:

image-20250106101942229

开启一个聊天循环:

        Console.ForegroundColor = ConsoleColor.White;

        ChatHistory history = [];
        string history1 = string.Empty;
        bool isComplete = false;

        do
        {
            Console.WriteLine();
            Console.Write("> ");
            string? input = Console.ReadLine();
            if (string.IsNullOrWhiteSpace(input))
            {
                continue;
            }
            if (input.Trim().Equals("EXIT", StringComparison.OrdinalIgnoreCase))
            {
                isComplete = true;
                break;
            }
            if (input.Trim().Equals("Clear", StringComparison.OrdinalIgnoreCase))
            {
                history.Clear();
                history1 = " ";
                Console.WriteLine("已清除聊天记录");
                continue;
            }

            history.Add(new ChatMessageContent(AuthorRole.User, input));
            history1 += $"User:{input}\n";

            Console.WriteLine();

            // Find the best service to use based on the user's input
            KernelArguments arguments = new(new PromptExecutionSettings()
            {
                ServiceId = router.GetService(input, serviceIds).Result,
                FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
            });

            // Invoke the prompt and print the response
            //await foreach (var chatChunk in kernel.InvokePromptStreamingAsync(userMessage, arguments).ConfigureAwait(false))
            //{
            //    Console.Write(chatChunk);
            //}
           
            var result = await kernel.InvokePromptAsync(history1, arguments).ConfigureAwait(false);
            Console.ForegroundColor = ConsoleColor.White;
            Console.WriteLine(result);
            Console.WriteLine();

            // Add the message from the agent to the chat history
            history.AddMessage(AuthorRole.Assistant, result.ToString());
            history1 += $"Assistant:{result}\n";
        } while (!isComplete);
    }
}

来看看现在这个简单的路由规则:

image-20250106102824888

当你的提问中包含一个ServiceId的时候,就会选择那个服务ID对应的模型进行回复,如果不包含就选择第一个服务ID对应的模型进行回复。

实际上这样使用,很容易让AI迷惑,因为我们总是要带上一个ServiceId,如果让AI根据用户的提问,自己决定用哪个模型是更好的。

进阶使用,用AI自己来决定

image-20250106103343454

使用一个靠谱的AI模型来做这个事情比较好。

我们输入你好,那么Prompt就会变成这样:

image-20250106103624167

AI返回的结果如下:

image-20250106103713305

image-20250106103742224

再试试其他几个怎么触发:

image-20250106103848889

而工具调用与其他比较容易混淆,因为就算是我们自己,也很难分辨有什么区别:

image-20250106104310185

这时候或许修改Prompt可以奏效。

修改后的Prompt如下:

 string skPrompt = """
          根据用户的输入,返回最佳服务ID。
          如果用户需要获取当前时间与写邮件,则选择工具调用相关的服务ID。
          用户输入:
          {{$input}}
          服务ID列表:
          {{$serviceIds}}
          无需返回任何其他内容,只需返回服务ID。              
     """;

效果如下所示:

image-20250106113558077

以上就是本次分享的全部内容,希望对你有所帮助。

1、下载gitlab

首先在
/etc/yum.repos.d/
目录下配置
gitlab
下载镜像源。

# 进入目录 /etc/yum.repos.d/
cd /etc/yum.repos.d/

# 创建文件 gitlab-ce.repo 
vim gitlab-ce.repo

# 添加以下内容
[gitlab-ce]
name=gitlab-ce
baseurl=https://mirror.tuna.tsinghua.edu.cn/gitlab-ce/yum/el7/
gpgcheck=0
enabled=1

# 清空缓存 && 更新缓存
yum clean all && yum makecache

# 配置成功后下载gitlab
yum install -y  gitlab-ce-16.9.9-ce.0.el7.x86_64

# 下载最新版
yum install -y gitlab-ce 

2、安装配置

2.1、修改配置文件

#修改配置文件
vim /etc/gitlab/gitlab.rb

# 修改访问路径:
external_url 'http://xxx.xxx.xxx.xxx:9010'

2.2、启动

# 要使用命令重载一下配置文件
gitlab-ctl reconfigure 

# 重新启动
gitlab-ctl restart

# 查看各个组件状态
gitlab-ctl status
# 输出以下信息启动成功
run: alertmanager: (pid 24037) 413s; run: log: (pid 23539) 470s
run: gitaly: (pid 23986) 417s; run: log: (pid 22354) 613s
run: gitlab-exporter: (pid 23981) 417s; run: log: (pid 23371) 489s
run: gitlab-kas: (pid 22620) 598s; run: log: (pid 22635) 597s
run: gitlab-workhorse: (pid 23958) 419s; run: log: (pid 23183) 509s
run: logrotate: (pid 22194) 628s; run: log: (pid 22233) 625s
run: nginx: (pid 23965) 419s; run: log: (pid 23260) 501s
run: node-exporter: (pid 23973) 418s; run: log: (pid 23307) 497s
run: postgres-exporter: (pid 24061) 413s; run: log: (pid 23578) 465s
run: postgresql: (pid 22415) 604s; run: log: (pid 22433) 603s
run: prometheus: (pid 24003) 416s; run: log: (pid 23483) 477s
run: puma: (pid 23065) 522s; run: log: (pid 23083) 519s
run: redis: (pid 22264) 622s; run: log: (pid 22282) 618s
run: redis-exporter: (pid 23995) 417s; run: log: (pid 23429) 483s
run: sidekiq: (pid 23099) 516s; run: log: (pid 23140) 513s

2.3、访问

启动成功后,开放
9010端口

浏览器访问地址,发现启动成功

2.4、注册账号

点击立即注册,注册账号,输入账号密码

账号:sowler
密码:aqwe1235

2.5、登录

注册成功后,开始登录,输入账号和密码后报错:

通过上面的这段报错内容可以看到错误原因

Your account is pending approval from your GitLab administrator and hence blocked. Please contact your GitLabadministrator if you think this is an error

#翻译后
您的帐户正在等待您的GitLab管理员的批准,因此被封锁。如果您认为这是一个错误,请联系您的gitlab管理员

需要管理员批准,通过查找发现gitlab会内置一个管理员账号。默认账号为:root ,执行以下命令查看root的账号密码

[root@linux-servertwo opt]#  cat /etc/gitlab/initial_root_password
# WARNING: This value is valid only in the following conditions
#          1. If provided manually (either via `GITLAB_ROOT_PASSWORD` environment variable or via `gitlab_rails['initial_root_password']` setting in `gitlab.rb`, it was provided before database was seeded for the first time (usually, the first reconfigure run).
#          2. Password hasn't been changed manually, either via UI or via command line.
#
#          If the password shown here doesn't work, you must reset the admin password following https://docs.gitlab.com/ee/security/reset_user_password.html#reset-your-root-password.

Password: KNARZgiUjFtm/IOELqQWiR4P2ds7+xK715CxNQpOyU4=

# NOTE: This file will be automatically deleted in the first reconfigure run after 24 hours.

由此可以看出用户名就是root,密码就是上面的Password,通过以上账号再次进行登录。

发现登录成功。根据页面提示,可以点击 Deactivate 禁用注册功能。

2.6、设置中文

点击登录用户头像,选择下图所示的菜单。

进入以下页面,选择中文简体语言。

保存成功后,刷新页面,发现文字已经更新

2.7、更新管理员密码

设置好中文后,接下来就改尽快更新管理员密码了因为根据上面查看密码提示信息发现以上生成的管理员密码文件会在24小时后第一次重新配置时自动删除。

当前密码就是
/etc/gitlab/initial_root_password
文件的初始化密码,然后输入新密码进行更新。新密码:
aqwe1235

2.8、关闭注册功能

点击管理中心

找到设置

取消以下勾选

点击下方保存按钮

访问登录页面,发现注册功能已经取消。

2.9、激活账号

接下来激活刚刚创建的账号信息

点击管理中心,进入以下页面

点击等待批准菜单,显示未激活的账号列表,点击批准按钮进行激活

激活成功后,在激活列表展示已经激活的用户账号信息

激活后,使用
sowler
账号登录,发现登录成功。

2.10、管理员账号注册用户

首先使用
root
账号登录
gitlab
,进入管理中心

点击用户->新建用户

创建成功后,激活列表会显示用户信息

点击编辑,初始密码用户密码

当第一次登录时,由用户进行修改密码

切换用户账号进行登录,进行修改密码

2.11、邮件通知发送

修改
gitlab
配置
/etc/gitlab/gitlab.rb
启动邮件通知

vim /etc/gitlab/gitlab.rb

# 启用SMTP
gitlab_rails['smtp_enable'] = true
# SMTP服务器配置
gitlab_rails['smtp_address'] = "smtp.example.com"  # 替换为你的SMTP服务器地址
gitlab_rails['smtp_port'] = 465                  # SMTP端口,一般是587(TLS)或465(SSL)
gitlab_rails['smtp_user_name'] = "your-email@example.com"  # SMTP用户名
gitlab_rails['smtp_password'] = "your-password"    # SMTP密码
gitlab_rails['smtp_domain'] = "example.com"        # 邮件域名
gitlab_rails['smtp_authentication'] = "login"      # 认证方式
#gitlab_rails['smtp_enable_starttls_auto'] = true   # 启用TLS
gitlab_rails['smtp_tls'] = true  

# 配置成功后,查询加载配置
gitlab-ctl reconfigure
gitlab-ctl restart
 
# 测试邮件配置
# 进入GitLab Rails控制台
[root@linux-servertwo gitlab]#  gitlab-rails console -e production
--------------------------------------------------------------------------------
 Ruby:         ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [x86_64-linux]
 GitLab:       16.9.9 (ed54f379d9b) FOSS
 GitLab Shell: 14.33.0
 PostgreSQL:   14.11
------------------------------------------------------------[ booted in 56.85s ]
Loading production environment (Rails 7.0.8)
irb(main):001:0> 

# 发送测试邮件
Notify.test_email('test@example.com', 'Message Subject', 'Message Body').deliver_now
Notify.test_email('1024409453@qq.com', 'GitLab注册通知', '您注册的Gitlab账号已成功,欢迎登录查看。这是一封测试邮件').deliver_now

如果遇到问题,可以查看日志文件:

# 查看所有GitLab日志
gitlab-ctl tail

# 只查看邮件相关日志
gitlab-ctl tail gitlab-rails

# 第一次没有配置成功,连接超时了
irb(main):002:0> Notify.test_email('1024409453@qq.com', 'GitLab注册通知', '您注册的Gitlab账号已成功,欢迎登录查看。这是一封测试邮件').deliver_now
Delivered mail 676e255dc9029_76b52d00328@linux-servertwo.mail (30024.4ms)
/opt/gitlab/embedded/lib/ruby/gems/3.1.0/gems/net-smtp-0.3.3/lib/net/smtp.rb:645:in `rescue in tcp_socket': Timeout to open TCP connection to smtp.163.com:456 (exceeds 30 seconds) (Net::OpenTimeout)
/opt/gitlab/embedded/lib/ruby/3.1.0/socket.rb:61:in `connect_internal': Connection timed out - user specified timeout (Errno::ETIMEDOUT)
irb(main):003:0> 

# 再次发送
irb(main):001:0> Notify.test_email('1024409453@qq.com', 'GitLab注册通知', '您注册的Gitlab账号已成功,欢迎登录查看。这是一封测试邮件').deliver_now
Delivered mail 676e312ae2e23_3bad2d00104f9@linux-servertwo.mail (616.7ms)
/opt/gitlab/embedded/lib/ruby/gems/3.1.0/gems/net-smtp-0.3.3/lib/net/smtp.rb:1076:in `check_response': 553 Mail from must equal authorized user (Net::SMTPFatalError)

# 设置发件人
# 发件人邮箱设置 - 使用163邮箱
gitlab_rails['gitlab_email_from'] = 'gitlab_test@163.com'      # 必须和smtp_user_name一致
gitlab_rails['gitlab_email_display_name'] = 'GitLab DevOps'         
gitlab_rails['gitlab_email_reply_to'] = 'gitlab_test@163.com'  # 保持一致
gitlab_rails['gitlab_email_subject_suffix'] = '[GitLab-DevOps]'  


# 以上配置成功后,再次发送,发送成功
irb(main):001:0> Notify.test_email('1024409453@qq.com', 'GitLab注册通知', '您注册的Gitlab账号已成功,欢迎登录查看。这是一封测试邮件').deliver_now
Delivered mail 676e335528c0b_4a412d00629c9@linux-servertwo.mail (852.2ms)
=> #<Mail::Message:375740, Multipart: false, Headers: <Date: Fri, 27 Dec 2024 12:55:49 +0800>, <From: GitLab DevOps <sowlerblogs@163.com>>, <Reply-To: GitLab DevOps <gitlabe@test.com>>, <To: 1024409453@qq.com>, <Message-ID: <676e335528c0b_4a412d00629c9@linux-servertwo.mail>>, <Subject: GitLab注册通知>, <Mime-Version: 1.0>, <Content-Type: text/html; charset=UTF-8>, <Content-Transfer-Encoding: 7bit>, <Auto-Submitted: auto-generated>, <X-Auto-Response-Suppress: All>>

邮件列表已经显示

查看邮件

企业邮箱示例

gitlab_rails['gitlab_email_from'] = 'gitlab@yourdomain.com'

gitlab_rails['gitlab_email_display_name'] = 'GitLab DevOps'

gitlab_rails['gitlab_email_reply_to'] = 'support@yourdomain.com'

gitlab_rails['gitlab_email_subject_suffix'] = '[GitLab-DevOps]'

2.12、备份与恢复

把提交的代码和操作的数据进行备份是很主要的,不然后面数据遗失了损失很大,由其对于公司来说。所以强烈建议定期备份数据文件或者编写脚本自动备份数据。数据备份不仅保证了数据安全可控,在
gitlab
迁移到另一台服务器时,使用最佳方法就是通过备份和还原。

2.12.1、备份

手动备份

Gitlab
提供了一个简单的命令行来备份整个
Gitlab
,并且能灵活的满足需求。使用如下命令,备份数据到
/var/opt/gitlab/backups
目录下

[root@linux-servertwo gitlab]# gitlab-rake gitlab:backup:create
2024-12-27 05:40:07 UTC -- Dumping database ... 
Dumping PostgreSQL database gitlabhq_production ... [DONE]
2024-12-27 05:40:13 UTC -- Dumping database ... done
2024-12-27 05:40:13 UTC -- Dumping repositories ... 
2024-12-27 05:40:13 UTC -- Backup 1735278007_2024_12_27_16.9.9 is done.
2024-12-27 05:40:13 UTC -- Deleting backup and restore PID file ... done

进入
/var/opt/gitlab/backups
目录查看备份文件

[root@linux-servertwo backups]# ls -l
total 720
-rw------- 1 git git 737280 Dec 27 13:40 1735278007_2024_12_27_16.9.9_gitlab_backup.tar

更改Gitlab备份目录

# 更改/etc/gitlab/gitlab.rb文件
vim /etc/gitlab/gitlab.rb

# Backup settings 数据存放的路径、权限、时间配置
gitlab_rails['manage_backup_path'] = true              # 开启备份功能
gitlab_rails['backup_path'] = "/data/gitlab/backups"    # 自定义备份路径
gitlab_rails['backup_archive_permissions'] = 0644        # 备份文件权限
gitlab_rails['backup_keep_time'] = 604800               # 备份保留时间(单位:秒),这里是7天

# 重新加载配置 
gitlab-ctl reconfigure

# 重启服务
gitlab-ctl restart

自定义备份目录需要创建备份目录并赋予目录git权限

# 创建备份目录
mkdir -p /data/gitlab/backups
# 设置目录所有者
chown -R git:git /data/gitlab/backups
# 设置目录权限
chmod 755 /data/gitlab/backups

# 开始备份
[root@linux-servertwo gitlab]# gitlab-rake gitlab:backup:create
2024-12-27 06:05:08 UTC -- Dumping database ... 
Dumping PostgreSQL database gitlabhq_production ... [DONE]
2024-12-27 06:05:36 UTC -- Dumping database ... done
2024-12-27 06:05:36 UTC -- Dumping repositories ... 
2024-12-27 06:05:36 UTC -- Warning: Your gitlab.rb and gitlab-secrets.json files contain sensitive data 
and are not included in this backup. You will need these files to restore a backup.
Please back them up manually.
2024-12-27 06:05:36 UTC -- Backup 1735279508_2024_12_27_16.9.9 is done.
2024-12-27 06:05:36 UTC -- Deleting backup and restore PID file ... done

# 查看自定义的备份目录
[root@linux-servertwo gitlab]# ls -l /data/gitlab/backups
total 720
-rw-r--r-- 1 git git 737280 Dec 27 14:05 1735279508_2024_12_27_16.9.9_gitlab_backup.tar
# 发现备份成功

问题

在备份的时候出现红字警告

2024-12-27 06:05:36 UTC -- Warning: Your gitlab.rb and gitlab-secrets.json files contain sensitive data 
and are not included in this backup. You will need these files to restore a backup.
Please back them up manually.
# 翻译
警告:您的gitlab。Rb和gitlab的秘密。Json文件包含敏感数据并且不包含在此备份中。您将需要这些文件来恢复备份。请手动备份。

根据提示,需要自己来备份
gitlab.rb

gitlab-secrets.json
两个文件

[root@linux-servertwo gitlab]# cp /etc/gitlab/gitlab.rb /etc/gitlab/gitlab.rb.bak
[root@linux-servertwo gitlab]# cp /etc/gitlab/gitlab-secrets.json /etc/gitlab/gitlab-secrets.json.bak

自动备份

使用
Linux

crontab
命令来实现自动备份

# 输入命令crontab -e
crontab -e  
# 输入相应的任务 -添加定时任务(每天凌晨2点备份)
0 2 * * * /opt/gitlab/bin/gitlab-rake gitlab:backup:create CRON=1

#注意:环境变量CRON=1的作用是如果没有任何错误发生时, 抑制备份脚本的所有进度输出
#查看周期性计划任务
crontab -l 

2.12.2、恢复

首先进入
/var/opt/gitlab/backups
目录

cd /var/opt/gitlab/backups

把已经备份的文件上传进去,然后执行以下命令恢复数据

# 停止相关服务
sudo gitlab-ctl stop unicorn
sudo gitlab-ctl stop puma
sudo gitlab-ctl stop sidekiq

# 恢复备份-填写备份文件的时间戳信息
gitlab-rake gitlab:backup:restore BACKUP=1735279508_2024_12_27_16.9.9

# 启动服务
gitlab-ctl reconfigure

gitlab-ctl restart 

gitlab-ctl status |grep run

2.13、登录欢迎页

进入
管理中心->设置->外观

3、内存优化

使用云服务部署
gitlab
的服务器是2C4G的云服务器,部署后内存占用太大,服务器出现卡顿现象,所以需要更改
gitlab
启动配置来缩小内存占用。通过
Linux

free -h
命令查看内存占用信息,发现内存已经占用满了。

更改配置文件,关闭不需要的内置服务,首先找到并修改
gitlab.rb
主配置文件

vim /etc/gitlab/gitlab.rb 

减少Puma工作进程和线程数

# Puma 设置 
# Puma settings - reduce workers and threads
# puma['enable'] = true
# puma['ha'] = false
puma['worker_timeout'] = 60
# 减少 Puma worker 进程数,默认值为 2
puma['worker_processes'] = 2
# 减少每个 worker 的最小和最大线程数
# 默认值分别为 4 和 4,降低这些值可以减少内存使用
puma['min_threads'] = 1
puma['max_threads'] = 2

降低后台守护进程并发数【Sidekiq并发数】

# Sidekiq settings - reduce concurrency
# 减少后台任务处理的并发数
sidekiq['max_concurrency'] = 5
# 设置最小并发数为 0,允许在空闲时释放更多内存
sidekiq['min_concurrency'] = 0

减少PostgreSQL的内存缓冲区和工作内存

# 并发连接数
postgresql['max_connections'] = 200
# 减少共享缓冲区大小,默认推荐值是系统内存的 25% ,对于小型部署,128MB 通常足够
postgresql['shared_buffers'] = "128MB"
# 减少每个数据库连接的工作内存, 降低此值可以减少总体内存使用,但可能影响复杂查询性能
postgresql['work_mem'] = "8MB"
# 设置维护操作的工作内存, 这个值影响 VACUUM, CREATE INDEX 等操作
postgresql['maintenance_work_mem'] = "16MB"
# 减少查询计划器预估可用的系统缓存大小
# 这不是实际分配的内存,而是告诉优化器系统有多少可用缓存
postgresql['effective_cache_size'] = "256MB"
# 限制后台工作进程数
postgresql['max_worker_processes'] = 2
# 禁用并行查询,可以节省内存
postgresql['max_parallel_workers_per_gather'] = 0

限制Redis最大内存使用

# 限制 Redis 最大内存使用
redis['maxmemory'] = "256MB"
# 内存达到限制时的淘汰策略
# allkeys-lru: 当内存不足时,删除最近最少使用的 key
redis['maxmemory_policy'] = "allkeys-lru"

限制Ruby垃圾收集器的内存分配

# GitLab application settings
# 环境变量配置
#gitlab_rails['env'] = {
  # 限制 glibc 内存分配器的 arena 数量
  # 可以减少内存碎片
  #'MALLOC_ARENA_MAX' => "2",
  # 限制 Bundler 并行安装 gem 的作业数
  #'BUNDLE_JOBS' => "1"
#}

gitlab_rails['env'] = {
  'MALLOC_ARENA_MAX' => "2",
  'BUNDLE_JOBS' => "1"
}

减少GitLab后台任务的运行频率

# 后台任务计划任务优化
# Reduce number of background jobs
# 减少清理卡住的 CI 任务的频率(每 4 小时而不是每小时)
gitlab_rails['stuck_ci_jobs_worker_cron'] = "0 */4 * * *"
# 减少清理构建产物的频率(每 30 分钟而不是每 7 分钟)
gitlab_rails['expire_build_artifacts_worker_cron'] = "*/30 * * * *"
# 减少仓库检查的频率(每 2 小时而不是每小时)
gitlab_rails['repository_check_worker_cron'] = "0 */2 * * *"
# 减少管理员邮件发送的频率(每周而不是每天)
gitlab_rails['admin_email_worker_cron'] = "0 0 * * 0"

重载配置

gitlab-ctl reconfigure

重启服务

gitlab-ctl restart

检查服务状态

gitlab-ctl status

这些设置会降低GitLab的性能,但可以显著减少内存使用。可以根据具体使用情况和服务器资源,可能需要进一步调整这些值。再次查看内存占用大小

可以发现内存已经成功下降到系统可控范围内。

问题

通过执行以上命令,内存占用有一定的下降,但是当用户登录进去后,内存占用率有显著上升,但用户退出后内存占用不会下降

###############################################
# Ruby GC (垃圾回收)优化
###############################################
gitlab_rails['env'] = {
  # 已有配置
  'MALLOC_ARENA_MAX' => "2",
  'BUNDLE_JOBS' => "1",
  
  # Ruby GC 调优
  # 更激进的垃圾回收
  'RUBY_GC_HEAP_INIT_SLOTS' => "50000",
  'RUBY_GC_HEAP_FREE_SLOTS' => "4096",
  'RUBY_GC_HEAP_GROWTH_MAX_SLOTS' => "300000",
  'RUBY_GC_HEAP_GROWTH_FACTOR' => "1.1",
  # 强制在大对象分配后进行GC
  'RUBY_GC_MALLOC_LIMIT' => "4000100",
  'RUBY_GC_OLDMALLOC_LIMIT' => "4000100",
  # 减少Ruby进程的最大内存使用
  'RUBY_HEAP_MIN_SLOTS' => "150000",
  'RUBY_HEAP_FREE_MIN' => "4096",
  'RUBY_HEAP_SLOTS_INCREMENT' => "100000"
}

# 重新加载配置
gitlab-ctl reconfigure

# 完全重启所有服务
gitlab-ctl stop

# 启动gitlab
gitlab-ctl start

# 检查服务状态
gitlab-ctl status

通过
free -h
再次查看内存占用情况

通过以上配置,内存占用基本稳定在2.2~2.3G左右,已经达到预期效果可以正常使用了。

4、服务器配置建议

根据实际用户规模和项目数量调整硬件配置。

  • CPU
    :至少配置 4 核 CPU(中型团队建议 8 核以上)。
  • 内存
    :最低要求 4GB,推荐至少 8GB 或更多(对于大型部署建议 16GB+)。
  • 存储空间
    :推荐使用
    SSD
    提升 IO 性能,确保有足够空间存储代码仓库、备份和日志。
| 小型团队 (1-10用户) | 2核 | 4GB | 50GB SSD | 100Mbps |
| 中小团队 (10-50用户) | 4核 | 8GB | 100GB SSD | 500Mbps |
| 中型团队 (50-100用户) | 8核 | 16GB | 500GB SSD | 1Gbps |
| 大型团队 (100+用户) | 16核 | 32GB | 1TB SSD (NVMe) | 10Gbps |

# 说明:
 # 所有配置建议使用SSD存储以提升性能
 # 内存建议预留25%用于系统运行
 # 存储空间需要考虑代码仓库增长和备份需求
 # 网络带宽需要根据实际访问量和并发数调整

服务器硬件资源不足会导致 GitLab 运行缓慢或服务中断。官网说明:

5、安装目录

GitLab主安装目录,组件依赖程序:/opt/gitlab/

GitLab配置文件目录:/etc/gitlab/ 

GitLab各个组件存储路径,数据目录:/var/opt/gitlab/          

GitLab组件日志目录:/var/log/gitlab/ 

GitLab仓库默认存储路径:/var/opt/gitlab/git-data/repositories

版本文件备份路径:/var/opt/gitlab/backups/

nginx安装路径:/var/opt/gitlab/nginx/

redis安装路径:/var/opt/gitlab/redis

详细信息

/opt/gitlab/                # GitLab主安装目录
├── bin/                   # 可执行文件目录
│   ├── gitlab-ctl        # GitLab控制脚本
│   └── gitlab-rake       # GitLab rake命令
│
├── embedded/              # GitLab依赖的软件包
│   ├── bin/              # 内置命令
│   ├── service/          # 服务配置
│   └── lib/              # 依赖库
│

/etc/gitlab/               # 配置文件目录
├── gitlab.rb             # 主配置文件
├── gitlab-secrets.json   # 密钥配置
└── ssl/                  # SSL证书目录

/var/opt/gitlab/          # 数据目录
├── backups/             # 默认备份目录
├── git-data/            # Git仓库数据
├── postgresql/          # PostgreSQL数据
├── redis/              # Redis数据
├── gitlab-rails/       # Rails应用数据
└── nginx/              # Nginx配置和日志

/var/log/gitlab/         # 日志目录
├── nginx/              # Nginx日志
├── postgresql/         # 数据库日志
├── redis/             # Redis日志
├── gitlab-rails/      # Rails应用日志
└── sidekiq/           # 后台任务日志

6、常用命令

#  检查 GitLab 当前配置
grep -E '^[a-Z]' /etc/gitlab/gitlab.rb # 会显示所有未被注释的配置行,帮助查看当前生效的 GitLab 配置

# 重新加载配置文件
gitlab-ctl reconfigure

# 重启
gitlab-ctl restart

# 查看服务状态
gitlab-ctl status

# 启动gitlab	
gitlab-ctl start

# 停止  
gitlab-ctl stop

# 查看所有的logs; 按 Ctrl-C 退出
gitlab-ctl tail

# 拉取/var/log/gitlab下子目录的日志
gitlab-ctl tail gitlab-rails

# 拉取某个指定的日志文件
gitlab-ctl tail nginx/gitlab_error.log

# 帮助
gitlab-ctl help

#GitLab 诊断命令 建议使用时机:安装GitLab后、升级GitLab前后、遇到系统问题时、定期系统检查
gitlab-rake gitlab:check SANITIZE=true --trace	

# 查看运行状态
systemctl status gitlab-runsvdir.service 

# 禁止Gitlab开机自启动
systemctl disable gitlab-runsvdir.service 

# 启用Gitlab开机自启动
systemctl enable gitlab-runsvdir.service

公众号文章链接:
https://mp.weixin.qq.com/s/TswRK9QymfE2OvjNqhZ5Zw

《异教徒 Heretic》是Unity在2019年GDC大会上展示的一款技术Demo,部分资源于2020年中旬公开下载。

这款Demo主要用于展示Unity在数字人技术领域的最新进展,尤其是在写实数字人渲染和面部动画的处理上。

通常面部肌肉的每一处细微变化都会对最终的视觉效果产生显著影响。

而传统基于表情基和骨骼驱动的面部动画方案,虽然能够提供较为流畅的表现,但在精度和真实感上往往存在差距。

为了追求更高的真实还原度,《异教徒》Demo采用了前沿的4D捕捉技术。这项技术通过硬件设备精确捕捉每一帧的面部表情数据,

并通过先进的拟合算法进行实时重建,从而实现了前所未有的细节还原和视觉真实感。

官方Blog:

https://unity.com/blog/technology/making-of-the-heretic-digital-human-character-gawain

百度网盘缓存Demo下载地址(测试所使用版本Unity2021.3.26,HDRP 12):

链接:
https://pan.baidu.com/s/1Mk3X8VZpeoQq-w5SfmsE2g
提取码: f75e

1.SkinDeformation

这部分主要处理4D设备捕捉到的表情动画,到Unity这个环节的数据应该是经过Wrap3D处理,

直接播放Demo场景里的Timeline即可单独预览:

SkinDeformationClip是一个SO文件,存放烘焙好的动画信息,而SkinDeformationRenderer负责表情数据的最终渲染输出。

1.1 SkinDeformationRenderer

该脚本会读取blendInputs字段中的数据并拿来进行处理,该字段的赋值在SkinDeformationTimeline中:

var inputA =playable.GetInput(inputIndexA);var inputB =playable.GetInput(inputIndexB);var assetA = ((ScriptPlayable<SkinDeformationPlayable>)inputA).GetBehaviour().clip;var assetB = ((ScriptPlayable<SkinDeformationPlayable>)inputB).GetBehaviour().clip;//赋值处:
target.SetBlendInput(0, assetA, (float)(inputA.GetTime() /assetA.Duration), inputWeightA);
target.SetBlendInput(
1, assetB, (float)(inputB.GetTime() / assetB.Duration), inputWeightB);

该脚本中的数据结构有标记Lo、Hi后缀字段,看上去似乎和低频高频数据有关,但实际上储存的是

当前帧和上一帧数据,以及插值数值。

for (int i = 0; i != subframeCount; i++)
{
subframes[i].frameIndexLo
=i;
subframes[i].frameIndexHi
= i + 1;
subframes[i].fractionLo
= 0.0f;
subframes[i].fractionHi
= 1.0f;
}

还有一组Albedo的有关数据,但没有看到被使用:

private static readonly BlendInputShaderPropertyIDs[] BlendInputShaderProperties ={newBlendInputShaderPropertyIDs()
{
_FrameAlbedoLo
= Shader.PropertyToID("_BlendInput0_FrameAlbedoLo"),
_FrameAlbedoHi
= Shader.PropertyToID("_BlendInput0_FrameAlbedoHi"),
_FrameFraction
= Shader.PropertyToID("_BlendInput0_FrameFraction"),
_ClipWeight
= Shader.PropertyToID("_BlendInput0_ClipWeight"),
},
newBlendInputShaderPropertyIDs()
{
_FrameAlbedoLo
= Shader.PropertyToID("_BlendInput1_FrameAlbedoLo"),
_FrameAlbedoHi
= Shader.PropertyToID("_BlendInput1_FrameAlbedoHi"),
_FrameFraction
= Shader.PropertyToID("_BlendInput1_FrameFraction"),
_ClipWeight
= Shader.PropertyToID("_BlendInput1_ClipWeight"),
},
};

数据在导入时会通过MeshLaplacian进行降噪:

var laplacianResolve = (laplacianConstraintCount <frameVertexCount);if(laplacianResolve)
{
#if SOLVE_FULL_LAPLACIANlaplacianTransform= newMeshLaplacianTransform(weldedAdjacency, laplacianConstraintIndices);#elselaplacianTransform= new MeshLaplacianTransformROI(weldedAdjacency, laplacianROIIndices, 0);
{
for (int i = 0; i != denoiseIndices.Length; i++)
denoiseIndices[i]
=laplacianTransform.internalFromExternal[denoiseIndices[i]];for (int i = 0; i != transplantIndices.Length; i++)
transplantIndices[i]
=laplacianTransform.internalFromExternal[transplantIndices[i]];
}
#endiflaplacianTransform.ComputeMeshLaplacian(meshLaplacianDenoised, meshBuffersReference);
laplacianTransform.ComputeMeshLaplacian(meshLaplacianReference, meshBuffersReference);
}

在SkinDeformationClipEditor.cs中存放有ImportClip的逻辑。

当点击SO的Import按钮时触发。

1.2 SkinDeformationFitting

该脚本主要通过最小二乘得到拟合表情的各个BlendShape权重。

并通过Accord.NET子集得到非负数结果,这个在官方技术文章里有提到。

最小二乘后的计算结果会存放在frames.fittedWeights中:

//remap weights to shape indices
for (int j = 0; j != sharedJobData.numVariables; j++)
{
sharedJobData.frames[k].fittedWeights[sharedJobData.blendShapeIndices[j]]
= (float)x[j];
}

在运行时存放在:

public classSkinDeformationClip : ScriptableObject
{
public unsafe structFrame
{
public float*deltaPositions;public float*deltaNormals;public float* fittedWeights;//<--- publicTexture2D albedo;
}

最后会传入Renderer:

public classSkinDeformationRenderer : MeshInstanceBehaviour
{
[NonSerialized]
public float[] fittedWeights = new float[0];//used externally

在Renderer中混合代码如下:

for (int i = 0; i != fittedWeights.Length; i++)
smr.SetBlendShapeWeight(i,
100.0f * (fittedWeights[i] * renderFittedWeightsScale));

补充:当最小二乘逻辑执行时,若当前矩阵与b矩阵数值相差过大,则结果越接近于0,反之矩阵之间数值越接近则结果数值越大。

在最小二乘法求解过程中,如果
当前矩阵

b矩阵
之间的数值差异较大,那么解的结果通常会趋近于零。

相反,
当前矩阵

b矩阵
的数值较为接近时,求解结果的数值则相对较大。

这一点也符合最终混合权重系数时的逻辑。

1.3 Frame信息读取

在Renderer脚本中,会调用clip.GetFrame获得当前帧的信息。即Clip中的

这样一个unsafe结构:

public classSkinDeformationClip : ScriptableObject
{
public unsafe structFrame
{
public float*deltaPositions;public float*deltaNormals;public float*fittedWeights;publicTexture2D albedo;
}

读取时会从frameData取得数据,该字段为NativeFrameStream类型,内部为Unity的异步文件读取实现。

加载时,如果是编辑器下就从对应目录的bin文件加载否则从StreamingAssets加载:

voidLoadFrameData()
{
#if UNITY_EDITOR string filename = AssetDatabase.GetAssetPath(this) + "_frames.bin";#else string filename = Application.streamingAssetsPath +frameDataStreamingAssetsPath;
Debug.Log(
"LoadFrameData" + filename + ")");#endif

2.SnappersHead

该脚本提供对控制器、BlendShape、Mask贴图强度信息的逻辑控制。

2.1 控制器

在场景中选中挂有SnappersHeadRenderer脚本的对象,即可在编辑器下预览控制器。

这里控制器只是GameObject,概念上的控制器。

它类似于DCC工具中的控制器导出的空对象,通过脚本获得数值,并在LateUpdate中输出到BlendShape从而起作用。

在层级面板位于Gawain_SnappersControllers/Controllers_Parent下,模板代码使用了136个控制器,

Gawain角色并没有使用所有控制器。

2.2 BlendShape & Mask贴图

SnappersHead脚本中主要是对之前SkinDeformation处理过的BlendShape进行钳制,

其代码应该是自动生成的:

public unsafe static void ResolveBlendShapes(float* a, float* b, float*c)
{
b[
191] = max(0f, a[872] / 2.5f);
b[
192] = max(0f, a[870] / 2.5f);
b[
193] = max(0f, (0f - a[872]) / 2.5f);
b[
294] = linstep(0f, 0.2f, max(0f, (0f - a[871]) / 2.5f));
b[
295] = linstep(0.2f, 0.4f, max(0f, (0f - a[871]) / 2.5f));
b[
296] = linstep(0.4f, 0.6f, max(0f, (0f - a[871]) / 2.5f));
b[
297] = linstep(0.6f, 0.8f, max(0f, (0f - a[871]) / 2.5f));
b[
298] = linstep(0.8f, 1f, max(0f, (0f - a[871]) / 2.5f));
b[
129] = hermite(0f, 0f, 4f, -4f, max(0f, (0f - a[541]) / 2.5f));
b[
130] = max(0f, a[542] / 2.5f);
b[
127] = max(0f, (0f - a[542]) / 2.5f);
b[
34] = max(0f, (0f - a[301]) / 2.5f);
...

Mask贴图也是类似的方式,对Albedo、Normal、Cavity三中贴图进行后期优化与钳制,

最后将Mask混合强度信息传入Shader。

3.SkinAttachment粘附工具

这一块主要是眉毛等物件在蒙皮网格上的粘附。

与UE Groom装配的做法类似,通过三角形重心坐标反求回拉伸后的网格位置。

(UE Groom官方讲解:
https://www.bilibili.com/video/BV1k5411f7JD
)

SkinAttachment组件表示每个粘附物件,SkinAttachmentTarget组件表示所有粘附物件的父容器,

模型顶点和边信息查找用到了KDTree,在项目内的KdTree3.cs脚本中,

三角形重心坐标相关函数在Barycentric.cs脚本中。

查找时,每个独立Mesh块被定义为island,在这个结构之下再去做查找,

例如眉毛的islands如下:

通过Editor代码,每个挂载有SkinAttachment组件的面板上会重绘一份Target Inspector GUI,方便编辑。

当点击编辑器下Attach按钮时,会调用到SkinAttachment的Attach函数:

public void Attach(bool storePositionRotation = true)
{
EnsureMeshInstance();
if (targetActive != null)
targetActive.RemoveSubject(
this);

targetActive
=target;
targetActive.AddSubject(
this);if(storePositionRotation)
{
attachedLocalPosition
=transform.localPosition;
attachedLocalRotation
=transform.localRotation;
}

attached
= true;
}

SkinAttachmentTarget组件会在编辑器下保持执行,因此在更新到LateUpdate时候会触发如下逻辑:

voidLateUpdate()
{
if(UpdateMeshBuffers())
{
ResolveSubjects();
}
}

4.眼球

4.1 眼球结构

说一下几个关键性的结构:

  • 角膜(cornea) 最外边的结构,位于房水之外,它的主要作用是屈光,帮助光线聚焦到眼内
  • 房水(aqueoushumor)晶状体后的半球形水体,图形上经常要处理的眼球焦散、折射都是因为存在该结构的原因
  • 虹膜(Iris)关键性的结构,位于晶状体外,房水内。眼睛颜色不同也是因为该结构的色素不一样导致,虹膜起到收缩瞳孔的效果
  • 瞳孔(pupil)不多解释
  • 巩膜(sclera)眼白部分,通常需要一张带血丝的眼白贴图

虽然房水这样的结构在多数图形相关文章中未被提起,但博主认为物理层面这仍很重要。

4.2 EyeRenderer

该Demo中的EyeRenderer实现了角膜、瞳孔、巩膜等效果的参数调节,后续这块内容被集成在HDRP的Eye Shader中,

并在Ememies Demo中得到再次升级。

4.3 眼球AO

使用ASG制作了眼球AO,ASG指AnisotropicSphericalGaussian各向异性球面高斯。

隐藏面部网格后,单独调节参数效果:

该技术类似球谐函数的其中一个波瓣,参数可自行微调。

将ASG单独提取测试效果:

原代码中给到了2个该技术的参考链接:

structAnisotropicSphericalSuperGaussian
{
//(Anisotropic) Higher-Order Gaussian Distribution aka (Anisotropic) Super-Gaussian Distribution extended to be evaluated across the unit sphere.// //Source for Super-Gaussian Distribution:// https://en.wikipedia.org/wiki/Gaussian_function#Higher-order_Gaussian_or_super-Gaussian_function// //Source for Anisotropic Spherical Gaussian Distribution:// http://www.jp.square-enix.com/info/library/pdf/Virtual%20Spherical%20Gaussian%20Lights%20for%20Real-Time%20Glossy%20Indirect%20Illumination%20(supplemental%20material).pdf// floatamplitude;
float2 sharpness;
floatpower;
float3 mean;
float3 tangent;
float3 bitangent;
};

5.Teeth&Jaw 颌骨

5.1 下颌骨位置修正

TeethJawDriver脚本提供了修改参数Jaw Forward,可单独对下颌位置进行微调,

隐藏了头部网格后非常明显(右侧参数为2):

另外该参数没有被动画驱动。

5.2 颌骨AO

颌骨AO(或者叫衰减更合理)通过外部围绕颌骨的6个点(随蒙皮绑定)代码计算得到。

通过球面多边形技术实现,在SphericalPolygon.hlsl中可查看:

void SphericalPolygon_CalcInteriorAngles(in float3 P[SPHERICALPOLYGON_MAX_VERTS], out floatA[SPHERICALPOLYGON_MAX_VERTS])
{
const int LAST_VERT = (SPHERICALPOLYGON_NUM_VERTS - 1);

float3 N[SPHERICALPOLYGON_MAX_VERTS];
//calc plane normals//where N[i] = normal of incident plane//eg. N[i+0] = cross(C, A);//N[i+1] = cross(A, B); {
N[
0] = -normalize(cross(P[LAST_VERT], P[0]));for (int i = 1; i != SPHERICALPOLYGON_NUM_VERTS; i++)
{
N[i]
= -normalize(cross(P[i - 1], P[i]));
}
}
//calc interior angles {for (int i = 0; i != LAST_VERT; i++)
{
A[i]
= PI - sign(dot(N[i], P[i + 1])) * acos(clamp(dot(N[i], N[i + 1]), -1.0, 1.0));
}
A[LAST_VERT]
= PI - sign(dot(N[LAST_VERT], P[0])) * acos(clamp(dot(N[LAST_VERT], N[0]), -1.0, 1.0));
}
}

6.杂项

6.1 ArrayUtils.ResizeCheckedIfLessThan

项目中许多数组都使用了这个方法,该方法可确保目标缓存数组的长度不小于来源数组。

一方面避免使用List,另一方面可很好的做到缓存,避免预分配。

该类还提供了一个ArrayUtils.CopyChecked接口,可直接执行分配+拷贝。

6.2 头部骨架

头部使用FACS (Facial Action Coding System) 骨架结构进行搭建。

6.3 总结

在该Demo中,网格处理相对复杂,尤其是通过MeshAdjacency进行了顶点融合等操作。

这点在SkinAttachment粘附部分运用较多,时间原因不继续展开研究。

这些技术在Enemies Demo中得到了进一步升级。

项目中广泛使用了指针操作与Unity Job系统的结合,虽然不能确定仅仅使用指针就一定优于Unity.Mathematics,

但这一做法在性能优化上可能有所帮助。

可以预见,从传统的骨骼蒙皮技术,到更精细的面部肌肉拉伸蒙皮,再到利用机器学习实现的布料模拟,

角色渲染的提升方向至少已经有了明确的思路可循。在实时渲染领域,技术的不断进步为未来的渲染效果提供了新的可能性。


参考&扩展阅读:

官方Blog Heretic Demo页:
https://unity.com/blog/technology/making-of-the-heretic-digital-human-character-gawain

Megacity Unity Demo工程学习:
https://www.cnblogs.com/hont/p/18337785

Unity FPSSample Demo研究:
https://www.cnblogs.com/hont/p/18360437

Book of the Dead 死者之书Demo工程回顾与学习:
https://www.cnblogs.com/hont/p/15815167.html