[{"content":"1 架构概述 在实时音视频（RTC）与高并发直播推流的场景中，系统往往面临极高的性能与稳定性要求。\n传统的单线程同步推流方案在面对网络抖动或算力瓶颈时，极易导致主渲染管线阻塞，进而造成全局画面的卡顿甚至程序崩溃。\n为了构建一个健壮的工业级直播与录制引擎，本系统摒弃了简单的同步回调模型，采用高度解耦的异步流水线（Asynchronous Pipeline）架构与多级生产者-消费者（Producer-Consumer）模型。\n在设计前，本架构致力于解决四大企业级核心痛点：\n1.1 网络阻塞渲染（I/O瓶颈） 推流过程中的网络延迟绝对不能反向阻塞GPU/CPU的渲染帧率。 1.2 多源音画同步（A/V Sync） 系统音频、麦克风音频与视频帧在不同时钟域产生，必须在底层实现精准对齐。 1.3 推播矩阵的算力优化 针对同时推流至多个平台（如RTMP矩阵）外本地录制的场景，必须避免重复编码带来的算力雪崩。 1.4 弱网抗抖动能力（QoS保障） 在带宽突降的恶劣网络环境下，系统需具备则我降级与平滑恢复的动态流控能力。 1.5 架构设计 本架构没有采用简单的单队列缓冲，而是根据业务的“计算密集度”与“I/O密集度”差异，设计了两级物理隔离的生产者-消费者边界。\n这种设计使得UI渲染、音视频编码、网络发送三大核心模块在各自独立的线程域中全速奔跑。\n1.5.1 渲染主线程与编码子线程 目标：绝对保护UI主线程的极致流畅，防止编码耗时拖慢渲染帧率。\n生产者（Producer）：系统的UI渲染层（如ScreenWindow）。他负责以极高的频率产生NV12视频帧或PCM音频数据，并通过统一门面RecordController的addVideoData和addAudioData接口投递数据。\n缓冲队列（Queue）：底层维护了轻量级、无锁化（或极低锁争用）的m_videoQueue队列和AudioDSP缓冲池。\n消费者（Consumer）：FFmpegRecorder作为系统的心脏线程，在主循环中不断从队列中取出原始数据（Raw Data）进行后续处理。\n解耦效果：渲染线程执行投递动作后瞬间返回，实现了真正的“非阻塞”调用，彻底切断了C++复杂内存管理对上层业务的干扰。\n1.5.2 编码子线程与I/O发送线程 目标：彻底切断网络抖动对音视频压制管线的反噬。\n生产者（Producer）：依然是FFmpegRecorder线程他将原始音视频帧进行软/硬编码后，生成压缩后的数据包（AVPacket） 缓冲队列（Queue）：线程安全的核心队列PacketQueue。 消费者（Consumer）：独立的网络/文件I/O线程StreamSender。他负责通过pop()阻塞获取队列中的数据包，并执行耗时的网络Socket发送或磁盘写入。 解耦效果：即使推流时遭遇严重断网，av_interleaved_write_frame的阻塞也只会卡住StreamSender线程。此时编码器依然可以全速运转将AVPacket压入队列，为后续的QoS熔断争取了宝贵的缓冲时间。 2 直播vs录播差异 直播推流：将编码后的音视频数据，像流水一样实时发送到远端的流媒体服务器（例如通过RTMP协议）。它的核心诉求是低延迟和抗网络抖动。 录播/录制：将编码后的数据，按照特定的封装格式（入MP4、MKV）安全地写入磁盘。它的核心诉求是高画质和文件完整性。 在FFmpeg世界里，无论直播还是录播，前端的采集、重采样、H.264编码过程是完全共用的。他们唯一区别发生在最后一步：复用（Muxing）与IO。\n编码器吐出来的是纯粹的H.264视频流，他不能直接传输或播放。我们需要把它装进一个”盒子“（FLV或MP4格式），这个装盒子的过程叫做Muxing。\n3 YUV与RGB格式区别 在传统的RGB24格式中，每个像素需要3字节（红、绿、蓝各1字节）。但人类的眼睛有个生理Bug：对亮度（黑白轮廓）极其敏感，但对色彩（亮度）的变化很迟钝。\nY（Luma）：代表亮度（黑白画面），必须全部全量保留。\nU（Cb）和V（Cr）：代表色彩。既然人眼对色彩迟钝，我们干脆让每4个Y共享1个U和1个V。这就是4：2：0色度抽样。\n体积锐减50%的魔法算术题： 假设有一块4x4的图像（16个像素）： RGB24需要体积：16x3=48字节 YUV420需要体积：16Y+4U+4V=24字节 3.1 YUV4:2:0与NV12内存布局 3.1.1 YUV420P 在内存里，这24个字节的存放方式：Planar（平面格式）的逻辑最简单粗暴：Y、U、V三个平面独立存。\n3.1.2 NV12 YUV420P需要维护3个内存指针（Y、U、V），在GPU渲染时，需要发起3次内存读取操作。为了提速，微软和各种硬件厂商主推了NV12（Semi-Planar）格式。 他的规则是：Y依然全部连续存放，U和V交替存放.\n当把NV12丢给OpenGL或Direct3D渲染时，GPU只需要创建2个纹理（Textures）。取色度时，GPU的采样器一次读取2个字节，瞬间就能拿到U和V还原色彩，极大地提高了Cache的命中率。 4 GPU纹理零拷贝（Zero-Copy） 传统的音视频处理中，“GPU渲染”-\u0026gt;下载至CPU内存-\u0026gt;上传至编码器显存的链路引发严重的PCle总线拥塞与CPU占用突增。\n4.1 触发条件 在processVideo函数中，系统回进行校验，当同时满足以下三个条件时，触发零拷贝： 1.传入的数据包含有有效的ID3D11Texture2D显存句柄。 2.编码器为硬件加速模式（如：h264_nvenc）。 3.渲染输出分辨率与目标分辨率完全一致。 4.2 实现原理 //在纯GPU路径中，系统绕过了像素级别的字节操作，直接将D3D11 Texture 的显存地址（Pointer）绑定至AVFrame的数据域。 srcFrame-\u0026gt;format = AV_PIX_FMT_D3D11; srcFrame-\u0026gt;data[0] = (uint8_t*)data.texture.Get(); // 显存指针直通 srcFrame-\u0026gt;hw_frames_ctx = av_buffer_ref(m_vEncoder-\u0026gt;getHWFramesRef()); 4.3 容灾与降级方案 当必须使用软件编码器，或目标推流分辨率于渲染分辨率不一致时，系统必须将GPU显存数据回读(Download)至CPU内存。\n4.3.1 Staging Texture中转机制 标准的渲染纹理通常位于显卡高速区，禁止CPU直接访问。系统通过创建一个带有D3D11_USAGE_STAGING标志和D3D11_CPU_ACCESS_READ权限的中转纹理，将渲染结果拷贝至此，再通过Map与Unmap接口建立CPU寻址映射。 显存行跨度安全拷贝策略\n这是图形编程汇总最易引发内存越界和画面畸变（如绿屏、斜纹偏移）的风险点。现代GPU为提升寻址效率，往往会对每一行像素进行字节填充（Padding），以满足特定的字节对齐（如256或512字节对齐）。因此，显存中每一行的实际步长(Row Pitch)往往大于图像的真实有效宽度(Width)。\n在downloadTexture函数中，系统严格舍弃了不安全的整块memcpy，采用了基于步长的精确剥离拷贝算法：\n// 剥离填充字节，逐行安全拷贝 Y 分量 for (int i = 0; i \u0026lt; height; ++i) { memcpy(dstY + i * width, src + i * map.RowPitch, width); } // 剥离填充字节，逐行安全拷贝 UV 分量 for (int i = 0; i \u0026lt; height / 2; ++i) { memcpy(dstUV + i * width, src + ySize + i * map.RowPitch, width); } 该算法保证了从异构硬件读取的YUV数据能够完美映射回标准的密集排列内存，根绝了画面的倾斜失真。\n4.4 格式转换与缩放 在提取到安全的纯净内存数据后，若业务层配置了分辨率缩放（例如：1080P降采样至720P），系统将介入FFmpeg提供的libswscale组件。\n4.4.1 上下文复用机制 为了保障严格条件下的60FPS实时处理要求，系统在initScaler中通过sws_getCachedContext实现了缩放上下文与缓冲内存的生命周期复用。避免了每秒数十次的大对象动态分配操作。\n// FFmpegRecorder.cpp bool FFmpegRecorder::initScaler(int inW, int inH, int inFmt, int outW, int outH, int outFmt) { // 如果上下文已存在且参数未变，直接复用 m_swsCtx = sws_getCachedContext(m_swsCtx, inW, inH, (AVPixelFormat)inFmt, outW, outH, (AVPixelFormat)outFmt, SWS_BICUBIC, nullptr, nullptr, nullptr); if (!m_swsCtx) return false; // 准备缩放后的 AVFrame 容器，仅在尺寸变化时重新分配 buffer if (!m_scaledFrame) { m_scaledFrame = av_frame_alloc(); } // ... 检查并执行 av_frame_get_buffer } 4.4.2 缩放滤波器 根据性能与画质的不同权衡，底层系统支持根据配置枚举应用不同的重采样数学模型：\n// 配置定义见 RecorderConfig.h enum class DownscaleFilter { Bilinear, // 线性插值 (快) Bicubic, // 三次卷积 (清晰) Lanczos // 兰索斯 (锐利, 推荐) }; Bilinear(双线性插值)：运算耗时最低，但在处理文字边缘时可能出现轻微模糊。 Bicubic(三次卷积插值)：平滑度提升，适合大多数日常场景。 Lanczos(兰索斯重采样)：保留极高的高频细节与锐利度，但CPU计算开销最大。强烈推荐用于复杂游戏画面的推流降采样。 // FFmpegRecorder.cpp if (needScale) { // 1. 确保缩放器已初始化为 NV12 -\u0026gt; NV12 if (initScaler(data.width, data.height, AV_PIX_FMT_NV12, targetW, targetH, AV_PIX_FMT_NV12)) { // 2. 执行真正的图像重采样 sws_scale(m_swsCtx, srcFrame-\u0026gt;data, srcFrame-\u0026gt;linesize, 0, data.height, m_scaledFrame-\u0026gt;data, m_scaledFrame-\u0026gt;linesize); // 3. 将工作指针指向缩放后的内存块 frameToEncode = m_scaledFrame; } } 5 音频重采样 采样率（Sample Rate）： 就是计算机一秒钟量多少次。比如常见的44100，就是一秒钟量4410次高度。\n什么是重采样？\n就是把”一秒钟量4万次“的音频，硬生生转换成”一秒钟量4.8万次“的音频。\n为什么必须重采样？ 想象，麦克风是一个急性子，一秒钟给你48000个数字。但系统电脑声音是一个慢性子，一秒钟只给你44100个数字。这两个声音就像在说方言。在代码中，我们在AudioDSP::init里设定了一个”普通话“标准（也就是统一的输出采样率m_outRate）。所有声音进来，都必须先被“翻译”成这个统一的标准，这就是重采样。\n5.1 基于FFmpeg API swr_alloc_set_opts2和swr_init（翻译官入职）：这就是相当于我们雇佣一个“声音翻译官”。\nswr_convert（翻译官干活）：实际把一串串音频数字送给翻译官，他帮你转换成目标数字输出出来。\nAVAudioFifo（蓄水池）：这是一个超级关键的组件。因为麦克风和电脑给声音的速度是一阵一阵的（有时多有时少），而我们后续打包处理声音时，每次需要的量是固定的。所以我们建了一个FIFO“蓄水池”。翻译官翻译完的声音，先倒进这个池子里存着，等后续需要的时候，再用固定的大小将其平稳取出。\n6 音频混音 **什么是混音？**简单来说，就是让你在看直播时，既能听到主播说话的声音（麦克风），又能听到游戏里的枪声（系统声音）。\n底层的“做加法”原理： 混音的底层逻辑：就是各个轨道音频相加。\n混音的致命痛点：爆音（Clipping） 既然是做加法，就会遇到一个大麻烦。计算机记录声音数字的“尺子”是有最大刻度的（通常最大不能超过1.0）。\n如果主播大喊一声（数字变成0.8），游戏里刚好发生大爆炸（数字变成0.7），加起来是1.5。1.5超出了尺子的最大刻度1.0。计算机会粗暴地把超过1.0的部分像切菜一样“一刀切”削平。这在耳机里听起来，就是极其刺耳的爆音。\n爆音的解决方案：\n在AudioDSP类里，用tanh的数学函数。可以把他想象成一块“智能海绵”：\n当混音加起来的数字比较小（比如0.5）时，海绵不怎么管它，原汁原味保留。但当数字大到快要超过1.0（比如1.5、2.0）时，这块海绵会把声音“温柔地挤压”一下，让他无限接近1.0,但永远不会超过1.0。\n6.1 重采样与混音代码实现 初始化 当麦克风或系统声音第一次把数据塞进来时，系统会检查：这里是否有翻译官和水池，于是马上修建。\n// 1. 如果没有翻译官 (swr)，或者输入的频率变了，就重新入职和建水池 if (!ctx.swr || ctx.lastInputRate != inRate || ctx.lastInputChannels != inChannels) { if (ctx.swr) swr_free(\u0026amp;ctx.swr); // 辞退老翻译官 // 2. 设置输入输出频道标准 AVChannelLayout outLayout; av_channel_layout_default(\u0026amp;outLayout, m_outChannels); AVChannelLayout inLayout; av_channel_layout_default(\u0026amp;inLayout, inChannels); // 3. 拟定翻译合同 (swr_alloc_set_opts2)，告诉他输入是什么样，我们要的输出是什么样 swr_alloc_set_opts2(\u0026amp;ctx.swr, \u0026amp;outLayout, AV_SAMPLE_FMT_FLT, m_outRate, \u0026amp;inLayout, AV_SAMPLE_FMT_FLT, inRate, 0, nullptr); // 4. 翻译官正式上岗 (swr_init) if (swr_init(ctx.swr) \u0026lt; 0) return; // 5. 修建蓄水池 AVAudioFifo，用来装翻译后的标准水滴，容量给个 4096 if (ctx.fifo) av_audio_fifo_free(ctx.fifo); ctx.fifo = av_audio_fifo_alloc(AV_SAMPLE_FMT_FLT, m_outChannels, 4096); // 6. 准备一个临时脸盆 (resampleBuffer)，翻译官刚翻译完的水先放脸盆，再倒进水池 av_samples_alloc_array_and_samples(\u0026amp;ctx.resampleBuffer, nullptr, m_outChannels, 4096, AV_SAMPLE_FMT_FLT, 0); } 重采样与缓冲 数据源源不断地来，他就不断转换，并倒进各自的蓄水池(FIFO)中。\n// 1. 计算这次来了多少原始数据 int inSamples = data.size() / (inChannels * sizeof(float)); const uint8_t* inputData[] = { (const uint8_t*)data.constData() }; // 2. 估算翻译后会有多少数据 (因为频率变了，数量也会变) int outSamplesMax = av_rescale_rnd(swr_get_delay(ctx.swr, inRate) + inSamples, m_outRate, inRate, AV_ROUND_UP); // 3. 开始干活！swr_convert 把 inputData 翻译成金色水滴，放进脸盆 ctx.resampleBuffer int convertedSamples = swr_convert(ctx.swr, ctx.resampleBuffer, outSamplesMax, inputData, inSamples); // 4. 把脸盆里的水，稳稳地倒进蓄水池 AVAudioFifo 里存起来 if (convertedSamples \u0026gt; 0) av_audio_fifo_write(ctx.fifo, (void**)ctx.resampleBuffer, convertedSamples); 提取与混音 编码器急需数据打包，使用者拿着固定大小的“瓢”，去各个水池里舀水。并在通过一个大缸内做加法混合。\n// 1. 编码器拿着名为 outL 和 outR 的空盆来了，先洗干净 (置0) memset(outL, 0, nbSamples * sizeof(float)); memset(outR, 0, nbSamples * sizeof(float)); // 2. 遍历所有的水池（麦克风池、系统池） for(auto key : sources.keys()) { auto\u0026amp; s = sources[key]; // 3. 检查水池里的水够不够一瓢 (av_audio_fifo_size) if (av_audio_fifo_size(s.fifo) \u0026gt;= nbSamples) { // 4. 从水池舀出一瓢水，放进 tmpBuf void* bufPtr = tmpBuf; av_audio_fifo_read(s.fifo, \u0026amp;bufPtr, nbSamples); // 5. 核心：小学加法混音！把舀出来的水，乘以音量，加到主盆 outL/outR 里 for(int i=0; i\u0026lt;nbSamples; ++i) { outL[i] += tmpBuf[i*2] * s.volume; // 左声道相加 outR[i] += tmpBuf[i*2+1] * s.volume; // 右声道相加 } } } // 6. 智能海绵上场：使用 tanh 挤压主盆里的水，防止加起来的数字超过 1.0 (爆音) for(int i=0; i\u0026lt;nbSamples; ++i) { outL[i] = std::tanh(outL[i]); outR[i] = std::tanh(outR[i]); } return nbSamples; // 返回满满一瓢完美混合的水！ 封装打包给编码器 混合好的完美音频，最终需要装进FFmpeg认识的箱子(AVFrame)里，送去压缩编码。\n// 1. 看看编码器的“瓢”有多大 (frameSize) int frameSize = m_aEncoder-\u0026gt;getContext()-\u0026gt;frame_size; while (true) { // 2. 调用上面的步骤，去拿一瓢混合好的水 int samplesRead = m_audioDSP-\u0026gt;popMixedData(m_mixL, m_mixR, frameSize); if (samplesRead \u0026lt; frameSize) break; // 如果水不够了，就跳出循环，等下次再来 // 3. 准备好箱子 (m_aFrame) if (av_frame_make_writable(m_aFrame) \u0026lt; 0) return; // 4. 把混好的水，小心翼翼地倒进箱子的左半区和右半区 float* dstL = (float*)m_aFrame-\u0026gt;data[0]; float* dstR = (float*)m_aFrame-\u0026gt;data[1]; memcpy(dstL, m_mixL, frameSize * sizeof(float)); memcpy(dstR, m_mixR, frameSize * sizeof(float)); // 5. 贴上时间戳标签 (pts)，表示这是什么时候的包裹 m_aFrame-\u0026gt;pts = m_totalAudioSamples + ptsOffset; m_totalAudioSamples += frameSize; // 6. 送上流水线去编码！ AVPacket* pkt = m_aEncoder-\u0026gt;encode(m_aFrame); } 全部流程：重采样翻译 -\u0026gt; FIFO蓄水缓冲 -\u0026gt; 按帧提取 -\u0026gt; 数学加法混音 -\u0026gt;Tanh防爆处理 -\u0026gt; AVFrame打包。\n7 视频编码 1.原始视频的”恐怖“体积\n视频就是快速翻动的连环画(帧)。一张1080P的彩色图片(格式是NV12),大约需要1920x1080x1.5≈3.11MB空间。如果帧率是60FPS，那么1秒钟的原始视频提及高达186MB。如果不经过压缩，在快的带宽也扛不住这样的数据洪流。\n2.视频编码的底层原理：榨干”冗余“\n编码(压缩)之所以可能是因为视频中充满了大量的”废话“（冗余数据）：\n空间冗余（帧内压缩）： 时间冗余（帧间压缩）： 7.1 I帧、P帧、B帧与GOP 为了实现”时间冗余“的压缩，视频编码有三种帧来实现。\nI帧（关键帧，Intra-coded）：这是一张完整的图片，不依赖任何其他真就能解码。他是预测的基础，但体积最大。 P帧（向前预测帧，Predictive）：他只记录当前画面与前面的I帧或P帧不同之处。体积较小。 B帧（双向预测帧，Bi-predictive）：他是机制压缩的产物，不仅参考前面的画面，还有参考后面的画面来计算差异。体积最小。 GOP（Group of Picture）： 两个I帧之间的距离。比如GOP = 30，意味着每30帧才有一个完整的全景图，中间全是P帧和B帧。\n低延迟的生死大敌：B帧 B帧需要参考未来的画面才能生成。这意味，采集到第2帧（如果是B帧）时，编码器不能立即编码发送，他必须把第2帧缓存在手里，死死等待第3帧（甚至第4帧）到来后，才能完成计算。这就是视频直播延迟的万恶之源。\n7.2 如何做到极致低延迟 在我们的推流项目中，为了把延迟压榨到毫秒级，我们在VideoEncoder::tryInitEncoder和网络传输层做了一系列极其冷酷的手术。\n7.2.1 封杀B帧 原理：宁可牺牲一点点压缩率，也绝不等待未来帧。\n**文件**: `FFmpegModules.h` **类与函数**: `VideoEncoder::tryInitEncoder` // [企业级优化 1] 强制关闭 B 帧，这是 RTMP 低延迟的核心 // 任何 B 帧都会导致播放器必须等待后续 P 帧到达才能解码，直接引入延迟 ctx-\u0026gt;max_b_frames = 0; // 宁可牺牲一点压缩率，也要求编码器做到“出一帧发一帧” 7.2.2 缩减码率控制缓冲 原理：编码器内部有个蓄水池，用来平滑码率峰值。如果设置太大，网络卡顿时数据会挤压在编码器里发不出去，导致巨大延迟。\n**文件**: `FFmpegModules.h` **类与函数**: `VideoEncoder::tryInitEncoder` // [企业级优化 3] 缩减码率控制缓冲区 (VBV Buffer) // 默认 buffer 往往是 1-2秒，导致网络波动时数据在编码器内部积压。 // 设为 0.5 秒或更小，迫使编码器在带宽受限时快速降低画质而不是堆积数据。 ctx-\u0026gt;rc_buffer_size = bitrate / 2; // 强行把编码器的“大肚皮”勒紧，积压上限减半 7.2.3 零延迟硬件级调优 不同的编码器有不同的内部秘籍:\nx264(软编)：设置av_opt_set()，这回彻底关闭Lookahead（向前缓冲）。 NVENC（NVIDIA显卡）：设置tune为ull，并强制delay为0，这是英伟达转为游戏直播设计的超低延迟模式。 AMF（AMD显卡）：设置usage为ultralowlatency。 QSV（Intel显卡）：强制async_depth为1，斩断异步深度的缓冲。 **文件**: `FFmpegModules.h` **类与函数**: `VideoEncoder::tryInitEncoder` // ---------------------------------------------------------------------- // [企业级优化 4] 针对不同编码器的低延迟参数深度调优 // ---------------------------------------------------------------------- // \u0026gt;\u0026gt;\u0026gt; 1. x264 软编优化 if (name == \u0026#34;libx264\u0026#34;) { // \u0026#34;zerolatency\u0026#34; 是核心，它会禁用 B 帧，去除 Lookahead，通过牺牲压缩率换取最低延迟 av_opt_set(ctx-\u0026gt;priv_data, \u0026#34;tune\u0026#34;, \u0026#34;zerolatency\u0026#34;, 0); ... // \u0026gt;\u0026gt;\u0026gt; 2. NVENC 硬编优化 (NVIDIA) else if (name.contains(\u0026#34;nvenc\u0026#34;)) { ... // 调优: ull (Ultra Low Latency) 是 NVENC 专门为直播设计的低延迟模式 av_opt_set(ctx-\u0026gt;priv_data, \u0026#34;tune\u0026#34;, \u0026#34;ull\u0026#34;, 0); // 强制 0 延迟缓冲，彻底干掉并行帧处理的延迟 av_opt_set(ctx-\u0026gt;priv_data, \u0026#34;delay\u0026#34;, \u0026#34;0\u0026#34;, 0); ... // \u0026gt;\u0026gt;\u0026gt; 3. AMF 硬编优化 (AMD) else if (name.contains(\u0026#34;amf\u0026#34;)) { // 开启 AMD 的超低延迟模式 av_opt_set(ctx-\u0026gt;priv_data, \u0026#34;usage\u0026#34;, \u0026#34;ultralowlatency\u0026#34;, 0); } // \u0026gt;\u0026gt;\u0026gt; 4. QSV 硬编优化 (Intel) else if (name.contains(\u0026#34;qsv\u0026#34;)) { // 异步深度设为1，斩断 Intel 硬件编码器的异步缓冲队列 av_opt_set(ctx-\u0026gt;priv_data, \u0026#34;async_depth\u0026#34;, \u0026#34;1\u0026#34;, 0); } 7.2.4 IO与缓冲优化 原理：编码好的数据包绝对不能在发送端停留。\n在StreamMuxer::applyNetWorkOptions中开启flush_packets=1强制立即发包。开启tcp_nodelay=1禁用系统的Nagle算法（不拼凑小包）。将FFmpeg内部的积压队列fifo_size从默认1万砍到50，一旦网络拥堵立即让上层感知。 **文件**: `FFmpegModules.h` **类与函数**: `StreamMuxer::applyNetworkOptions` void applyNetworkOptions(AVDictionary** opts) { // ... // [优化] 开启 TCP_NODELAY，禁用 Nagle 算法，对低延迟至关重要 // 让系统有包就发，绝对不为了节省头部开销去“拼凑大包” av_dict_set(opts, \u0026#34;tcp_nodelay\u0026#34;, \u0026#34;1\u0026#34;, 0); // ... // [关键优化] 减小 FFmpeg 内部的包缓冲。 // 原来的 10000 包太大了，会导致网络抖动时 FFmpeg 内部积压数秒数据而不报错 // 改为 100-200，一旦网络堵塞立即让上层(AdaptiveStreamController)感知并降码率 av_dict_set(opts, \u0026#34;fifo_size\u0026#34;, \u0026#34;50\u0026#34;, 0); // [关键优化] 强制立即发送，不进行微缓冲 // 数据一旦 mux 完成，绝不在 FFmpeg 的 IO 层做任何停留 av_dict_set(opts, \u0026#34;flush_packets\u0026#34;, \u0026#34;1\u0026#34;, 0); // ... } 8 音画同步 1.核心概念：简单来说，音画同步就是保证主播嘴唇动的那一刻，你刚好听到他的声音。\n在数字世界里，音频和视频是两条完全独立的流水线。它们被打碎成一个个的数据包（Packet），在网络中传输，最后再由播放器拼装。\n为了让它们对齐，我们给每一个画面和每一段声音贴上了一个“时间标签”，这就是PTS（Presentation Time Stamp，显示时间戳）。\n2.为什么音视频会跑偏？\n采集源的“心脏”不同： 麦克风的硬件时钟晶振和摄像头的硬件时钟晶振存在微小的物理误差。时间久了，一个会比另一个多跑几毫秒。\n处理耗时不同： 视频编码：（尤其是涉及到GPU、缩放、H.264压缩）的耗时远远大于音频编码。视频容易“迟到”。\n网络抖动：在发送端，音视频包就像公路上的车，有的堵车，有的畅通无阻。\n8.1 音画同步算法 为了把这两只脚绑在一起，业界总结了三种主流算法。\n1.同步到音频（Video to Audio）——最主流做法\n底层原理：以音频的播放时间为绝对标准(基准参考)。如果视频跑快了，就让视频“停下来等一等”（重复显示上一帧）；如果视频跑慢了，就让视频“快进”（直接丢弃当前帧）。 优势：人类的耳朵对声音的卡顿、变调极其敏感（稍微断一下就会觉得刺耳），但人类的眼睛是个“笨蛋”，视频一秒钟少一两帧（丢帧）根本察觉不出来。 劣势：视频画面在极其恶劣的网络下可能会出现轻微的跳跃感。 2.同步到视频（Audio to Video）\n底层原理：以视频时间为准。如果音频慢了，就把音频数据“拉伸”（重采样填补空缺）；如果音频快了，就把音频“压缩”（丢弃音频采样点）。 优势：画面极其平滑流畅。 劣势：改变音频长度通常会导致“变调”（声音变尖锐或低沉），需要极其复杂的算法（如Sonic或SoundTouch）来保证变速不变调，极其消耗CPU。 3.同步到外部时钟（Sync to System Time）\n底层原理：不偏袒任何一方，那一块客观的“系统秒表”。音频和视频都看这块秒表决定自己的去留。 劣势：非常适合直播推流和多路混流的场景。 本项目使用**“基于时间戳统计的视频同步到音频”**策略。因为音频的采样点数量极其稳定，我们用视频来丈量时间。\n8.2 音画同步代码 FFmpegRecorder.cpp中把“视频采集慢”与“编码器慢”这两种极其容易混淆的情况，通过**“底层补帧”和”高层截流“**的维度彻底剥离开来。\n8.2.1 建立绝对时间轴与丈量差距（Diff） 音频一直在均匀采集，视频一直在持续出帧。我们只需要拿“总采样数”和“总帧数”处理它们的速率，就能算出理论上极其精确的物理时间。然后用游标卡尺一量：Diff = 音频时间-视频时间。\n**【对应源码 `FFmpegRecorder.cpp -\u0026gt; processVideo`】** // 算出精准的物理时间 double audioSec = (double)m_totalAudioSamples / m_config.audio.sampleRate; double videoSec = (double)m_videoPtsCounter / m_config.base.fps; double frameDur = 1.0 / m_config.base.fps; // 1帧的标准时长 (如 0.033s) // 计算差距 (Diff) double diff = audioSec - videoSec; 8.2.2 视频跑太快 如果视频跑太快，绝对不能调用Sleep()睡眠等待，否则整条推流线程都会卡死，声音直接断流。一旦发现视频帧超过前一帧（diff + framDur \u0026lt; 0）直接将这张刚渲染好的画面当场销毁，绝不阻塞流水线。\n**【对应源码 `FFmpegRecorder.cpp -\u0026gt; processVideo`】** if (diff \u0026lt; -frameDur) { dropFrame = true; // 宣判死刑 m_droppedFrames++; // 记录罪证 } // ... if (dropFrame) { av_frame_free(\u0026amp;srcFrame); // 物理销毁内存，直接扫进垃圾桶 return; // 立刻返回，绝不阻塞线程！ } 8.2.3 视频跑太慢 当Diff \u0026gt; 0.033s时，视频落后了。此时有两个情况：\n1.摄像头没采集到（采集慢）\n2.显卡算不过来（编码慢）\n8.2.3.1 场景一：摄像头卡了 此时显卡很闲，但时间轴上缺了画面。代码会启动“克隆印章”，把当前好不容易拿到的一张画面，连续加盖时间戳，向编码器连塞好几次（最高限制补5帧防止死循环）。用静止的画面填满时间轴黑洞，保住不黑屏。\n**【对应源码 `FFmpegRecorder.cpp -\u0026gt; processVideo`】** if (diff \u0026gt; frameDur) { // 算出缺了几个坑位 duplicateCount = (int)(diff / frameDur); if (duplicateCount \u0026gt; 5) duplicateCount = 5; // 限制最多补5帧 m_duplicatedFrames += duplicateCount; } // 处理补帧 (疯狂印章) for (int i = 0; i \u0026lt; duplicateCount; ++i) { frameToEncode-\u0026gt;pts = m_videoPtsCounter; encodeAndWriteVideo(frameToEncode); // 连续多次塞给编码器 m_videoPtsCounter++; // 时间轴向前推进 } 8.2.3.2 场景二：GPU编译器慢了 如果编码器已经卡冒烟，继续使用刚刚的策略，系统会崩溃。\n所以跳出processVideo，看我们在run()线程循环里写的最高级防线：**外部自适应流控！**当发现耗时超标，会在源头直接拦截，强行把30FPS砍到20FPS，把帧扔进垃圾桶。\n**【对应源码 `FFmpegRecorder.cpp -\u0026gt; run` 主循环】** // 获取 AI 流控策略 StreamConfig strategy = m_adaptiveCtrl-\u0026gt;getCurrentConfig(); bool shouldDrop = false; // 发现网络/编码质量差，源头强行降帧 if (strategy.quality == NetworkQuality::Poor) { if (m_frameIdx % 3 == 0) shouldDrop = true; // 30fps 强降 20fps } if (shouldDrop) { // 拦截：根本不进 processVideo 的同步算法！直接丢弃保命！ } else { processVideo(vFrame); // 机器健康，才放行处理 } 8.2.4 音频的绝对统治 音频的代码异常安静。它不需要看别人脸色，不需要任何复杂的if-else。它只做一件事：老老实实的的把采样点加起来做为自己的时间戳。\n**【对应源码 `FFmpegRecorder.cpp -\u0026gt; processAudio`】** // 没有丢弃，没有克隆，单纯的累加就是真理 int64_t ptsOffset = av_rescale_q(m_config.audio.globalSyncOffsetMs, {1, 1000}, m_aEncoder-\u0026gt;getContext()-\u0026gt;time_base); m_aFrame-\u0026gt;pts = m_totalAudioSamples + ptsOffset; m_totalAudioSamples += frameSize; // 老老实实铺砖 9 基于FFMPEG实现直播录制过程 9.1 全局与网络环境初始化 音视频流转之前，必须初始化FFmpeg的全局网络组件以及系统的硬件加速上下文（如D3D11）。\n实际意义：为后续的RTMP/SRT等网络协议推流加载底层Socket库，并为硬件编码器（如NVENC）准备GPU内存交互通道。 代码位置：FFmpegRecorder.cpp -\u0026gt; FFmpegRecorder::initNetwork()和startRecord() 代码片段：\nvoid FFmpegRecorder::initNetwork() { avformat_network_init(); } // 在 startRecord 中初始化 D3D 设备 if (!m_d3dDevice) { setD3DDevice(DX11Context::instance().device()); } 核心FFmpeg API 解析： avformat_network_init()：这是一个全局函数。它会在底层注册所有的网络协议（如HTTP、RTMP、TCP、UDP）。如果不调用此函数，后续使用avio_open2尝试连接直播服务器，会直接报错“找不到协议”。 9.2 编码器初始化 这是决定**“直播超低延迟”和”录播高画质“**分水岭的一步。系统通过读取配置，针对性地设置编码器参数。\n实际意义： 直播：极致压缩缓冲，强行关闭B帧，牺牲部分压缩率换取绝对的实时性。 录播：允许较大的GOP和B帧，使用VBR/CBR追求更高的画面质量。 代码位置：FFmpegRecorder.cpp -\u0026gt; FFmpegRecorder::run()（预处理配置）以及FFmpegModules.h -\u0026gt; VideoEncoder::tryInitEncoder。 // 在 FFmpegRecorder::run() 中针对直播强制覆盖低延迟参数 if (m_config.streaming.enable) { m_config.streaming.tune = \u0026#34;zerolatency\u0026#34;; m_config.streaming.preset = \u0026#34;superfast\u0026#34;; m_config.streaming.max_b_frames = 0; // 核心：禁用 B 帧 } // 在 FFmpegModules.h 中执行底层初始化 ctx = avcodec_alloc_context3(codec); ctx-\u0026gt;max_b_frames = 0; // 对应直播 ctx-\u0026gt;rc_buffer_size = bitrate / 2; // 控制 VBV 缓冲区 // ... av_opt_set(ctx-\u0026gt;priv_data, \u0026#34;tune\u0026#34;, \u0026#34;zerolatency\u0026#34;, 0); int ret = avcodec_open2(ctx, codec, nullptr); 核心FFmpeg API 解析： avcodec_find_encoder_by_name()：通过字符串（如”libx264“,\u0026ldquo;h264_nvenc\u0026rdquo;）在FFmpeg注册表中查找对应的编码器驱动。 av_opt_set()：极其关键的底层调优接口。FFmpeg的编码器（尤其是第三方库如x264/nvenc）有大量的私有参数（priv_data）。设置”runes“，”zerolatency“会在底层指示x264禁用Lookahead（未来帧预测）并抛弃B帧，确保画面输入后立马能被编码器出来。不会驻留编码器内部。 avcodec_open2()：根据上述配置，正式向操作系统申请内存并打开编码器实例。 9.3 封装器初始化与网路握手 编码器准备好后，需要创建容器（FLV/MP4）来承载编码后的数据。这里通过多态（FileMuxer和StreamMuxer）实现了同时写本地和推网络。\n实际意义： 录播：在本地磁盘创建**.mp4**文件并写入文件头(Moov Box)。 直播：与远程流媒体服务器（如Nginx-RTMP。BiliBili）建立TCP连接，完成RTMP握手，并发送FLV Metadata（包含流媒体信息）。 // StreamMuxer 网络推流初始化 av_dict_set(\u0026amp;opts, \u0026#34;tcp_nodelay\u0026#34;, \u0026#34;1\u0026#34;, 0); // 禁用 Nagle 算法 av_dict_set(\u0026amp;opts, \u0026#34;flush_packets\u0026#34;, \u0026#34;1\u0026#34;, 0); // 无缓冲立即发送 av_dict_set(\u0026amp;opts, \u0026#34;rw_timeout\u0026#34;, \u0026#34;3000000\u0026#34;, 0); // 3秒超时 ret = avformat_alloc_output_context2(\u0026amp;fmtCtx, nullptr, \u0026#34;flv\u0026#34;, m_path.toStdString().c_str()); ret = avio_open2(\u0026amp;fmtCtx-\u0026gt;pb, m_path.toStdString().c_str(), AVIO_FLAG_WRITE, nullptr, \u0026amp;opts); avformat_write_header(fmtCtx, nullptr); 核心FFmpeg API 解析：\navformat_alloc_output_context2()：自动根据后缀(.mp4)或强指定格式(\u0026ldquo;flv\u0026rdquo;)分配上下文AVFormatContext。 avio_open2：这是I/O层的核心。对于直播URL，它会阻塞执行DNS解析、TCP三次握手和应用层握手。代码中传入字典opts（如tcp_nodelay=1）直接作用于底层Socket API，避免数据包在系统缓冲中滞留。 avformat_write_header()：将参数（如视频宽高、音频采样率）格式化并写入容器头部。直播时，这一步实际上就把流配置发给了服务器，服务器此时才知道即将收到什么流。 9.4 数据预处理 在将画面和声音发送给编码器前，必须将其转化为编码器唯一认识的格式（如视频NV12，音频FLTP）。\n实际意义：处理用户采集的任意分辨率画面和多路音频（麦克风+系统音）的混合，统一采样率和格式。 代码位置：FFmpegRecorder.cpp -\u0026gt; initScaler/processVideo 以及FFmpegModules.h -\u0026gt; AudioDSP::pushData。 // 视频缩放与格式转换 m_swsCtx = sws_getCachedContext(..., SWS_BICUBIC, ...); sws_scale(m_swsCtx, srcFrame-\u0026gt;data, srcFrame-\u0026gt;linesize, 0, data.height, m_scaledFrame-\u0026gt;data, m_scaledFrame-\u0026gt;linesize); // 音频重采样 int convertedSamples = swr_convert(ctx.swr, ctx.resampleBuffer, outSamplesMax, inputData, inSamples); av_audio_fifo_write(ctx.fifo, (void**)ctx.resampleBuffer, convertedSamples); 核心FFmpeg API 解析： sws_scale()：底层利用CPU SIMD指令集（如AVX/SSE）对图像进行高效的矩阵运算和插值（如双三次插值SWS_BICUBIC），以实现色彩空间转换（RGB-\u0026gt;YUV）和画面缩放。 swr_convert()\u0026amp; av_audio_fifo_write()：swr_convert使用FIR滤波器对音频采样点进行重新计算（如48khz转44.1khz）。由于音频编码要求固定大小的帧（如AAC每帧1024个采样点），重采样后数据量往往不规整，因此底层必须用AVAudioFifo(环形缓冲区)暂存数据，以便后续定长抽取。 9.5 核心编码 将庞大的裸数据（YUV/PCM）压缩成体积极小的包（H.264/AAC）。\n实际意义：节省网络带宽和磁盘空间。 代码位置：FFmpegModules.h —\u0026gt;VideoEncoder::sendAndReceive。 AVPacket* VideoEncoder::sendAndReceive(AVFrame* frame) { int ret = avcodec_send_frame(ctx, frame); // 送入裸帧 if (ret \u0026lt; 0 \u0026amp;\u0026amp; ret != AVERROR_EOF) return nullptr; ret = avcodec_receive_packet(ctx, pkt); // 取出压缩包 if (ret == 0) return av_packet_clone(pkt); return nullptr; } 核心FFmpeg API解析： avcodec_send_frame() \u0026amp; avcodec_receive_packet()：这是FFmpeg 3.x 引入的异步收发API。 底层逻辑：调用send时，FFmpeg会将帧拷贝到内部缓冲队列或提交给GPU（硬件编码）。调用receive时，尝试获取压缩结果。 直播与录播的差异表现：在直播模式（0B帧）下，送入1帧通常马上能receive一个包。而在录播模式下，可能需要送入各多帧后（为了计算B帧），才能拉取到第一个包（存在一定的滞后性）。 9.6 封包写入与异步发送 为了防止推流时网络波动卡住主编码线程，代码将生成的AVPacket压入队列，由单独的StreamSender线程复责实际的网络发送和文件写入。\n实际意义：完成最终的音画同步（时间戳转换），并将数据安全地落地到磁盘和推送到远端。 代码位置：FFmpegModules.h -\u0026gt; BaseMuxer::writePacket 与 StreamSender::run。 int BaseMuxer::writePacket(AVPacket* pkt, const AVRational\u0026amp; sourceTimeBase, bool isVideo) { // 转换时间基 av_packet_rescale_ts(localPkt, sourceTimeBase, outStream-\u0026gt;time_base); // 交织写入 int ret = av_interleaved_write_frame(fmtCtx, localPkt); return ret; } 核心FFmpeg API解析：\nav_packet_rescale_ts()：时间基转换。编码器通常以帧率作为时间基（如1/30），而容器（如FLV）通常以毫秒（1/1000）为时间基。此函数按比例精确缩放PTS和DTS，防止播放端出现音画不同步或快放/慢放现象。\nav_interleaved_write_frame()：\n底层逻辑：不仅仅是\u0026quot;写入\u0026quot;。interleaved意味着他会在内部维护一个缓存，自动将视频包和音频包按时间戳顺序交织排序（例如：\n交替排布1s视频和1s音频）。这能确保播放器在拉流时顺序读取既可以完美回放，不需要在内存里缓存大量数来对齐。\n结合前面的flush_packets=1选项，当交织完成后，底层会立即调用Socket send()将其发送网络，实现直播的极低延迟。\n10 ABR?为什么要防阻塞？ 1.什么是自适应码率（Adaptive Bitrate,ABR）？\n在流媒体推流中，ABR是一种根据当前网络实时带宽，动态调整视频编码输出码率（画质/帧率）的技术。\n2.为什么需要ABR和防阻塞？\n网络抖动的必然性：由于Wi-Fi信号干扰、基站切换、宽带共享等原因，主播端的上行带宽经常发生剧烈波动。 不进行干预的恐怖后果（TCP头部阻塞）：直播推流通常使用RTMP（基于TCP）。当网络拥堵（发送速度\u0026lt;编码速度）时，数据包会在操作系统和发送缓冲区和FFmpeg内部队列中疯狂积压。 后果一：极高的延迟。 后果二：内存爆满，甚至导致程序OOM（Out of Memory）崩溃。 后果三：当队列终于排空时，播放端会瞬间收到大量过期数据，导致画面鬼畜式快进。 10.1 AIMD防拥塞算法 在本项目中，AdativeStreamController的核心灵魂是AIMD算法（加法增大，乘法减小）。这也是TCP拥塞控制的核心思想。\n1.侦测拥塞\n流媒体程序就像坐在车里的司机，看不到前面的路况，智能通过“感觉”来判断。\n判断A：发送耗时变长。 //AdaptiveStreamController::reportSentStats() // --- 核心算法：判断拥塞 --- // 判据1: socket 发送阻塞超过 100ms (说明带宽满了) // 判据2: 平均延迟持续升高 bool isCongested = (durationMs \u0026gt; 200) || (m_avgLatency \u0026gt; 150); 如果在正常网络下发送一个包只要5ms，现在却要200ms。说明网卡堵了。\n判断B：队列积压。 //FFmpegRecorder::checkNetworkHealth int queueSize = m_sender-\u0026gt;getQueueSize(); // [调优] 放宽丢帧阈值，高分辨率下包可能会更多 const int THRESHOLD_DROP = 30; // 原 30 -\u0026gt; 100 (给予更多缓冲时间) const int THRESHOLD_CONGESTION = 20; // 原 10 -\u0026gt; 20 // ... else if (queueSize \u0026gt; THRESHOLD_CONGESTION) { // ... 处理轻微拥塞 } 在FFmpegRecorder::checkNetworkHealth中，如果发现m_sender里积压队列超过了THRESHOLD_CONGESTION，说明发送速度已经跟不上编码速度了。\n2.AIMD算法\n乘法减小（MD）——遇到拥堵，重拳出击保命：\n在AdaptiveStreamController::adjustBitrate中，一旦判断网络拥塞，我们会断崖式降低码率。\n如果连续拥塞次数少，码率乘以0.7（砍掉30%）\n如果连续拥塞超过2次，码率直接乘以0.5（腰斩）。\n底层逻辑：拥塞发生时，带宽雪崩是瞬间的，必须要最猛烈的手段清空拥塞窗口，否则死路一条。\n//AdaptiveStreamController::adjustBitrate if (isCongested) { // [乘法减小] Multiplicative Decrease m_consecutiveCongestionCount++; m_consecutiveGoodCount = 0; // 拥塞越严重，降得越快 float dropFactor = (m_consecutiveCongestionCount \u0026gt; 2) ? 0.5f : 0.7f; m_currentBitrate = static_cast\u0026lt;int\u0026gt;(m_currentBitrate * dropFactor); qWarning() \u0026lt;\u0026lt; \u0026#34;[Adaptive] Network Congestion! Dropping Bitrate to:\u0026#34; \u0026lt;\u0026lt; m_currentBitrate / 1000 \u0026lt;\u0026lt; \u0026#34;kbps\u0026#34;; m_lastAdjustmentTime = now; } 加法增大（AI）——网络恢复，小心试探： 连续持续3s以上网络顺畅(m_consecutiveGoodCount \u0026gt; 3)，我们每次只增加50kbps。 底层逻辑：不能加的太猛，否则会立刻再次引发拥塞。这种线性的试探可以安全地摸索出当前带宽的真正上限。 //AdaptiveStreamController::adjustBitrate else { // [加法增大] Additive Increase m_consecutiveCongestionCount = 0; m_consecutiveGoodCount++; // 网络稳定 3秒后才开始试探性增加 if (m_consecutiveGoodCount \u0026gt; 3) { m_currentBitrate += 50000; // 每次加 50kbps m_lastAdjustmentTime = now; } } ","permalink":"https://toyezi.cn/posts/av-streaming-recording-engine/","summary":"从异步流水线与多级生产者-消费者架构、YUV/NV12 与 GPU 零拷贝，到音频重采样混音、FFmpeg 编码封装、音画同步与 AIMD 弱网防拥塞的学习记录。","title":"工业级音视频推流与录制引擎"},{"content":"1 总体设计 引擎在顶层设计上采用了面向接口编程的思想，主要分为两个核心部分：\n统一的特效契约（IEffect接口）：所有特效（无论是磨皮、美白还是贴纸）都必须继承IEffect基类。这个基类抽象出了三个核心动作：process(处理图像)、setParameter(设置参数)和开关状态控制。 集中的大管家（EffectManager）：充当门面(Facade)，对外隐藏了所有滤镜的具体调用细节。业务层只需要告诉Manager我要开启美白，参数设为50，Manager就会找到对应的特效对象并下发指令。 1.1 为什么要这样做？（优势） 极致的解耦（符合开闭原则OCP）：如果新增一个“变脸”特效，不需要修改EffectManager的核心逻辑，只需要新建一个类继承IEffect，然后再EffectConfigurator中注册即可。\n统一的参数总线：利用std::string和QVariant作为参数传递的载体，打破了不同特效参数类型不一致(有的需要float，有的需要string)的壁垒，这使得UI层利用反射或数据绑定动态生成调节滑块变得简单。\n2 特效管线设计 特效管线是一种基于责任链模式（Chain of Responsibility）的变形，他将各种独立的图像处理操作（如磨皮、美白、贴纸）串联起来，按严格的顺序依次执行。\n统一的抽象接口：定义了IEffect基类，规定了所有特效必须实现process和setParameter等方法。这使得管线可以按统一的方式对待所有特效，而不需要知道具体的实现细节。\n有序的执行队列：在 EffectManager 中，管线被存储为一个包含EffectNode的std::vector，每个节点都有一个order（权重/优先级）。当调用registerEffect注册特效时，系统会自动根据order进行排序。\n科学的处理顺序：在EffectConfigurator::configureDefaultPipeline中，定义了执行顺序：\n底层处理：磨皮(10)-\u0026gt;美白(20)。必须最先执行，为后续提供干净的底图。 形变与妆容：瘦脸/形变(30)-\u0026gt;美妆(35)。 色彩与滤镜：滤镜(40)-\u0026gt;对比度/亮度(50)。 前景叠加：贴纸(60)。必须在色彩调整后加上，否则贴纸会被滤镜影响导致偏色。 后处理：背景模糊(70)-\u0026gt;水印(80)。水印放在最后，防止被前面的任何特效(比如磨皮或模糊)破坏。 3 人脸检测 在数字图像处理的世界里，所有的操作本质上都是对像素点坐标的数学运算。\n算力与目标的脱节：如果没有人脸检测，极致的SIMD液化算法（如：瘦脸、大眼）将面临”盲人摸象“的困境。虽然算法拥有每秒处理数百万像素的强大”肌肉“，但在缺乏引导的情况下，程序无法识别”眼睛“或”下巴“的具体像素位置。\n从像素到语义的跨越：人脸检测的任务是将无意义的RGB像素流翻译为计算机可理解的 ”语义坐标“ 。通过锁定人脸，程序才能精准下达指令：在指定的（x，y）坐标处执行局部形变或特效叠加。\nYuNet五点模型：本项目采用高性能的YuNet模型，通过FaceData结构体导出5个核心关键点：左眼、右眼、鼻尖、左嘴角、右嘴角。\n特效驱动基础：\n瘦脸/大眼：利用关键点确定的中心点（Center）和半径（Radius）驱动SIMD液化核。 AR贴纸：根据双眼间的距离（Dist）和斜率计算贴纸的缩放比例与旋转角度。 3.1 人脸检测 vs 人脸识别 在实时音视频领域，清晰定义”检测“与”识别“的边界对系统架构优化至关重要。\n人脸检测（Detection）： 其中：\n核心目标：回答”人脸在哪里？“。 技术焦点：关注矩形框位置与关键点精度。 实时性要求：极高。为了保证特效不掉帧，检测逻辑通常需要锁定在毫秒级执行。 人脸识别（Recognition）\n其中： 核心目标：回答“人脸是谁？”。 技术焦点：提取面部特征向量并与数据库比对。 应用差异：直播特效仅需“位置”数据来驱动渲染，并不关心用户身份。因此，本项目专注于检测，以换取极高的执行效率。 工程妥协策略： 由于AI推理依然存在性能开销，代码中采用了抽帧检测策略（每两帧检测一次：m_frameCounter++ %2 == 0），以平衡CPU占用率并保持画面流畅。 3.2 实现细节 在FaceTracker.cpp中，有几个点需要注意的：\n路径与编码保护： OpenCV的C++接口在Windows系统下，底层往往调用的是标准C的文件接口I/O。他们通常不支持UTF-8。如果用户的电脑用户名或模型路径包含中文，直接传std::string会导致OpenCV找不到文件触发内存崩溃。而**toLocal8Bit()**会根据操作系统的当前区域设置（Local）将字符串转换为正确的编码（如GBK）。 // FaceTracker.cpp -\u0026gt; loadModel 函数 QString qPath = QString::fromStdString(modelPath); // ... [略去路径检查代码] ... // 核心：将 Qt 的 UTF-16 字符串转为当前系统的本地编码 (ANSI/Local8Bit) std::string safePath = qPath.toLocal8Bit().constData(); m_detector = cv::FaceDetectorYN::create(safePath, \u0026#34;\u0026#34;, ...); 坐标还原映射 因为是在320尺寸下检测的，所有必须通过scaleX/scaleY将坐标还原到原始分辨率（如1080P），否则特效位置会全部错位。 // FaceTracker.cpp -\u0026gt; detect 函数 const cv::Size inferenceSize(320, 320); // 1. 固定推理尺寸 cv::resize(imgBGR, inputBlob, inferenceSize); // ... [推理得到 faceData] ... // 2. 计算缩放因子 (从 320 还原到原始 w x h) float scaleX = (float)w / 320.0f; float scaleY = (float)h / 320.0f; // 3. 物理空间坐标还原 m_currentFace.rect.x = (int)(faceData[0] * scaleX); m_currentFace.rect.y = (int)(faceData[1] * scaleY); // ... [同理还原 landmarks] ... 线程安全： 由于检测时异步或多模块共享的，代码通过std::lock_guard \u0026lt; std::mutex \u0026gt;确保了m_currentFace数据在读取和更新时不会发生数据竞争。 // FaceTracker.h mutable std::mutex m_mutex; // FaceTracker.cpp -\u0026gt; detect / getFaceData 函数 void FaceTracker::detect(const cv::Mat\u0026amp; imgBGR) { // ... std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 1. 更新时加锁 m_currentFace = ...; } FaceData FaceTracker::getFaceData() const { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 2. 读取时也必须加锁 return m_currentFace; } 4 CPU与GPU差异 CPU和GPU在芯片设计上存在根本分歧：“低延迟”、“高吞吐量”的博弈。\n4.1 CPU低延迟导向 架构优势：CPU的芯片面积大部分被巨大的多级缓存(L1/L2/L3 Cache)和复杂的控制单元（Control Unit）占据，仅保留极少数量的算术逻辑单元（ALU）。 核心优势：极高的单核时钟频率。其复杂的控制单元支持超标量执行、分支预测和乱序执行。这使得CPU在处理复杂的串行逻辑分支（如密集的if-else条件判断）时，能够以极低的延迟快速响应。 4.2 GPU高吞吐量导向 架构特征：GPU摒弃了复杂的控制流和庞大的缓存结构，将绝大部分晶体管全部分配给了计算核心（成千上万个精简的ALU）。 核心优势：基于SIMT（单指令多线程）架构。虽然GPU的单核主频较低，且不具备复杂的逻辑预测能力，但它能够在同一时钟周期内，调度成千上万个轻量级线程执行完全相同的指令。 业务映射：视频画面由数百万个独立的像素点组成。如，亮度调节、色彩映射等特效操作，本质上是对这数百万个像素执行高度重复的数学运算。这完美契合了GPU的SIMT架构特征，使其在此类任务中展现出极其恐怖的吞吐能力。 5 双流设计 双流设计是指系统同时拥抱CPU计算和GPU(CUDA)计算两条平行的处理链路，并能在运行时根据硬件条件动态切换。这是保证软件在高端显卡和低端核显机器上都能稳定运行的我关键。 接口层的双路重载：IEffect接口强制(或默认)提供了两个process重载函数：一个是**cv::Mat\u0026amp;的CPU版本，另一个是接收cv::cuda::GpuMat\u0026amp;**的GPU 版本。 优雅的降级机制(Fallback)：如果某个特效还没有来得及实现GPU版本，在IEffect::process(cv::cuda::GpuMat\u0026amp;)基类中提供了一个非常巧妙地默认实现：将显存数据下载到内存（download）-\u0026gt;调用CPU的process-\u0026gt;在上传回显存(upload)。这保证了即使有部分特效支持GPU，整条管线依旧能跑通。 智能的硬件检测与分发：EffectManager在初始化时会调用checkCudaHardware()检测是否有支持CUDA的设备。 如果有，默认走processPipelineGpu，直接操作GpuMat显存数据。 如果没有，自动回退到processPipelineCpu处理流水线。 6 降级CPU处理逻辑 如果写了a = b + c 一行代码，CPU实际要执行多少步骤。\n取值、解码、执行、写回；\nvoid scalar_add(int* a, int* b, int* res, int len) { for (int i = 0; i \u0026lt; len; i++) { res[i] = a[i] + b[i]; } } #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;xmmintrin.h\u0026gt; // SSE核心头文件 #include \u0026lt;time.h\u0026gt; // 性能测试 #include \u0026lt;stdlib.h\u0026gt; // 生成随机数 // 【SIMD版本】源码优化版，核心优化逻辑 void simd_add(int* a, int* b, int* res, int len) { // 优化点1：按SIMD寄存器宽度批量处理（128位=4个int） int batch_len = len - (len % 4); // 计算能被4整除的长度 int i = 0; // 优化点2：批量处理核心逻辑（一次算4个数） for (; i \u0026lt; batch_len; i += 4) { // 步骤1：非对齐加载——把a[i]~a[i+3]装进XMM寄存器 __m128i vec_a = _mm_loadu_si128((__m128i*)(a + i)); // 步骤2：非对齐加载——把b[i]~b[i+3]装进XMM寄存器 __m128i vec_b = _mm_loadu_si128((__m128i*)(b + i)); // 步骤3：SIMD并行加法——一次算4组a[i]+b[i] __m128i vec_res = _mm_add_epi32(vec_a, vec_b); // 步骤4：非对齐存储——把4个结果一次性写回res _mm_storeu_si128((__m128i*)(res + i), vec_res); } // 优化点3：边界处理——剩余不足4个的数，标量兜底 for (; i \u0026lt; len; i++) { res[i] = a[i] + b[i]; } } 6.1 SIMD与CPU底层原理 执行模型的差异：\n传统的标量执行(SISD)：全称是Single Instruction Single Data（单指令单数据流）。传统的for循环就像是一个工人搬砖，每次只能处理单个数据单元(例如：一个像素的一个颜色通道)。 矢量执行(SIMD)：全称是Single Instruction Multiple Data(单指令多数据流)。可以理解为给这个工人配了一台叉车。指令依然是“搬运”，但一次操作可以同时处理4个、8个、甚至更多的数据单元。 CPU的底层架构秘密：\n通用寄存器：标准的32位或64位寄存器，一次只能存1个float或double数据。 超宽矢量寄存器：现代CPU内部集成了专用的宽字节寄存器。例如，XMM寄存器(128位)可以同时容纳4个32位的float；YMM寄存器(256位)可以同时容纳8个float。 硬件级并行：当调用SIMD加法指令时，CPU的算术逻辑单元(ALU)会在同一个时钟周期，对寄存器中的4个或8个浮点数同时完成加法计算。这是一种指令级别的纯硬件加速，而非软件层面的多线程。 6.2 SIMD与图像处理 严苛的算力与时间约束： 以1080P分辨率为例，一帧画面包含1920x1080 ≈ 207万个像素。 对于“瘦脸”或”大眼“这种空间形变(液化)算法，每一个像素坐标的重映射都涉及大量的浮点数乘法运算和极其耗时的开平方(sqrt)运算。 在 30FPS的实时处理标准下，CPU只有约33毫秒的时间来完成这一整帧的计算。传统的标量for循环在如此高频、密集的数学运算前，极易遭遇性能瓶颈，导致画面掉帧。 GPU的局限性与CPU的不可替代行： 虽然GPU天生适合大规模并行计算，但在实际工程架构中，为了避免 CPU内存 与 GPU显存 之间 频繁拷贝 带来的巨大总线延迟 或者在 缺乏高端独立显卡 的设备上，往往需要在CPU端预先计算好复杂的形变网格。此时，利用SIMD榨干CPU多核算力就成了最佳选择。 6.3 OpenCV通用指令集封装 直接编写底层的SIMD汇编指令，不仅编写困难，难以阅读，且无法跨平台运行。但OpenCV提供的封装库。它提供了一套通用的指令集。\n以下结合AdvancedEffects.cpp中的FaceReshapeEffect::warpLocal 函数，剖析普通的标量逻辑是如何转换为SIMD矢量逻辑的。\n第一步：数据的批量加载（Load） //FaceReshapeEffect: `void FaceReshapeEffect::warpLocal(cv::Mat\u0026amp; mapX, cv::Mat\u0026amp; mapY, const cv::Point2f\u0026amp; center, float r, float strength)` cv::v_float32 v_x = cv::v_load(ptrX + x); [cite: 1] 使用 cv::v_load(ptrX + x) 指令，CPU可以越过逐个读取的限制，一次性从内存地址中连续读取4个或8个像素的X坐标，加载到矢量寄存器中。\n第二部：矢量数学运算（Math） //FaceReshapeEffect: `void FaceReshapeEffect::warpLocal(cv::Mat\u0026amp; mapX, cv::Mat\u0026amp; mapY, const cv::Point2f\u0026amp; center, float r, float strength)` cv::v_float32 v_dist2 = cv::v_muladd(v_dx, v_dx, v_dy2); [cite: 1] 并行减法：cv::v_sub(v_x,v_cx) 能同时计算出多个像素距离中心点的横向偏移量。\n融合乘加（FMA）：代码中使用的 cv::v_muladd(v_dx, v_dx, v_dy2) 的指令。他在底层将乘法和加法融合为一步，直接算出\ndx^2+dy^2 不仅速度翻倍，还减少了中间结果的精度损失。\n第三步：数据的批量写回（Store） cv::v_store(ptrX + x, v_res_x); [cite: 1] 计算完成后，使用 cv::v_store(ptrX + x, v_res_x) 将寄存器中的多组新坐标一次性安全地写回内存。\n7 CUDA 架构与封装 拥有了庞大的GPU核心阵列，开发者需要一套标准化的接口来指挥这支”算力大军“。\n什么是CUDA？\nCUDA（Compute Unified Device Architecture，统一计算架构）是NVIDIA推出的一种通用并行计算平台的编程模型。\n他打破了早期GPU只能通过图形API（如OpenGL/DirectX）进行图形渲染的限制，允许开发者使用拓展的C/C++语法，直接操作底层GPU线程、线程块和网格，将GPU转化为通用计算引擎。\n7.1 OpenCV封装 在实际的企业级开发中，纯手工编写底层的CUDA Kernel函数（管理线程同步、共享显存分配等）开发成本极高。\n本项目采取了更加成熟的方案：利用OpenCV提供的cv::cuda模块。在接口设计中，定义了重载process(cv::cuda::GpuMat\u0026amp;img)虚函数。底层复杂的CUDA线程调度均由OpenCV完成封装，开发者只需要对显存数据结构（GpuMat）调用相应的滤波器或算子，即可轻松激活万核并行计算能力。\n7.2 显存屏障与传输瓶颈 尽管GPU计算能力惊人，但在CPU与GPU协同工作（异构计算）时，存在一个极容易被忽略的性能陷阱：PCle总线数据屏障。\n理论分析：当我们在代码中调用img.upload(cpuImg)将视频帧推送到GPU，或调用img.download(cpuImg)将渲染结果拉回CPU时，数据必须串行通过PCle总线。相较于GPU内部高达数百GB/s的显存带宽，PCle总线的带宽(通常不足20GB/s)极其有限。 性能陷阱：如果为了一次极其轻量的像素运算，将整帧1080P数据传入GPU。计算完成后传回，其 “通信耗时” 将远远大于“计算节省的时间” 。这回导致系统整体帧率不升反降。 7.3 最小化传输策略 为了跨越这一屏障，优秀的架构设计必须遵循“**计算向数据靠拢，最小化数据搬运”**的原则。\n在本项目中，我们通过两层机制进行了深度优化：\n全链路GPU驻留：一旦图像进入显存管线，尽可能让所有特效（磨皮、美白、滤镜)按顺序直接在GpuMat上连续处理，拒绝中间环节的频繁下传。\nROI（Region of Interest）混合裁剪：当遇到高度依赖CPU复杂数学逻辑的特效（根据人脸关键点进行贴纸的旋转计算和矩阵混合)时，我们不下载全图。例如在特效贴纸(StickerEffect)和瘦脸特效(FaceReshapeEffect)中，代码现在CPU端计算出极小的目标矩阵区域(cv::Rect roi)，随后利用gpuRoi.download（cpuRoi)将这块极小的局部数据传回给CPU进行复杂处理，完成后再局部覆盖回显存。这极大限度地规避了PCle的带宽瓶颈。\n7.4 代码实现 当遇到GPU不好写的逻辑时，如何进行CPU/GPU混合处理。\n空间变换与网格映射（代码走读FaceReshapeEffect瘦脸大眼）： 大眼和瘦脸的本质：坐标重映射（Remap）。 架构巧思：为什么网格（MapX/MapY）的复杂数学生成逻辑放在CPU做，而最终的cv::cuda::remap像素映射交由GPU做？（发挥各自特长） void FaceReshapeEffect::process(cv::cuda::GpuMat\u0026amp; img) { if (!m_face.hasFace) return; // 【步骤 1：发挥 CPU 特长】在 CPU 上计算网格 (数学运算 CPU 单核足矣，且逻辑复杂不适合手写 CUDA Kernel) updateMaps(img.cols, img.rows); // 注：updateMaps 内部调用了经过 SIMD 优化的 warpLocal 函数，更新 m_mapXCpu 和 m_mapYCpu // 【步骤 2：跨越屏障】上传计算好的轻量级坐标网格到 GPU 显存 m_mapXGpu.upload(m_mapXCpu); m_mapYGpu.upload(m_mapYCpu); // 【步骤 3：发挥 GPU 特长】GPU Remap (极快)，利用万核算力瞬间完成全图 1080P 像素的插值重绘 cv::cuda::remap(img, img, m_mapXGpu, m_mapYGpu, cv::INTER_LINEAR, cv::BORDER_REFLECT_101); } ROI（Region of Interest）的极致裁剪： 第一步：在GPU上计算好紧凑的ROI区域，严格限制越界\n// 计算包围嘴唇的最小矩形区域 int x1 = std::max(0, (int)(center.x - width - 10)); int y1 = std::max(0, (int)(center.y - height - 10)); int x2 = std::min(img.cols, (int)(center.x + width + 10)); int y2 = std::min(img.rows, (int)(center.y + height + 10)); cv::Rect roi(x1, y1, x2 - x1, y2 - y1); roi \u0026amp;= cv::Rect(0, 0, img.cols, img.rows); // 安全防御：防止越出 1080P 画布 if(roi.width \u0026lt;= 0 || roi.height \u0026lt;= 0) return; 第二步：仅将这块极小的区域download到CPU\n// 下载 ROI 到 CPU 处理 cv::cuda::GpuMat gpuRoi(img, roi); // 划定 GPU 上的感兴趣区域（不发生拷贝，仅偏移指针） cv::Mat cpuRoi; gpuRoi.download(cpuRoi); // 核心妥协：仅下载这一小块“嘴唇”，极大节省了 PCIe 传输带宽 第三步：在CPU端利用OpenCV的基础函数完成复杂数学计算与Alpha混合\n// 绘制 Mask (利用 CPU 端成熟的 cv::ellipse 和 cv::GaussianBlur 逻辑) cv::Mat mask = cv::Mat::zeros(cpuRoi.size(), CV_8UC1); cv::Point2f localCenter(center.x - roi.x, center.y - roi.y); cv::ellipse(mask, localCenter, cv::Size(width, height), 0, 0, 360, cv::Scalar(255), -1); cv::GaussianBlur(mask, mask, cv::Size(15, 15), 0); // Alpha 混合: Out = Src * (1 - alpha) + Overlay * alpha float alpha = m_lipstickLevel / 200.0f; for(int y=0; y\u0026lt;cpuRoi.rows; y++) { uchar* mPtr = mask.ptr\u0026lt;uchar\u0026gt;(y); cv::Vec4b* iPtr = cpuRoi.ptr\u0026lt;cv::Vec4b\u0026gt;(y); // BGRA for(int x=0; x\u0026lt;cpuRoi.cols; x++) { if (mPtr[x] \u0026gt; 0) { float k = (mPtr[x]/255.0f) * alpha; iPtr[x][1] = cv::saturate_cast\u0026lt;uchar\u0026gt;(iPtr[x][1] * (1-k*0.5)); // 降低绿通道 iPtr[x][2] = cv::saturate_cast\u0026lt;uchar\u0026gt;(std::min(255.0f, iPtr[x][2] + 50.0f * k + iPtr[x][2]*k)); // 提亮红通道 } } } 第四步：upload贴回GPU原图，完成微创手术\n// 完美缝合：将处理好的小图直接覆盖回 GPU 原图的 roi 坐标位置 gpuRoi.upload(cpuRoi); ","permalink":"https://toyezi.cn/posts/yunet-face-tracking-vfx/","summary":"从 IEffect 特效契约与责任链管线、CPU/GPU 双流降级，到 SIMD 矢量优化、YuNet 五点关键点人脸追踪与 CUDA 显存驻留策略的学习记录。","title":"YuNet人脸追踪与AI特效引擎"},{"content":"1 流媒体输出引擎 在实时直播与录制系统中，系统需要同时采集多个异构素材，如4K屏幕捕获、1080P摄像头、高频更新的文字等，并将其合成为一路稳定的60FPS视频流。\n传统的CPU合成方案存在以下致命痛点：\n内存带宽瓶颈：在CPU中进行像素拷贝与叠加会产生巨大的总线开销。 转换损耗：将渲染用的RGB数据转换为编码用的YUV格式时，CPU运算耗时极长。 线程阻塞：复杂的合成逻辑若与采集或UI逻辑混杂，会导致严重的丢帧。 为此，本系统设计了以SceneComositor为核心的异步GPU合成架构。\n这是一个经典的“生产者-消费者”渲染管线架构。在复杂的音视频软件（如：OBS、直播客户端）中，为了保证界面不卡顿且渲染高效率，通常会将“采集”、“缓冲”、“渲染”、”输出“严格剥离到不同线程中。\nSceneCompositor（场景合成器）正式这个架构中的核心消费者与混流引擎。\n2 视频捕获技术选型 屏幕采集技术-DWM处理逻辑\nGDI(BitBlt):兼容性最好。但纯CPU拷贝，性能极差，会导致画面卡顿、CPU占用飙升，且无法采集硬件加速窗口(如Chrome浏览器，大部分游戏会黑屏)\nDXGI(Desktop Duplication API)：性能极好。但只能全屏采集(抓整个显示器)。如果只要抓某个特定窗口，需要自己算裁剪坐标，并且抓出来的画面有可能是被挡住的\nWGC是微软在Windows10引入的现代采集API。\n不怕遮挡：直接从DWM(桌面窗口管理器)的独立缓冲提取画面，窗口被挡住依旧能录制。 支持硬件加速窗口：完美抓取Chrome、游戏甚至透明特效。 超高性能：完全基于GPU操作，纹理数据(Testure)直接留在显存里，不经过GPU，极大地释放了系统资源。 黄边框提示：系统级隐私保护，采集时窗口边缘会有黄框提示(Windows 11新版可关闭) 2.1 WGC核心 1.GraphicsCaptureItem(采集目标)：告诉系统需要抓取什么，可以是显示器，可以是指定的窗口。\n2.Direct3D11CaptureFramePool(帧缓冲池)：WGC不是每次分配新内存，而是创建一个池子。DWM会源源不断地把采集到的新画面(DXGI纹理)扔进这个池子。高性能的原因：使用CreateFreeThreaded，画面到达回调会在后台工作线程触发，不阻塞UI主线程。\n3.GraphicsCaptureSession(采集会话)：采集的开关，调用StartCapture()开始源源不断进水，Close()关闸。\n3 Windows摄像头捕获视频架构 3.1 DirectShow 在早期（Windows 98到Windows XP），Windows的多媒体核心是DirectShow。\n架构思想：他采用的是滤镜图架构。微软将整个流程定义为一个流水线，包含原滤镜(Source Filter，即摄像头)、变换滤镜(Transform Filter，如解码/格式转换）和渲染滤镜（Render Filter，如屏幕显示）。\n痛点：随着高清视频和3D游戏的普及，DirectShow暴露出了致命缺陷-它与现代显卡架构(DirectX 11/12)结合起来极其勉强。视频数据经常需要在系统内存（CPU）和显存（GPU）之间来回拷贝，导致极高的CPU占用率。\nDirectShow的致命缺陷：摄像头数据必须先引入系统内存（RAM），交由CPU，拆包、解码，导致CPU100%满载，处理完的图像通过PCle总线拷贝回显卡，带宽极度拥挤，引发高延迟和卡顿。\n3.2 Media Foundation 为了适应高清化和现代GPU，微软从Windows Vita开始，彻底废弃了DirectShow的底层设计，重写了全新的多媒体核心引擎：Media Foundation（MF）\n架构思想：引入了媒体拓扑和媒体会话概念。\n架构革命：\n1.原生DXGI整合：Media Foundation在最底层与DirectX图形基础设备（DXGI）打通。这意味着摄像头采集到的画面，可以“零拷贝”，地直接驻留在显卡显存（VRAM）中，交由GPU进行渲染或硬件编码，CPU甚至都不需要碰这些像素。\n2.极致的异步化：所有的调用全部变成异步操作，彻底解决了老架构容易阻塞主线程导致UI卡死问题。\nMF零拷贝革命的意义：彻底打通底层DirectX接口，图像直接存入显存（VRAM）形成DXGI纹理，格式转换/缩放全靠GPU流处理器一瞬间完成，CPU彻底释放，0%负载处理4K/8K视频。\n3.3 WinRT MediaCapture WinRT MediaCapture是建立在Media Foundation极限性能基础上的现代工程化封装。\nMedia Foundation是Windows的核心引擎层，他直接与操作系统内核和显卡(DirectX)对话，追求的是极致的控制力和零拷贝性能。\nWinRT MediaCapture是高级抽象层。他底层完全是MF引擎，但对外暴露是C++接口。\n3.3.1 Media Foundation白盒图论 \u0026amp; WinRT MediaCapture黑盒端点 3.3.1.1 白盒图论 本质定义：内部结构与数据流向对开发者完全透明，且高度依赖人工干预。\n运行机制：开发者扮演“底层管线构建者”。必须手动实例化媒体源(IMFMediaSource)、色彩转换节点（IMFTransform）和接收器（IMFMediaSink），并且亲手完成所有引脚连接与格式协商。\n优劣性：拥有像素级与内存级的绝对控制权。代价是代码极度冗长，且任何内存对齐或格式协商的细微错误都会导致整条管线崩溃。\n3.3.1.2 黑盒端点 本质定义：基于外观模式，将底层复杂管线完全封装，向外仅暴露有限的控制面板。\n运行机制：开发者转变为“业务调度者”。仅需创建MediaCapture对象，向其注入MediaCaptureInitializationSettings（声明分辨率、内存类型等需求），并带哦用InitializeAsync()即可。\n优劣性：由系统后台全自动完成所有设备的寻址、转换器的插入及管线的连线。开发极简，但丧失了部分底层魔改的自由度。\n4 WinRT MediaCapture应用（详细步骤） 微软官方WinRT FrameReader标准流程\n寻址（Enumerate）：在庞杂的系统设备列表中，拿到目标摄像头的唯一身份证（Device ID）。 配置与初始化（Initialize Settings）：向系统提交一份“需求列表”，决定接下来是用CPU还是GPU处理数据，是独占硬件还是与其他软件共享。 定位数据源（Find Source）：从摄像头提供的多个功能镜头中，精准剥离“彩色画面（Color）的数据流”。 建立阅读器（Create Reader）：为选定的摄像头建立一个专属的数据接收队列，并决定当程序处理不过来时该怎末办。 订阅事件（Subscribe Event）：挂载一个FrameArrived回调函数，告诉系统有新画面时通知程序。 开机拉流（Start）：正式命令摄像头开始录制。 拆解快递（Frame Arriver Callback）：获取最新帧，进行格式转换，并提取位C++能读写的裸内存。 关闭清理（Cleanup）：停止数据流，释放摄像头设备，销毁阅读器。 4.1 代码实现 4.1.1 寻址与线程环境配置 对应代码(getCameras与workerThreadFunc开头)：\nwinrt::init_apartment(winrt::apartment_type::multi_threaded); auto devices = DeviceInformation::FindAllAsync(DeviceClass::VideoCapture).get(); 其中：\ninit_apartment：这是进入WinRT的现代世界的“门票”。因为我们的代码跑在子线程里。必须要告诉Windows这个线程使用多线程公寓（MTA）模型处理底层COM对象。没有这个，所有的WinRT API直接崩溃。\nFindAllAsync：向系统要一份“全电脑摄像头的通讯录”。\n4.1.2 配置与初始化黑盒 先告诉 Windows 我想怎么使用摄像头，然后请求系统初始化这个摄像头。对应代码(workerThreadFunc循环内部)：\n//MediaCaptureInitializationSettings创建的是一个初始化配置对象是 WinRT 类型，提供属性读写方法，并非这个普通 C++ 结构体。 MediaCaptureInitializationSettings settings; //决定：到底打开电脑上的哪一个摄像头 settings.VideoDeviceId(deviceId.toStdWString()); //这里声明当前 MediaCapture 只需要视频采集，不需要音频采集。 settings.StreamingCaptureMode(StreamingCaptureMode::Video); settings.MemoryPreference(MediaCaptureMemoryPreference::Cpu); settings.SharingMode(MediaCaptureSharingMode::SharedReadOnly); m_mediaCapture = MediaCapture(); m_mediaCapture.InitializeAsync(settings).get(); 其中：\nMemoryPreference::CPU：告诉系统，我最终要把画面拿到系统内存(m_pixelBuffer)里自己处理，请系统帮我把底层数据搬运到CPU可读区域。\nSharingMode：这个是“三级降级策略”的核心。有的摄像头被别的软件占用了，只能用SharedReadOnly（共享只读）模式；有的高级工业相机必须用ExclusiveControl（独占）才能发挥性能。\nInitializeAsync：真正启动WinRT黑盒，此时底层MF开始自动组装管线。\n4.1.3 定位彩色源、建立阅读器并挂在铃铛 对应代码（WorkThreadFunc中部）：\n// 找彩色镜头 auto frameSources = m_mediaCapture.FrameSources(); MediaFrameSource bestSource = nullptr; for (auto const\u0026amp; kv : frameSources) { if (kv.Value().Info().SourceKind() == MediaFrameSourceKind::Color) { ... } } // 建立阅读器 m_frameReader = m_mediaCapture.CreateFrameReaderAsync(bestSource).get(); // 挂载门铃（订阅事件） m_frameReader.FrameArrived({ this, \u0026amp;CameraCapture::onFrameArrived }); 现在很多笔记本电脑有红外人脸解锁镜头。通过检查SourceKind::Color，可以精准避开那些黑白的红外镜头。\nFrameArrived就是挂门铃。是事件驱动机制，代码不需要死循环取查询摄像头有没有新画面，极大地释放了CPU。\n4.1.4 开机拉流 对应代码(workThreadFunc后部)：\nauto status = m_frameReader.StartAsync().get(); 黑盒管线全线贯通。执行完这句话后，摄像头的物理绿灯亮起，开始向系统狂奔发送数据。\n4.1.5 拆解快递 MediaFrameReader 获取视频帧 → SoftwareBitmap 转换像素格式 → 获取 CPU 内存指针 → memcpy 复制到自己的缓冲区。对应代码（onFrameArrived）：\n// 1. 尝试获取最新可用帧 auto frame = sender.TryAcquireLatestFrame(); // 获取 WinRT 提供的软件位图 bitmap auto bitmap = frame.VideoMediaFrame().SoftwareBitmap(); // 2. 格式翻译 //Pixel Format（像素格式）转换 //Alpha Mode（透明度表示方式）转换 if (bitmap.BitmapPixelFormat() != BitmapPixelFormat::Bgra8 || bitmap.BitmapAlphaMode() != BitmapAlphaMode::Premultiplied) { bitmap = SoftwareBitmap::Convert(bitmap, BitmapPixelFormat::Bgra8, BitmapAlphaMode::Premultiplied); } // 3. 从 WinRT 管理的 bitmap 中取得可供 C++ 代码直接读取的像素内存地址。 auto buffer = bitmap.LockBuffer(BitmapBufferAccessMode::Read); //创建对底层内存的引用 auto ref = buffer.CreateReference(); //查询原生字节访问接口 auto byteAccess = ref.as\u0026lt;IMemoryBufferByteAccess\u0026gt;(); //准备接收指针和容量是 uint8_t* data = nullptr; byteAccess-\u0026gt;GetBuffer(\u0026amp;data, \u0026amp;capacity); //这会获取第 0 个图像平面的描述信息 auto plane = buffer.GetPlaneDescription(0); // 4. 拷贝到自己的内存池（m伪代码，y代表行数) emcpy(m_pixelB(uffer.data() + y * w * 4, data + plane.StartIndex + y * plane.Stride, w * 4); 其中：\nsender 通常是 MediaFrameReader 的帧到达事件回调参数。 TryAcquireLatestFrame()：因为系统送快递(画面)极快，如果程序处理满了，直接丢弃旧的，永远只拿“最新”的一帧，保证直播延迟降低。 SoftwareBitmap 是 WinRT 提供的软件位图类型。获取可供 CPU 访问的像素数据，同时保存图像尺寸、像素格式和 Alpha 模式等信息的视频图像。 SoftwareBitmap()::Convert：这是WinRT黑盒最强大的魔法。不管底层摄像头吐出来的是YUV还是NV12这种压缩格式，系统底层都会自动翻译成显卡喜欢的(BGRA8)标准格式 IMemoryBufferByteAccess：这是现在C++ API打破次元壁，强行深入底层获取裸指针的COM调用接口。只有拿到这个裸指针才能把画面为给DirectX或者OpenCV。 plane包含的字段： plane.StartIndex：图像平面像素数据的起始字节偏移 plane.Width：图像平面的宽度 plane.Height：图像平面的高度 plane.Stride：图像邻两行起始地址之间的字节距离 5 音频捕获全流程 5.1 声音的产生 物理层：振动与连续的波 例如：声音的本质是空气的振动(比如说话、敲键盘)。麦克风的作用，就是一块能感受空气压力的振膜，随着声波来回震动，把机械能转换成连续的电压变化（模拟信号）。\n转换层：ADC（模数转换）与PCM数据 例如：计算机只懂 0 和 1 ，存不了连续的电压波浪。所以声卡芯片里有一个叫ADC(数模转换器)，他的工作就是“切西瓜“ 1.采样率：每一秒切多少刀。比如 44199HZ 或 48000HZ ，意味着一秒要测量4.8万次电压值。 2.位深：每一刀切下来的那块，用多精确的尺子去量，16-bit可以表示65536中音量级别，24-bit甚至32-bit float则极其细腻。\n总结：最终，那段连续的声波被切碎、量化，变成了一长串连绵不断的数字。这种最原始的、没经过任何MP3/AAC压缩的纯数字阵列。就叫做 PCM数据 。\n5.2 Windows音频框架 5.2.1 内核态混音 在早期的Windows系统中，音频处理高度依赖内核级驱动。\n底层运行机制：\n所有上层应用（无论是使用MME还是DirectSound API）发出的音频数据流，最终都会通过系统调用，穿透用户态，进入最高权限的内核态。内核层中，由一个名为KMixer.sys（Kernel Mode Audio Mixer）的系统级驱动程序统一负责所有音频流的重采样和混音工作，随后再传递给声卡硬件驱动。\n致命的架构缺陷(系统级单点故障)：\n由于KMixer.sys和第三方声卡的音效处理模块都在内核态运行，他们拥有操作系统的最高内存和硬件访问权限。这意味极差的容错率：一旦在繁重的多通道混音过程中发生内存越界，或者第三方声卡驱动出现微小的Bug，将直接引发整个操作系统的崩溃。\n5.2.2 用户态音频核心 为了彻底解决内核态处理带来的稳定性灾难，微软重写了整个音频栈，引入了Core Audio APIs。\n底层运行机制： 微软将庞大且危险的”混音“与”音效处理(APO)“工作，强制从内核态剥离，上移到用户态。这一工作交由一个独立的、受保护的系统级后台服务进程来统筹——即Windows Audio Engine（进程名：audiodg.exe，全程Audio Device Graph）。\n现代音频管线的标准化流程：\n会话隔离：每个发声的应用程序都被视为一个独立的音频会话。 软件混音：所有程序的音频流都先汇聚到audiodg.exe中，由CPU再用户态完成重采样和混合。 单一流输出：混合完成后的”纯净“数据流，才被打包成唯一的一条通道，安全地传递给底层的内核态声卡驱动(仅作单纯的数据透穿，不再做复杂计算)。 架构重构的决定性意义：\n极致的稳定性（故障隔离）：现在，如果某个音频处理模块崩溃，只会导致audiodg.exe这个用户态进程闪退(表现为电脑突然没声音了)，Windows服务会 自动重启 ，操作系统不会因此蓝屏。 细粒度的控制力：因为所有音频都在用户态的audiodg.exe中进行路由和混合，操作系统终于可以实现”为每个独立的应用程序单独调节音量“这种现代功能。 系统级内录的诞生：正是因为有了一个统一的用户态混音节点，WASAPI才能在数据最终送往内核声卡驱动之前，合法地”截取“这份已经混合好的桌面全局音频。 5.3 音频采集技术 5.3.1 MME(Waveform Audio) 在现代Windows中，MME API实际上已经没有原生底层支持。微软为了兼容老程序，在系统里放了一个”翻译官(wdmaud.drv)“。现在所调用的MME函数，会被翻译官拦截，转译成内核流(Kernel Streaming)，最后再绕回WASAPI去执行。中间隔了厚厚的好几层包装。导致采集音频延迟非常高。\n5.3.2 DirectSound 在XP时代，DirectSound的杀手锏是HAL绕过。它允许游戏程序绕过系统的KMixer，直接与支持硬件混音的高级声卡对话。由于是硬件直接处理，延迟极低，还能释放CPU算力。但随着系统架构重写，微软为了安全，彻底禁止了第三方程序直接调用声卡硬件进行音频处理。现在的DirectSound接口只是一个空壳。当调用DirectSound录音时，系统底层创建了一个WASAPI的实例。他就像套着DirectSound皮肤的WASAPI，不仅没有了硬件加速的优势，还要白白承担一层API转换的性能损耗。\n5.3.3 WASAPI 这是针对直播、录屏软件的终极武器。WASAPI是直接与Windows Audio Engine(audiodg.exe)对话的原生接口，中间没有任何翻译层。\n微软官方文档中，对于使用WASAPI开发“音频捕获客户端(Capture Client)”，明确规定了流水线：\n环境准备(COM Initialization)：初始化底层组件环境。 发现设备(Device Enumeration)：查找系统中的麦克风或扬声器。 激活音频客户端(Active IAudioClient)：与指定的硬件设备建立连接。 协商格式与初始化：获取设备的音频格式(采样率、位深)，并告诉操作系统怎么用它（独占还是共享？要不要录系统声音？） 获取捕获服务：拿到真正用来“抽水”的数据读取工具。 启动数据流(Start)：命令声卡开始录音。 捕获大循环(The Capture Loop)：在一个死循环中，不断检查有没有新数据。如果有，拿出来转换格式；拿完必须把缓冲区还给系统。 停止与清理(Stop \u0026amp; Cleanup)：停止流，释放所有资源。 详细WASAPI采集流程可以看这里《WASAPI 音频捕获入门：从工作模式到完整采集流程》\n6 纹理池 纹理是存储空间着色信息的连续函数或离散数据数组，通常被映射到集合物体的表面，以增加其视觉细节和真实感。\n6.1 纹理和图片的区别 1.普通图片(如：JPG、内存里)：\n住址：存在CPU控制的系统内存(RAM)里面。\n特点：CPU处理起来很方便，但如果让显卡(GPU)去画它，显卡就得跨过主板总线去拿数据，机器缓慢，画面绝对会卡成幻灯片。\n2.纹理(如代码里的ID3D11Texture2D)：\n住址：存在GPU控制的显存(VRAM)里面。\n特点：这是显卡的后院。显卡的流处理器读取纹理就像从口袋掏一块糖一样简单，能瞬间完成放大、缩小、加美颜滤镜等极速运算。\n总结：纹理，就是一张专门存在显卡显存内存里，供显卡高速使用的特殊图片。\n6.2 纹理池的作用 在高性能视频处理（如直播、录屏）中，直接连接采集和渲染会遇到三大痛点：\n1.帧率不匹配： 摄像头采集的可能是30fps，但显示器刷新率是144Hz。\n如果没有纹理池，渲染线程必须等待采集线程，或者采集线程会被渲染阻塞，导致掉帧。\n2.资源分配开销：\n在Direct3D11中，频繁创建（CreateTexture2D）和销毁纹理是非常昂贵的CPU/GPU操作。\n3.生命周期管理陷阱： 如果渲染器正在读取纹理，采集线程突然将其释放，会导致程序崩溃。\n6.3 纹理池架构设计 代码通过策略模式巧妙地解决了不同场景下的缓冲需求。\n6.3.1 交换链策略(SwapChainStrategy) 原理：只保留最新的“一帧”。\n适用场景：对实时性要求极高的情况(如游戏预览)，宁可丢弃旧帧，也要保证显示最新帧。\n源码解析：m_frontBuffer永远寸尺最后一次push进来的纹理。\n6.3.2 环形缓冲区策略 原理：维护一个固定容量的队列(类似于双端队列)。\n适用场景：视频录制或编码。当采集端瞬间产生多帧时，缓冲区可以起到”削峰填谷“的作用，防止数据丢失。\n重点：当超过m_capacity时，移除最旧的帧，保证内存不溢出。\n核心逻辑对比：\n7 渲染管线 渲染管线是一系列处理阶段的集合，将3D几何数据、纹理数据和着色器指令转换为2D光栅化像素并输出到显示器。它结合了固定功能硬件和可编程着色器阶段，以实现灵活且高效的实时渲染。\n输入装配阶段： 通俗解释：备料阶段。显卡不知道什么是矩形，他只认三角形和顶点。我们需要把顶点坐标数据喂给显卡。\n顶点着色器（Vertex Shader -VS）： 通俗解释：塑性阶段。显卡拿到顶点后，要计算这些点最终在屏幕上什么位置。\n光栅化阶段（Rasterizer - RS）: 通俗解释：切割阶段。顶点连成多边形后，光栅化器会计算出这个多边形覆盖了屏幕上哪些像素点。\n像素着色器（Pixel Shader -PS） 通俗解释：上色阶段。管线针对每一个被覆盖的像素点，执行一次上色程序。这需要并行计算。\n输出合并阶段（Output Merger -OM） 通俗解释：完工于装裱。把像素着色器算出来的颜色写道最终的画板上（或者和弧板上原有的颜色混合）。\n7.1 什么是Shader？ Shader是一段直接运行在GPU硬件上的微型、高度并发的计算程序。他是现代“可编程渲染管线(Programmable Rendering Pipeline)”的核心组件。\nVertex Shader\n阶段定位：渲染管线的第一个可编程阶段。他的输入是单一的顶点数据(Vertex Data)。\n核心职责（空间变换）：针对传入的每一个顶点独立执行几何变换。他通常利用矩阵乘法。将顶点的局部空间坐标转换到归一化设备空间坐标系下的裁剪空间。\nPixel Shader\n阶段定位：在经历了光栅化阶段（Rasterizer Stage，将集合图形切割成像素片段）之后执行。\n核心职责（像素计算）：接收经过线性插值处理后的片元(Fragement)属性，执行纹理采样、光照计算或色彩空间转换，最终输出该像素的RGBA颜色数据发送给输出合并阶段。\n8 图像旋转、缩放和色彩转换 8.1 图像旋转 旋转一副图像，本质上是旋转承载这些图像的相框，即4个顶点交给了顶点着色器VS_Main重写计算旋转后的顶点坐标。\nfloat centerX = x + w * 0.5; float centerY = y + h * 0.5; float localX = (input.pos.x - 0.5) * w; float localY = (input.pos.y - 0.5) * h; float rad = rotation * 0.0174532925f; // 角度转弧度 (PI/180) float c = cos(rad); float s = sin(rad); 重点：HLSL里的sin()和cos()函数是直接在GPU的硬件算数逻辑单元(ALU)中执行的，速度极快。\nfloat rotatedX = localX * c - localY * s; float rotatedY = localX * s + localY * c; 这是标准的2D旋转公式。在GPU中，这种乘加运算通常可以在一个时钟周期内完成。\nfloat finalPixelX = centerX + rotatedX; float ndcX = (finalPixelX / canvasW) * 2.0f - 1.0f; 屏幕的像素坐标（如1920x1080）显卡是不认的，显卡只认从-1.0到1.0的坐标空间。这段代码将实际像素转化成了显卡标准的NDC坐标输出给后续的管线。\n8.2 图像缩放 当一张1080p的图片被缩小到手机屏幕大小，或者放大到4K电视上，GPU是如何决定每个像素如何涂什么颜色的？这就是纹理采样。\n核心指令：tex.Sample在HLSL中\ntex.Sample(samLinear, input.tex) 其中：\ntex：是绑定到显存的原始图片。\ninput.tex：是当前这个像素点对应的UV坐标（范围从0到1）。\nsamLinear：是采样器状态。这是缩放魔法的核心。\n当图片放大时，UV坐标可能会落到两个真实的像素点中间。GPU会自动去抓取周围的4个真实像素，根据距离远近按比例混合出一种新的颜色。这一切都是显卡的**纹理映射单元（TMU）**在硬件层面瞬间完成，这也是Bilinear缩放极快的原因。\n为了让画面更锋锐（没有马赛克且边缘平滑），Bicubic算法不再只看周围4个点，而是要计算周围16个点的权重。\nfloat2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f)); // 复杂的权重多项式 这中算法虽然清晰，但大量的数学计算会消耗GPU算力，通常用于高质量的最终推流画面。\n8.3 色彩空间转换 这是直播和视频开发领域最值钱的知识点。摄像头和视频解码器为了省带宽，吐出的时NV12(YUV)格式；但显示器和DirectX渲染只能看懂RGB。\n数学上，RGB转YUV时一组线性方程组(矩阵乘法)。例如：\nY = 0.2126 * R + 0.7152*G+0.0722*B+Offset 在HLSL中，我们不需要写加法和乘法，直接使用图形学中最强大的内建函数-点成dot():\nfloat y = dot(rgb, yCoeff.rgb) + yCoeff.a; 其中：\nrgb是包含红绿蓝的3D向量float3.\nyCoeff.rgb是C++通过PS_COLOR_BUFFER传进来的转换系数。\ndot()指令会在底层硬件中一瞬间把对应位相乘并相加，效率很高。\n9 图像输出模块 9.1接口设计 WGC采集，但为了代码的可持续支持：OBS插件采集源、纯摄像头采集源、播放器拉流采集源。\n// ISource.h class ISource { public: virtual bool start() = 0; virtual void stop() = 0; // 返回的是 D3D11 的 SRV 视图，直接交给 GPU 渲染管线 virtual Microsoft::WRL::ComPtr\u0026lt;ID3D11ShaderResourceView\u0026gt; getTexture(ID3D11DeviceContext* ctx) = 0; }; 其中：\nMicrsoft::WRL::ComPtr ：是管理 COM（Component Object Model，组件对象模型） 接口指针的智能指针。\nID3D11Texture2D：是真正存储像素的资源，负责承载纹理数据。\nID3D11ShaderResourceView：这个SRV 相当于 Shader 访问纹理的入口，它是对已有 GPU 资源的一种访问视图，定义着色器能够以什么格式、从哪些子资源读取数据。\n这个ISource接口保证了，无论使用什么抓图，最终吐出来的都是标准版的ID3D11ShaderResourceVie（SRV）。渲染层（View层）拿到这个SRV直接丢给Shader就能画出来，完全不需要知道底层是WGC还是摄像头。这个就是高内聚、低耦合。\n总结：ISource 解决的是软件架构中的“如何统一访问不同图像源”，比如摄像头捕获的是NV12的图像源，WGC捕获的是BGRA的图像源。\n详细的捕获后渲染管线流程可以看这里：《从零理解实时视频渲染管线：纹理池、图像混合与合成输出》\n","permalink":"https://toyezi.cn/posts/driver-av-capture/","summary":"从 WGC 屏幕采集、WinRT 摄像头采集与 WASAPI 音频捕获，到纹理池、GPU 渲染和场景合成输出的学习记录。","title":"驱动级音视频采集与整合"},{"content":" 1. 先搞清楚：我们要做一件什么事 想象你在做一个直播工具：画面里要同时出现你的摄像头、你的电脑屏幕、以及一张背景图。这三样东西最终要\u0026quot;叠\u0026quot;成一张完整的画面，然后一方面显示给用户看，另一方面编码后推流或录制成文件。\n这整个过程，就是一条渲染管线（Rendering Pipeline）。\u0026ldquo;管线\u0026quot;这个词的意思是：数据像水一样，从一个环节流向下一个环节，环环相扣。\n首先要讲清楚三件事，正好对应管线里的三个关键环节：\n纹理池缓冲区：源和合成器之间如何\u0026quot;传递画面\u0026rdquo;，以及为什么需要一个缓冲。 图像混合：合成器怎么把多个画面叠成一张。 合成输出层：合成好的那一张画，如何变成用户能看到的预览、以及编码器能吃的格式。 在正式进入这三个环节之前，必须先补一些最基础的概念，否则后面会看不懂。\n2. 预备知识：帧、纹理、GPU 与智能指针 这一节是地基，每个概念我都会在第一次出现时用粗体标注并解释，后面不再重复。\n2.1 帧（Frame）与像素（Pixel） 帧（Frame）：视频本质上是一连串快速切换的静止图片。每秒钟切换 30 次，就是\u0026quot;30 帧每秒\u0026quot;，记作 30fps（Frames Per Second）。每一张静止图片，就叫一帧。\n像素（Pixel）：一帧画面是一个由很多小方块组成的网格，每个小方块就是一个像素。一张 1920×1080 的画面，就是横向 1920 个、纵向 1080 个像素，一共约 207 万个像素。\n每个像素要记录自己的颜色。最常用的一种记法是 RGBA：R（Red 红）、G（Green 绿）、B（Blue 蓝）、A（Alpha 透明度）。四个值各占 8 位（一个字节，取值 0~255）。\n2.2 CPU 与 GPU：两个不同的\u0026quot;工人\u0026quot; CPU（Central Processing Unit，中央处理器）：电脑的\u0026quot;大脑\u0026quot;，擅长处理复杂、有逻辑分支的任务，但一次能同时做的事不多（核数有限）。\nGPU（Graphics Processing Unit，图形处理器）：显卡上的\u0026quot;大力士\u0026quot;，核心数量极多（成百上千），擅长\u0026quot;同时做大量简单重复的计算\u0026quot;。给 207 万个像素做颜色变换，GPU 能一瞬间并行完成，而 CPU 一个个算会很慢。\n一条重要原则：画面相关的大量计算，尽量交给 GPU 并行做。本文后面你会反复看到\u0026quot;在 GPU 内完成转换\u0026quot;，原因就在这。\n2.3 纹理（Texture）：GPU 内存里的一帧画面 纹理（Texture）：GPU 的显存（显卡自带的内存）里，用来存\u0026quot;一帧画面数据\u0026quot;的一块内存。在 Direct3D 11 里，二维纹理的类型是 ID3D11Texture2D。你可以把纹理理解为\u0026quot;活在显卡里的一张图\u0026quot;。\n为什么要把画面放进 GPU？因为 GPU 要处理它（采样、混合、缩放），数据放在 GPU 自己的显存里，处理才最快。如果数据在 CPU 内存里，每次都要通过 PCIe 总线搬运，很慢。\n2.4 D3D11：程序用来指挥 GPU 的\u0026quot;遥控器\u0026quot; Direct3D 11（简称 D3D11）：微软提供的一套图形 API（Application Programming Interface，应用程序编程接口）。API 可以理解为\u0026quot;别人写好的、你可以调用的功能集合\u0026quot;。通过 D3D11，程序可以创建纹理、创建渲染目标、调用着色器、执行绘制等，从而指挥 GPU 干活。\nD3D11 里有三个特别重要的对象：\nDevice（设备），类型 ID3D11Device：负责\u0026quot;创建\u0026quot;各种资源（纹理、着色器、状态对象）。 Context（上下文），类型 ID3D11DeviceContext：负责\u0026quot;执行\u0026quot;绘制、复制、绑定等命令。可以理解为\u0026quot;下达命令的手\u0026quot;。 交换链（Swap Chain）：负责把画好的画面\u0026quot;呈现\u0026quot;到屏幕上。 2.5 视图（View）：同一块数据的\u0026quot;读写权限\u0026quot; 这是一个新手最容易困惑的点，请仔细看。\n视图（View） 是对 GPU 资源的\u0026quot;访问接口\u0026quot;。同一块纹理，可以有两种不同的\u0026quot;看\u0026quot;法：\nSRV（ShaderResourceView，着色器资源视图）：表示\u0026quot;这块纹理可以读\u0026quot;。着色器（见 2.7）采样纹理时，用的是 SRV。 RTV（RenderTargetView，渲染目标视图）：表示\u0026quot;这块纹理可以写\u0026quot;。GPU 把画面\u0026quot;画进去\u0026quot;时，用的是 RTV。 为什么要拆开？因为 GPU 有一条铁律：同一块纹理，不能同时一边写一边读（会产生数据冲突）。把读写分开成不同的\u0026quot;视图\u0026quot;，D3D11 就能明确知道你现在想干什么。\n2.6 ComPtr：会自动\u0026quot;回收\u0026quot;的智能指针 在 C++ 里，手动管理内存很容易出错（忘释放 → 内存泄漏；提前释放 → 崩溃）。D3D11 的对象都是 COM 对象（Component Object Model，微软的一种组件规范），它们靠\u0026quot;引用计数\u0026quot;来管理生命周期：\n每多一个人使用它，计数 +1； 每少一个人使用它，计数 -1； 计数归零时，对象自动销毁。 ComPtr（COM 智能指针） 是封装了这个计数逻辑的工具（Microsoft::WRL::ComPtr\u0026lt;T\u0026gt;）。你把它当成普通指针用，但它会在\u0026quot;不再被需要\u0026quot;时自动帮你做 -1 的操作，不用手动释放。本文代码里到处是 ComPtr\u0026lt;ID3D11Texture2D\u0026gt;，意思就是\u0026quot;一个会自动管理的纹理智能指针\u0026quot;。\n一句话总结：ComPtr 让你像用普通指针一样用 D3D11 对象，却不用操心\u0026quot;什么时候该释放\u0026quot;。\n2.7 着色器（Shader）：跑在 GPU 上的\u0026quot;小程序\u0026quot; 着色器（Shader）：一段运行在 GPU 上的小程序，用来告诉 GPU\u0026quot;每个顶点/每个像素该怎么算\u0026quot;。它用 HLSL（High-Level Shading Language，一种类似 C 的着色器语言）编写。\n本文涉及两种：\n顶点着色器（Vertex Shader）：处理\u0026quot;顶点\u0026quot;（图形的顶点位置），可以做旋转、缩放、坐标变换。 像素着色器（Pixel Shader，也叫片元着色器）：处理\u0026quot;像素\u0026quot;，决定每个像素最终显示什么颜色（比如采样纹理的颜色、做颜色空间转换）。 D3D11 里，程序先用 D3DCompile 把 HLSL 文本编译成 GPU 能执行的字节码，再用 Device 创建出着色器对象（ID3D11VertexShader / ID3D11PixelShader），绘制时绑定到 Context 上即可。\n2.8 线程、锁与生产者—消费者 线程（Thread）：程序里一条独立的\u0026quot;执行流\u0026quot;。一个程序可以有多个线程同时跑。比如一个线程专门采集画面，另一个线程专门合成画面。\n互斥锁（Mutex）：多个线程如果同时读写同一份数据，会\u0026quot;打架\u0026quot;（数据错乱甚至崩溃）。互斥锁是一把\u0026quot;门锁\u0026quot;：谁先拿到锁谁先进去操作，操作完出来再解锁，别人才能进去。C++ 里常用 std::mutex + std::lock_guard：\n// lock_guard 是\u0026#34;自动上锁/解锁\u0026#34;的辅助工具： // 它构造时自动 lock（上锁），离开作用域时自动 unlock（解锁）。 { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 上锁，别的线程在外面等 // ... 在这里安全地读写共享数据 ... } // 离开这个花括号，lock_guard 析构，自动解锁 生产者—消费者模型：一个经典的多线程协作场景。生产者负责\u0026quot;制造\u0026quot;数据（比如采集画面），消费者负责\u0026quot;消费\u0026quot;数据（比如合成画面）。两者速度往往不一样——生产者可能一秒产 60 帧，消费者可能一秒只能处理 30 帧——所以中间需要一块缓冲区来\u0026quot;暂存\u0026quot;数据，让双方不必互相等待。\n3. 纹理池缓冲区：生产者与消费者之间的\u0026quot;中转站\u0026quot; 3.1 为什么需要一个\u0026quot;池子\u0026quot;？ 回顾生产者—消费者模型。在本文的场景里：\n生产者：采集源（摄像头、屏幕、视频文件），它不断产出新帧。 消费者：合成器（下一节的主角 SceneCompositor），它按自己的节奏（比如 25fps）取帧去合成。 问题来了：如果生产者\u0026quot;推\u0026quot;一帧过来，消费者刚好没空接，这一帧就丢了；如果两边直接共享同一块内存又没加锁，就会数据错乱。所以需要一个专门的缓冲结构，负责：\n安全地接收生产者送来的帧； 安全地让消费者取走\u0026quot;最新的一帧\u0026quot;； 管理这些帧的生命周期（什么时候该释放显存）。 这个结构，就是 纹理池（TexturePool）。它本身是一块跨线程共享的内存区，用 std::mutex 严格保护。\n3.2 整体结构：一个\u0026quot;按 id 查找\u0026quot;的池子 TexturePool 是一个单例（Singleton）——全局只有一个实例，任何人通过 TexturePool::instance() 拿到它（见 2.9 提到的单例，instance() 是获取这个唯一实例的函数）。\n它内部用一张 std::map 来管理数据，键是每个源的唯一 id，值是一个\u0026quot;池节点\u0026quot;：\n对应真实代码（TexturePool.h）：\nclass TexturePool { public: // 获取全局唯一实例（单例） static TexturePool\u0026amp; instance(); // 生产者调用：把一帧纹理\u0026#34;推进\u0026#34;池子 void updateTexture(const std::string\u0026amp; sourceId, ID3D11Texture2D* texture, BufferStrategyType strategy = BufferStrategyType::SwapChain); // 消费者调用：从池子里\u0026#34;取走\u0026#34;最新一帧 Microsoft::WRL::ComPtr\u0026lt;ID3D11Texture2D\u0026gt; acquireTexture(const std::string\u0026amp; sourceId); // 生命周期管理：删除某个源、清理超时未使用的纹理 void removeSource(const std::string\u0026amp; sourceId); void cleanupUnused(long long currentTimeMs); private: // 池节点：每个源对应一个缓冲 + 一个\u0026#34;最后访问时间\u0026#34; struct PoolNode { std::shared_ptr\u0026lt;ITextureBufferStrategy\u0026gt; buffer; // 具体的缓冲策略（见 3.3） long long lastAccessTime = 0; // 最后访问时间戳，用于超时回收 BufferStrategyType strategyType; // 用哪种策略 }; std::map\u0026lt;std::string, PoolNode\u0026gt; m_pool; // 键值对：id -\u0026gt; 节点 std::mutex m_mutex; // 互斥锁，保护 m_pool 不被多线程同时改 }; 注释说明：\nstd::string sourceId：源的唯一标识字符串（如 \u0026quot;camera_0\u0026quot;），用来区分不同的源。 ID3D11Texture2D* texture：一个纹理指针（见 2.3），代表一帧画面。 long long：64 位整数，这里用来存时间戳（从某个起点到现在的毫秒数）。 BufferStrategyType：一个枚举，表示用哪种缓冲策略。 3.3 两种缓冲策略：单缓冲 vs 环形缓冲 不同源的特点不同，池子提供了两种策略，通过策略模式抽象出来。策略模式的意思是：把\u0026quot;可以替换的行为\u0026quot;抽成一个接口，让上层不用关心具体用的是哪种实现。\n接口 ITextureBufferStrategy（TexturePool.h）：\nclass ITextureBufferStrategy { public: virtual ~ITextureBufferStrategy() = default; virtual void push(ID3D11Texture2D* texture) = 0; // 存一帧 virtual Microsoft::WRL::ComPtr\u0026lt;ID3D11Texture2D\u0026gt; peek() = 0; // 取最新一帧 virtual void clear() = 0; // 清空 }; 注释说明：virtual ... = 0; 是 C++ 的\u0026quot;纯虚函数\u0026quot;语法，表示\u0026quot;这个类只定义接口，不实现，具体怎么做由子类决定\u0026quot;。peek() 这个词是\u0026quot;偷看一眼\u0026quot;的意思，指取出来看但通常不销毁原数据。\n策略一：SwapChainStrategy（单缓冲，只留最新一帧） class SwapChainStrategy : public ITextureBufferStrategy { public: void push(ID3D11Texture2D* texture) override { if (!texture) return; // 空指针直接返回，防止崩溃 std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 上锁 m_frontBuffer = texture; // 直接覆盖，只保留最新一帧 } ComPtr\u0026lt;ID3D11Texture2D\u0026gt; peek() override { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); return m_frontBuffer; // 返回最新一帧 } void clear() override { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); m_frontBuffer.Reset(); // 释放底层纹理 } private: std::mutex m_mutex; ComPtr\u0026lt;ID3D11Texture2D\u0026gt; m_frontBuffer; // 只存一个\u0026#34;前缓冲\u0026#34; }; 注释说明：\noverride：C++ 关键字，表示\u0026quot;这个函数重写了父类的虚函数\u0026quot;，编译器会帮你检查有没有写错。 m_frontBuffer = texture：因为 m_frontBuffer 是 ComPtr，赋值会自动增加引用计数（防止纹理被提前释放），旧的那帧则自动释放。 这种策略只留最新一帧，适合\u0026quot;画面不会过期、只要最新\u0026quot;的场景（比如静态图片）。 策略二：RingBufferStrategy（环形缓冲，留最近 N 帧） class RingBufferStrategy : public ITextureBufferStrategy { public: RingBufferStrategy() { m_capacity = AppConfig::instance()-\u0026gt;ringBufferSize(); // 从配置读取容量 if (m_capacity \u0026lt; 1) m_capacity = 3; // 兜底：至少 3 帧 } void push(ID3D11Texture2D* texture) override { if (!texture) return; std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); m_buffer.push_back(texture); // 加到队尾 if (m_buffer.size() \u0026gt; m_capacity) { // 超过容量 m_buffer.erase(m_buffer.begin()); // 删掉最旧的一帧 } } ComPtr\u0026lt;ID3D11Texture2D\u0026gt; peek() override { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); if (m_buffer.empty()) return nullptr; // 空则返回空指针 return m_buffer.back(); // 返回最新（队尾）的一帧 } void clear() override { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); m_buffer.clear(); // 清空所有帧 } private: std::mutex m_mutex; std::vector\u0026lt;ComPtr\u0026lt;ID3D11Texture2D\u0026gt;\u0026gt; m_buffer; // 存放多帧的动态数组 size_t m_capacity; // 最大容量 }; 注释说明：\nm_buffer.push_back(texture)：把新帧加到 vector 末尾。 m_buffer.erase(m_buffer.begin())：begin() 指向第一个元素（最旧的），erase 删除它。 m_buffer.back()：取最后一个元素（最新的）。 这种策略保留最近 N 帧，适合\u0026quot;帧率可能不稳、需要一点缓冲余量\u0026quot;的动态源（比如摄像头、视频）。 3.4 池子的核心操作：存入、取出、回收 存入 updateTexture void TexturePool::updateTexture(const std::string\u0026amp; sourceId, ID3D11Texture2D* texture, BufferStrategyType strategy) { if (sourceId.empty() || !texture) return; // 参数校验，无效直接返回 std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 全程上锁，保护 m_pool long long now = QDateTime::currentMSecsSinceEpoch(); // 当前时间戳（毫秒） auto it = m_pool.find(sourceId); // 在 map 里查找这个 id if (it == m_pool.end()) { // end() 表示\u0026#34;没找到\u0026#34; // 第一次见到这个源：新建一个节点 PoolNode node; node.lastAccessTime = now; node.strategyType = strategy; if (strategy == BufferStrategyType::SwapChain) { node.buffer = std::make_shared\u0026lt;SwapChainStrategy\u0026gt;(); // 单缓冲 } else { node.buffer = std::make_shared\u0026lt;RingBufferStrategy\u0026gt;(); // 环形缓冲 } m_pool[sourceId] = node; // 把节点存进 map it = m_pool.find(sourceId); } if (it != m_pool.end() \u0026amp;\u0026amp; it-\u0026gt;second.buffer) { it-\u0026gt;second.lastAccessTime = now; // 更新最后访问时间 it-\u0026gt;second.buffer-\u0026gt;push(texture); // 真正把帧推进去 } } 注释说明：\nm_pool.find(sourceId)：在 map 里查找键 sourceId，返回一个\u0026quot;迭代器\u0026quot; it（可以理解为\u0026quot;指向某个元素的游标\u0026quot;）；找不到时返回 m_pool.end()（一个表示\u0026quot;末尾/不存在\u0026quot;的特殊位置）。 it-\u0026gt;second：迭代器指向的是\u0026quot;键值对\u0026quot;，.first 是键（id），.second 是值（PoolNode）。 std::make_shared\u0026lt;T\u0026gt;()：创建一个 shared_ptr 智能指针管理的对象。 取出 acquireTexture ComPtr\u0026lt;ID3D11Texture2D\u0026gt; TexturePool::acquireTexture(const std::string\u0026amp; sourceId) { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); auto it = m_pool.find(sourceId); if (it != m_pool.end() \u0026amp;\u0026amp; it-\u0026gt;second.buffer) { it-\u0026gt;second.lastAccessTime = QDateTime::currentMSecsSinceEpoch(); // 更新访问时间 return it-\u0026gt;second.buffer-\u0026gt;peek(); // 取最新一帧（安全拷贝） } return nullptr; // 没找到，返回空指针 } 注释说明：注意它返回的是 ComPtr（不是裸指针），这很关键——返回时引用计数 +1，保证调用者拿着它使用的期间，纹理不会被别人释放。\n超时回收 cleanupUnused void TexturePool::cleanupUnused(int64_t currentTimeMs) { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 遍历 map，边遍历边删除（注意 it 的推进方式） for (auto it = m_pool.begin(); it != m_pool.end(); ) { if (currentTimeMs - it-\u0026gt;second.lastAccessTime \u0026gt; 5000) { // 超过 5 秒没被访问 it-\u0026gt;second.buffer.reset(); // 释放底层 D3D 资源（防止显存泄漏） it = m_pool.erase(it); // 从 map 中移除，并让 it 指向下一个 } else { ++it; // 没超时，正常往后走 } } } 注释说明：\ncurrentTimeMs - lastAccessTime \u0026gt; 5000：当前时间减去最后访问时间，超过 5000 毫秒（5 秒）就认为是\u0026quot;没人用的旧纹理\u0026quot;。 buffer.reset()：清空智能指针，触发底层纹理释放。 为什么要这个？因为如果某个源被删掉了，池子里还留着它的显存不释放，时间长了显存会被吃光（显存泄漏）。 小结（一句话比喻）：纹理池就像快递驿站——采集源是快递员，把\u0026quot;包裹\u0026quot;（纹理）按编号（sourceId）放进货架（map），合成器是取件人，按编号取走最新那个包裹；久无人取的包裹，驿站会在 5 秒后自动清掉腾地方。\n4. 图像混合：把多个图层拼成一张画 4.1 主角：合成器 SceneCompositor SceneCompositor（场景合成器） 是消费者，也是整条管线的\u0026quot;大脑\u0026quot;。它在一个独立的高优先级渲染线程里跑一个死循环（renderLoop），按设定帧率（比如 25fps 或 30fps）不停地把当前所有源\u0026quot;合成\u0026quot;成一张画面。\n先看它持有的关键成员（SceneCompositor.h）：\nclass SceneCompositor : public QObject { // QObject 是 Qt 框架的基类，提供\u0026#34;信号/槽\u0026#34;机制（见 5.4） // ...只看关键部分 struct RenderNode { // \u0026#34;渲染节点\u0026#34;：一个源在画布上的摆放信息 std::string sourceId; // 对应哪个源 float x, y, w, h; // 位置 (x,y) 和尺寸 (w,h) float rotation; // 旋转角度 int zOrder; // 层级（谁盖在谁上面） float opacity; // 透明度 }; std::vector\u0026lt;RenderNode\u0026gt; m_renderList; // 渲染列表：一份\u0026#34;图层树\u0026#34;的快照 std::mutex m_renderMutex; // 专门保护 m_renderList 的锁 // 离屏画布：GPU 里一张专门用来\u0026#34;画\u0026#34;的纹理，以及它的读/写接口 Microsoft::WRL::ComPtr\u0026lt;ID3D11RenderTargetView\u0026gt; m_mainRTV; // 写接口（画布） Microsoft::WRL::ComPtr\u0026lt;ID3D11ShaderResourceView\u0026gt; m_mainSRV; // 读接口 Microsoft::WRL::ComPtr\u0026lt;ID3D11Texture2D\u0026gt; m_mainTex; // 画布本体 // ... }; 注释说明：\nfloat：单精度浮点数（小数），用来存坐标、缩放等可能带小数的值。 struct：C++ 里定义一个\u0026quot;把多个相关变量打包在一起\u0026quot;的结构体。RenderNode 就打包了一个源的全部摆放信息。 离屏画布（Offscreen Canvas）：不在屏幕上直接显示，而是在 GPU 显存里开辟的一张\u0026quot;草稿纸\u0026quot;纹理。所有源都画到这张草稿纸上，画完再整体输出。 4.2 第一步：锁定图层列表（syncRenderNodes） UI 线程可以随时增删源、改位置，而渲染线程要读这些信息。如果两边同时读写同一份数据就会崩溃。解决办法：在锁的保护下，把图层信息快照一份，渲染线程只操作这份快照。\nvoid SceneCompositor::syncRenderNodes() { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_renderMutex); // 上锁，保护快照过程 m_renderList.clear(); // 清空旧的快照 auto items = m_scene-\u0026gt;getItems(); // 拿到场景里所有\u0026#34;图层项\u0026#34; for (const auto\u0026amp; item : items) { // 遍历每个图层项 auto source = item-\u0026gt;getSource(); // 拿到这个图层背后的源 if (source) { // ... 如果是动态源且有新帧，就把新帧推进纹理池 if (source-\u0026gt;hasNewFrame()) { // hasNewFrame()：判断是否有新帧 TexturePool::instance().updateTexture(source-\u0026gt;getSourceId(), tex.Get(), strategy); source-\u0026gt;markFrameProcessed(); // 标记\u0026#34;这帧我已处理过\u0026#34; } // 构造渲染节点，记录这个源的摆放信息 RenderNode node; node.sourceId = source-\u0026gt;getSourceId(); Transform t = item-\u0026gt;getTransform(); // Transform：位置/缩放/旋转的集合 node.x = t.x; node.y = t.y; node.w = t.width; node.h = t.height; node.rotation = t.rotation; node.zOrder = t.zOrder; node.opacity = 1.0f; m_renderList.push_back(node); // 加入快照列表 } } // 按层级 zOrder 从小到大排序（小的先画，大的后画，后画的盖在上面） std::sort(m_renderList.begin(), m_renderList.end(), [](const RenderNode\u0026amp; a, const RenderNode\u0026amp; b){ return a.zOrder \u0026lt; b.zOrder; }); } 注释说明：\nfor (const auto\u0026amp; item : items)：C++ 的\u0026quot;范围 for 循环\u0026quot;，逐个取出容器里的元素。auto 让编译器自动推导类型。 [](...){...}：Lambda 表达式（匿名函数），这里作为 std::sort 的\u0026quot;比较规则\u0026quot;：告诉 sort\u0026quot;按 zOrder 从小到大排\u0026quot;。 zOrder（层级顺序）：数值越大越靠上（后画）。排序后按顺序画，就能实现\u0026quot;背景在下、前景在上\u0026quot;的正确叠放。 这就是参考文档里说的\u0026quot;锁住 m_renderMutex，把图层树快照一份，确保 UI 怎么变都不会引起崩溃\u0026quot;。\n4.3 第二步：执行混合（renderToTexture） 这一步是核心。它会清空离屏画布，然后逐个源把纹理\u0026quot;贴\u0026quot;到画布上。\nvoid SceneCompositor::renderToTexture() { auto\u0026amp; dx11 = DX11Context::instance(); // 拿到全局唯一的 D3D11 上下文（见 4.5） float clearColor[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; // 清屏颜色：黑色（R=0,G=0,B=0,A=1） dx11.beginScene(m_mainRTV.Get(), clearColor); // ① 清空画布为黑色，并设为当前渲染目标 std::vector\u0026lt;RenderNode\u0026gt; localList; { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_renderMutex); localList = m_renderList; // ② 在锁内再快照一份，避免绘制中列表被改 } // ③ 计算\u0026#34;逻辑坐标 -\u0026gt; 物理坐标\u0026#34;的缩放系数 // 逻辑画布是场景的原始尺寸（如 1920×1080）， // 但为了性能，编辑模式下物理画布可能缩到 1280×720。 float scaleX = (float)m_canvasW / m_logicalW; float scaleY = (float)m_canvasH / m_logicalH; // ④ 遍历每个节点，逐个贴图 for (const auto\u0026amp; node : localList) { // 从纹理池取这个源的最新一帧 Microsoft::WRL::ComPtr\u0026lt;ID3D11Texture2D\u0026gt; tex = TexturePool::instance().acquireTexture(node.sourceId); if (tex) { // 拿到纹理的\u0026#34;描述\u0026#34;（宽高、格式等），宽高为 0 就跳过 D3D11_TEXTURE2D_DESC desc; tex-\u0026gt;GetDesc(\u0026amp;desc); if (desc.Width == 0 || desc.Height == 0) continue; // 创建临时 SRV（读接口），让着色器能采样这张纹理 // ...（略去创建代码）... Microsoft::WRL::ComPtr\u0026lt;ID3D11ShaderResourceView\u0026gt; tempSRV; // ... // ⑤ 逻辑坐标换算成物理坐标（乘缩放系数） float renderX = node.x * scaleX; float renderY = node.y * scaleY; float renderW = node.w * scaleX; float renderH = node.h * scaleY; // ⑥ 调用 dx11.drawTexture 把这张纹理\u0026#34;贴\u0026#34;到画布上 dx11.drawTexture(tempSRV.Get(), // 要贴的纹理（读接口） renderX, renderY, // 贴到画布的哪个位置 renderW, renderH, // 贴多大 node.rotation, // 旋转角度 (float)m_canvasW, (float)m_canvasH); // 画布物理尺寸 } } // ⑦ 解绑渲染目标（收尾，避免残留状态影响下一帧） ID3D11RenderTargetView* nullRTV[] = { nullptr }; dx11.context()-\u0026gt;OMSetRenderTargets(1, nullRTV, nullptr); } 注释说明：\ncontinue：跳过本轮循环剩余部分，直接进入下一次循环。 OMSetRenderTargets：D3D11 的一个方法，设置\u0026quot;当前把画面渲染到哪个目标上\u0026quot;。传 nullptr 表示\u0026quot;解绑，不再渲染到任何目标\u0026quot;。 这里体现了逻辑坐标与物理坐标的分离：场景里记录的坐标是\u0026quot;逻辑尺寸\u0026quot;（跟真实场景一致），实际渲染时按缩放系数映射到\u0026quot;物理画布\u0026quot;上。好处是：分辨率降级时不用改任何布局数据。 逻辑坐标乘以缩放系数（0.667 = 1280/1920），映射到物理画布。\n4.4 逐层贴图如何\u0026quot;叠加\u0026quot;：Alpha 混合 多个源叠在一起，上面的图如果完全不透明，就会把下面的完全盖住；如果半透明，就能透出下面的内容。这就是 Alpha 混合（Alpha Blending）——用像素的 Alpha（透明度）值，计算上下两层像素如何合成。\nD3D11 里通过 混合状态（BlendState） 来配置这个规则。看 DX11Context 里的初始化代码（Core/DX11Context.cpp）：\nD3D11_BLEND_DESC bd = {}; // 混合状态描述结构体 bd.RenderTarget[0].BlendEnable = TRUE; // 开启混合 bd.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA; // 源因子 = 源像素的 Alpha bd.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA; // 目标因子 = 1 - 源 Alpha bd.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; // 混合运算 = 相加 // ... m_device-\u0026gt;CreateBlendState(\u0026amp;bd, \u0026amp;m_blendState); // 创建这个混合状态对象 注释说明：这段配置的含义是——最终颜色 = 源颜色 × 源Alpha + 目标颜色 × (1 − 源Alpha)。当源 Alpha = 1（不透明），结果就是源颜色本身（完全盖住）；当 Alpha = 0.5，就是上下各一半（半透）。\nAlpha 混合。A 越接近 1，上层越\u0026quot;实\u0026quot;；越接近 0，越\u0026quot;透\u0026quot;。\n4.5 谁来真正执行绘制：DX11Context 与着色器 drawTexture 是真正\u0026quot;把一张纹理画到画布上\u0026quot;的函数。它内部调用 renderQuad（画一个矩形），并且会用到顶点着色器和像素着色器。\n先看调用入口（DX11Context.cpp）：\nvoid DX11Context::drawTexture(ID3D11ShaderResourceView* srv, float x, float y, float w, float h, float rotation, float canvasW, float canvasH) { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // 上锁，防止与别的 GPU 操作冲突 if (!srv) return; m_context-\u0026gt;PSSetShaderResources(0, 1, \u0026amp;srv); // 把纹理的\u0026#34;读接口\u0026#34;交给像素着色器 m_context-\u0026gt;PSSetSamplers(0, 1, m_samplerLinear.GetAddressOf()); // 设置采样器（双线性） m_context-\u0026gt;PSSetShader(m_ps.Get(), nullptr, 0); // 绑定像素着色器（PS_Main） renderQuad(x, y, w, h, rotation, canvasW, canvasH); // 画一个矩形（顶点着色器干活） // 用完后解绑，防止影响后续 ID3D11ShaderResourceView* nullSRV[] = { nullptr }; m_context-\u0026gt;PSSetShaderResources(0, 1, nullSRV); } 注释说明：\nPSSetShaderResources / PSSetSamplers / PSSetShader：名字里的 PS 是 Pixel Shader（像素着色器）。这些函数就是\u0026quot;把资源、采样器、着色器程序绑定到 GPU 管线上\u0026quot;。 采样器（Sampler）：决定\u0026quot;怎么从纹理里取颜色\u0026quot;（比如放大时用最近邻还是双线性插值）。这里用 m_samplerLinear（双线性，放大时更平滑）。 再来看着色器本体。这是 DX11Context.cpp 里一段 HLSL 代码（节选），顶点着色器负责\u0026quot;旋转 + 坐标变换\u0026quot;：\n// VS_Main：顶点着色器。输入一个顶点，输出它旋转后的位置。 PS_INPUT VS_Main(VS_INPUT input) { PS_INPUT output; // 1. 计算矩形中心点 float centerX = x + w * 0.5; float centerY = y + h * 0.5; // 2. 计算当前顶点相对中心的偏移 float localX = (input.pos.x - 0.5) * w; float localY = (input.pos.y - 0.5) * h; // 3. 角度转弧度（degrees * PI / 180） float rad = rotation * 0.0174532925f; float c = cos(rad); float s = sin(rad); // 4. 2D 旋转公式：绕中心旋转 float rotatedX = localX * c - localY * s; float rotatedY = localX * s + localY * c; // 5. 移回绝对坐标 float finalPixelX = centerX + rotatedX; float finalPixelY = centerY + rotatedY; // 6. 转成 NDC 坐标（-1 ~ 1，D3D 的标准坐标范围） float ndcX = (finalPixelX / canvasW) * 2.0f - 1.0f; float ndcY = 1.0f - (finalPixelY / canvasH) * 2.0f; output.pos = float4(ndcX, ndcY, 0.0f, 1.0f); output.tex = input.tex; // 纹理坐标原样传给像素着色器 return output; } 注释说明：\ninput.pos / input.tex：输入结构体 VS_INPUT 里定义的两个字段，分别是顶点位置和纹理坐标。 cos / sin：余弦/正弦，HLSL 内置的三角函数。 float4：HLSL 里\u0026quot;4 个 float\u0026quot;的向量类型（类似 (x, y, z, w)）。 NDC（Normalized Device Coordinates，归一化设备坐标）：D3D 规定屏幕坐标范围是 -1 到 1（x 和 y）。所以最后要把像素坐标换算到这个范围。 而像素着色器（PS_Main）就简单多了——它就是\u0026quot;从纹理采样一个颜色输出\u0026quot;：\nTexture2D tex : register(t0); // 声明：外部绑定的纹理 SamplerState samLinear : register(s0); // 声明：外部绑定的采样器 // PS_Main：像素着色器。输入插值后的纹理坐标，输出这个像素的颜色。 float4 PS_Main(PS_INPUT input) : SV_Target { return tex.Sample(samLinear, input.tex); // 采样纹理，得到该点的颜色 } 注释说明：\nTexture2D tex : register(t0)：声明一个纹理变量 tex，对应\u0026quot;寄存器槽位 t0\u0026quot;（前面 PSSetShaderResources(0, 1, \u0026amp;srv) 绑定的就是槽位 0）。 tex.Sample(sampler, uv)：采样函数，用采样器在纹理的 uv 坐标处取颜色。 : SV_Target：HLSL 语义，表示\u0026quot;这个函数的返回值就是这个像素最终的颜色\u0026quot;。 小结（一句话比喻）：图像混合就像在玻璃上贴贴纸——先铺一张黑底玻璃（清屏），然后按层级从下到上，把每张贴纸（纹理）按指定位置、大小、角度贴上去（drawTexture + 旋转着色器），半透明的贴纸能透出下面那层（Alpha 混合）。\n5. 合成输出层：一张画，两条路 当 renderToTexture() 结束，离屏画布 m_mainTex 上已经有了一张完整的 RGB 画面。接下来要\u0026quot;兵分两路\u0026quot;输出：\n合成输出层的两路分支。左路给人看，右路给编码器。\n两路的入口都在渲染线程的主循环 renderLoop 里（SceneCompositor.cpp）：\nvoid SceneCompositor::renderLoop() { // ...（前面是初始化、垃圾回收、同步图层，略）... renderToTexture(); // 先合成出 RGB 画面 if (m_currentMode == CompositorMode::LiveRunning) { // 只有\u0026#34;直播模式\u0026#34;才走编码 convertToYUVAndDownload(); // ② 编码输出（转 NV12 并下载） } downloadPreview(); // ① 预览输出（转 QImage 并下载） TexturePool::instance().cleanupUnused(QDateTime::currentMSecsSinceEpoch()); // 顺手清理纹理池 } 注释说明：\nm_currentMode == CompositorMode::LiveRunning：CompositorMode 是一个枚举（一组命名的整数常量），有 Editor（编辑）和 LiveRunning（直播）两个值。用 == 判断当前是否处于直播模式。 为什么编辑模式跳过编码？因为编码（转 YUV、下载）很耗 CPU/GPU，编辑时没人要看编码结果，跳过能省资源。 5.1 第一路：UI 预览（downloadPreview） 预览就是把 GPU 里的画面搬到 CPU，转成 Qt 能显示的图片。关键点有两个：GPU→CPU 下载 和 限频节流。\nvoid SceneCompositor::downloadPreview() { // ① 暴力节流：距离上次发送不足 33ms，直接丢弃本帧 if (!s_previewLimiter.isValid()) { s_previewLimiter.start(); // 首次调用：启动计时器 } if (s_previewLimiter.elapsed() \u0026lt; 33) { // elapsed() 返回距上次过了多少毫秒 return; // 不到 33ms，直接丢弃，减轻 UI 压力 } s_previewLimiter.restart(); // 重置计时器，准备下一帧 if (!m_previewStagingTex || !m_mainTex) return; // 资源为空则返回 // ② 把画布复制到 Staging（暂存）纹理 DX11Context::instance().copyResource(m_previewStagingTex.Get(), m_mainTex.Get()); // ③ mapRead：把 Staging 纹理映射到 CPU 可读的内存，回调里读数据 DX11Context::instance().mapRead(m_previewStagingTex.Get(), [this](const D3D11_MAPPED_SUBRESOURCE\u0026amp; map){ if (!map.pData) return; // 映射失败则返回 // 用这块 CPU 内存构造一张 QImage（Qt 的图片类型） QImage img((const uchar*)map.pData, m_canvasW, m_canvasH, map.RowPitch, QImage::Format_ARGB32); QImage deepCopy = img.copy(); // 深拷贝一份（因为 map 很快会被释放） emit frameReady(deepCopy); // 通过信号发给界面去显示（见 5.4） }); } 注释说明：\nStaging（暂存）纹理：一种特殊用途的纹理，允许 CPU 直接读写。GPU 里普通的纹理（D3D11_USAGE_DEFAULT）CPU 读不了，所以要先把数据\u0026quot;复制\u0026quot;到 Staging 纹理，再映射到 CPU 内存。 Map / mapRead：D3D11 的 Map 操作，把 GPU 纹理的某块内存\u0026quot;映射\u0026quot;成 CPU 能直接访问的指针。mapRead 是项目里封装好的线程安全版本。 map.RowPitch：行距，即纹理每一行在内存里占用的字节数（可能比 宽×每像素字节 大，因为有对齐填充）。构造 QImage 时传它，是为了正确处理这种对齐。 为什么节流？ 渲染线程可能跑 60fps，但 UI 刷新 30fps 就够流畅了。如果每一帧都发信号给 UI 线程，UI 线程会被频繁打断卡死。所以用 s_previewLimiter（一个计时器）强制\u0026quot;至少间隔 33ms 才发一次\u0026quot;（33ms ≈ 30fps），这就是限频节流（Throttle）。\n5.2 第二路：编码输出（convertToYUVAndDownload） 编码器（比如 FFmpeg、x264、NVENC）不吃 RGB，它们喜欢 YUV 格式，尤其是 NV12。所以这一路要先把 RGB 转成 NV12，再下载给编码模块。\nvoid SceneCompositor::convertToYUVAndDownload() { if (!m_mainSRV || !m_nv12Texture || !m_rtvY || !m_rtvUV) return; // ① 在 GPU 内，把 RGB 画布转成 NV12 纹理 DX11Context::instance().convertRGBtoNV12( m_mainSRV.Get(), // 输入：RGB 画布的读接口 m_rtvY.Get(), // 输出：Y 通道的写接口 m_rtvUV.Get(), // 输出：UV 通道的写接口 desc.Width, desc.Height, conf.colorSpace, conf.colorRange, conf.downscaleFilter); // 色彩配置 // ② 把 NV12 下载到 CPU，再交给回调 downloadNV12ToCallback(); } 注释说明：这里最关键的思路是——格式转换全程在 GPU 内完成（用着色器），而不是把 RGB 下载到 CPU 再用 CPU 慢慢转。GPU 并行做，快得多。\n5.3 什么是 YUV / NV12 先理解概念，再看转换代码。\nYUV：一种颜色表示法，和 RGB 不同，它把颜色拆成\u0026quot;亮度 + 色度\u0026quot;：\nY（亮度 Luma）：像素有多亮。 U、V（色度 Chroma）：像素偏什么颜色。 为什么编码器喜欢 YUV？因为人眼对亮度敏感、对颜色不敏感，所以可以压缩色度（让 U、V 的分辨率只有 Y 的一半甚至四分之一），几乎不影响观感，却能大幅减小数据量。这正是视频压缩的基础。\nNV12：YUV 的一种常见\u0026quot;打包\u0026quot;方式：先存完所有像素的 Y，再交错存 U、V（每 2×2 个像素共享一对 UV）。\n5.4 转码着色器：convertRGBtoNV12 DX11Context::convertRGBtoNV12 会做两件事：先用\u0026quot;RGB→Y 的着色器\u0026quot;把 Y 通道画到 m_rtvY，再用\u0026quot;RGB→UV 的着色器\u0026quot;把 UV 通道画到 m_rtvUV（见 Core/DX11Context.cpp，这里节选关键逻辑）：\nvoid DX11Context::convertRGBtoNV12(/* ...参数略... */) { std::lock_guard\u0026lt;std::mutex\u0026gt; lock(m_mutex); // ① 先解绑所有 RTV，防止\u0026#34;读写的纹理是同一块\u0026#34;导致冲突（读写冲突会黑屏） ID3D11RenderTargetView* nullRTV[] = { nullptr }; m_context-\u0026gt;OMSetRenderTargets(1, nullRTV, nullptr); // ② 更新颜色转换矩阵（决定用 BT.601 还是 BT.709，全范围还是限范围） updateColorMatrix(space, range, desc.Width, desc.Height); m_context-\u0026gt;PSSetConstantBuffers(1, 1, m_colorBuffer.GetAddressOf()); // ③ 关闭 Alpha 混合（格式转换不需要半透明混合） m_context-\u0026gt;OMSetBlendState(m_blendStateDisabled.Get(), nullptr, 0xFFFFFFFF); // ④ 绑定输入 RGB 纹理 m_context-\u0026gt;PSSetShaderResources(0, 1, \u0026amp;inputTex); // ⑤ 画 Y 通道：用 PS_RGB_to_Y_Bilinear 着色器，渲染到 Y 的 RTV m_context-\u0026gt;OMSetRenderTargets(1, \u0026amp;targetY, nullptr); m_context-\u0026gt;PSSetShader(psY, nullptr, 0); renderQuad(0, 0, (float)targetW, (float)targetH, 0.0f, ...); // ⑥ 画 UV 通道：用 PS_RGB_to_UV_Bilinear 着色器，渲染到 UV 的 RTV（尺寸减半） m_context-\u0026gt;OMSetRenderTargets(1, \u0026amp;targetUV, nullptr); m_context-\u0026gt;PSSetShader(psUV, nullptr, 0); renderQuad(0, 0, (float)targetW/2, (float)targetH/2, 0.0f, ...); // ⑦ 恢复状态、解绑，避免影响后续 // ... } 而\u0026quot;RGB→Y\u0026quot;着色器的核心就是一行点积运算（Core/DX11Context.cpp 的 HLSL 段）：\n// PS_RGB_to_Y_Bilinear：把采样到的 RGB 颜色转成亮度 Y float4 PS_RGB_to_Y_Bilinear(PS_INPUT input) : SV_Target { float3 rgb = tex.Sample(samLinear, input.tex).rgb; // 采样得到 R,G,B float y = dot(rgb, yCoeff.rgb) + yCoeff.a; // Y = 系数·RGB + 偏移 return float4(y, 0, 0, 1); } 注释说明：\ndot(a, b)：HLSL 的\u0026quot;点积\u0026quot;函数，即对应分量相乘再求和：a.x*b.x + a.y*b.y + a.z*b.z。 yCoeff：一个向量，里面存的是\u0026quot;RGB 各分量对亮度的贡献系数\u0026quot;（比如 BT.709 下大约是 0.2126、0.7152、0.0722）以及一个偏移量。这些系数在 updateColorMatrix 里根据色彩标准算好，写进常量缓冲（Constant Buffer）。 float3：3 个 float 的向量，.rgb 是取前三个分量（R、G、B）的写法。 5.5 信号与回调：把结果\u0026quot;送\u0026quot;出去 下载到 CPU 后，数据怎么交给别人？项目用了两种机制：\n（1）Qt 信号/槽（Signal/Slot）：SceneCompositor 继承自 QObject，可以\u0026quot;发信号\u0026quot;。发出后，任何\u0026quot;连接\u0026quot;了该信号的对象会自动收到通知。\n// 声明在 SceneCompositor.h 里 signals: void frameReady(const QImage\u0026amp; image); // \u0026#34;有一帧预览画面好了\u0026#34; void yuvFrameReady(const std::vector\u0026lt;uint8_t\u0026gt;\u0026amp; data, int width, int height, int64_t pts); // \u0026#34;有一帧 YUV 数据好了\u0026#34; （2）回调函数（Callback）：std::function存一个函数，供外部注入。\n// 类型定义：一个函数，参数是 (数据指针, 宽, 高, 时间戳) using VideoFrameCallback = std::function\u0026lt;void(const uint8_t* data, int width, int height, int64_t pts)\u0026gt;; void setVideoOutputCallback(VideoFrameCallback callback); // 外部注册回调 下载 NV12 后，两种方式都会触发（downloadNV12ToCallback 节选）：\n// 下载后： emit yuvFrameReady(buffer, w, h, currentPts / 1000); // 发信号给 Qt 侧 if (m_outputCallback) m_outputCallback(buffer.data(), w, h, currentPts); // 调用外部回调 注释说明：\nuint8_t：8 位无符号整数（字节）。YUV 数据就是一堆字节。 int64_t pts：pts（Presentation Time Stamp，显示时间戳），标记这一帧该在什么时间点显示，用于音视频同步。 两条路线的区别：信号走 Qt 的跨线程机制，适合发给界面；回调更直接，适合推流/录制模块（它们不是 Qt 对象，用回调更通用）。 小结（一句话比喻）：合成输出层就像一张画完成后，要做两份拷贝——一份缩印成照片贴在窗口上给人看（预览），一份转成印刷机喜欢的油墨配方交给印刷厂（NV12 编码）。前者怕太频繁卡住看的人，所以加了个\u0026quot;每分钟最多递 30 次\u0026quot;的规矩（节流）。\n6. 完整数据流：把所有环节串起来 现在把三节串成一张全景图。数据从\u0026quot;源\u0026quot;出发，最终变成\u0026quot;预览画面\u0026quot;和\u0026quot;编码数据\u0026quot;。\n用一段话复述这条链路：\n采集源产出一帧纹理 → 通过 TexturePool::updateTexture 存入纹理池（按 sourceId 归档，受互斥锁保护）→ 渲染线程的 renderLoop 每帧先 syncRenderNodes 同步图层快照，再 renderToTexture 从池里 acquireTexture 取出各源最新帧，用 DX11Context::drawTexture（配合旋转/采样/混合着色器）一张张贴到离屏画布 m_mainTex 上 → 合成完成后，downloadPreview 把画布下载成 QImage 发给界面预览；若是直播模式，convertToYUVAndDownload 先把画布在 GPU 内转成 NV12，再下载交给推流/录制模块。\n","permalink":"https://toyezi.cn/posts/realtime-rendering-pipeline/","summary":"以纹理池、SceneCompositor 与合成输出层为主线，串起一帧画面从采集源到预览和推流录制的完整渲染管线。","title":"从零理解实时视频渲染管线：纹理池、图像混合与合成输出"},{"content":"录音软件怎样拿到麦克风的声音？录屏软件又怎样录下电脑正在播放的音乐？\n在 Windows 上，WASAPI（Windows Audio Session API）是一套管理音频流、读取或写入音频缓冲区的接口。 它可以用于播放，也可以用于捕获。本文聚焦捕获：把声音变成程序能够读取和处理的采样数据。\n一、WASAPI 在音频链路中的位置 1. 捕获与播放是两个方向 通俗解释：播放是程序把音频交给系统；捕获是程序从系统取回音频。\n方向 程序做什么 常见场景 播放（Render） 向播放缓冲区写数据 音乐播放器、游戏声音 捕获（Capture） 从捕获缓冲区读数据 麦克风录音、会议、录屏声音 WASAPI 本身不是 MP3 或 AAC 编码器，也不会自动把声音发到网络。捕获之后，你可以把数据保存为音频文件、分析音量、送入语音识别，或者编码后传输。它只完成这条链路中的音频输入输出部分。\n2. 什么叫音频端点？ 可以先把“端点”理解为 Windows 展示给应用的音频输入或输出设备。例如麦克风是输入端点，扬声器和耳机可以是输出端点。\n默认设备不是唯一设备。 应用可以选择默认端点，也可以枚举设备，让用户指定 USB 麦克风或某副耳机。本文示例用默认设备，便于把注意力放在采集流程上。\n二、WASAPI有两种使用模式 1. 共享模式与独占模式：怎样使用设备？ 对比项 共享模式（Shared） 独占模式（Exclusive） 设备使用 多个应用共享端点 一个应用独占该端点 音频路径 由 Windows 音频引擎协调处理 绕过共享音频引擎的处理路径 格式选择 需要符合共享流支持的格式 需要符合设备支持的格式 对其他应用的影响 便于同时运行多个音频应用 可能使其他应用无法使用该端点 入门场景 日常录音、会议、系统声音捕获 有明确设备格式或时延需求的应用 通俗解释：共享像大家一起使用公共设施，由系统协调；独占像单独包下一个场地，控制更直接，但别人暂时用不了。\n2. 输入捕获与回环捕获：声音从哪里来？ 对比项 输入捕获 播放端点回环捕获（Loopback） 音源 麦克风、线路输入等捕获端点 选中的播放端点正在播放的音频 选择端点 eCapture eRender 初始化标志 不加 LOOPBACK 加 AUDCLNT_STREAMFLAGS_LOOPBACK 设备使用模式 可根据需求使用共享或独占 本文讨论的端点回环要求共享模式 回环捕获直接取得音频流，不需要麦克风对着扬声器录音。这里讨论的是指定播放端点的混合声音，不代表能够自动取得所有设备的声音，也不等同于单独捕获某一个进程。\n端点回环捕获只支持共享模式。 它与声卡可能提供的“立体声混音”录音设备也不是同一套入口，不要求用户先启用这样的硬件录音设备。\n3. 轮询与事件驱动：什么时候去读？ 方式 工作过程 需要关注什么 1. 定时查询 / 轮询 等一段时间，检查是否有可读数据 查询间隔过短会增加唤醒，过长可能积压 2. 事件驱动 等待系统事件，收到通知后读取可用包 需要设置事件句柄，并能响应停止请求 事件驱动通过 AUDCLNT_STREAMFLAGS_EVENTCALLBACK 和 SetEventHandle 配置。这里的“事件”是 Windows 同步事件，不是系统自动调用自己写的 C++ 回调函数；一般由工作线程等待事件后主动读取。\n三、音频格式：捕获到的字节到底是什么？ 1. 捕获格式说明 声音经过采样后变成一连串数值。常见表示有整数 PCM 和 IEEE 浮点采样；它们都不同于 AAC、MP3 这样的压缩数据。解释数据之前，必须知道采样率、声道数、采样表示及存储位数。\n参数 常见字段 说明 采样率 nSamplesPerSec 每个声道每秒有多少次采样 声道数 nChannels 同时记录多少路声音 每个采样容器位数 wBitsPerSample 一个采样值占多少存储空间 格式标识 wFormatTag / SubFormat 字节按整数还是浮点等方式解释 每帧字节数 nBlockAlign 一个采样时刻的全部声道占多少字节 WAVEFORMATEX 保存基本格式；WAVEFORMATEXTENSIBLE 还可以描述子格式、有效位数和声道布局。容器位数不一定等于有效精度，例如采样值可以放在比有效位数更大的容器中。\n2. 如何选择捕获格式？ 选择捕获格式，就是确定：接下来读到的声音，以什么采样率、多少声道、哪种数值表示交给程序。\n这里先区分两件事：\n捕获格式：WASAPI 把音频交给程序时使用的格式。 后续处理格式：文件、编码器或语音识别模块希望接收的格式。 两者可以相同，也可以不同。比如系统交给你的是“48 kHz、双声道、32 位浮点”，语音识别模块要求的却是“16 kHz、单声道、16 位整数”。这种情况下，需要选择合适的捕获格式，再进行必要的转换。\n① 没有特殊要求：先用系统的混合格式 共享模式下，Windows 音频引擎使用一种格式进行内部处理，这就是混合格式。GetMixFormat 是 IAudioClient 提供的一个查询方法，意思是：“请告诉我这个端点的音频引擎现在使用什么格式。”\n它返回的是格式说明，不是音频数据，也不是开始录音的命令。假设已经取得音频客户端接口 audioClient，调用示意如下：\nWAVEFORMATEX* format = nullptr; // 用来接收系统返回的格式说明 HRESULT hr = audioClient-\u0026gt;GetMixFormat(\u0026amp;format); if (FAILED(hr)) { // 查询失败：处理错误，不能继续使用 format。 return; } // 查询成功后，可读取这些字段： // format-\u0026gt;nSamplesPerSec：采样率 // format-\u0026gt;nChannels：声道数 // format-\u0026gt;wBitsPerSample：每个采样容器的位数 // format-\u0026gt;wFormatTag：格式类型；扩展格式还要看 SubFormat // 后续把 format 传给同一设备的共享流 Initialize。 // 完成相关使用后，用 CoTaskMemFree(format) 释放这份格式说明。 这段是调用片段，不是完整录音程序。其中 format 是接收结果的指针变量，\u0026amp;format 表示让方法把结果地址写回这个变量；hr 是调用结果，FAILED(hr) 用来判断是否失败。\n例如，查询结果可能是：\n查询到的信息 示例值 读取时意味着什么 采样率 48000 Hz 每个声道每秒有 48000 个采样值 声道数 2 每个音频帧包含两个声道的值 采样表示 IEEE 32 位浮点 按浮点数解释数据，不能当作 16 位整数读取 每帧字节数 8 两个声道，每个值 4 字节 ② 想指定格式：先询问是否支持 假如你希望直接取得“48 kHz、双声道、16 位整数 PCM”，就需要先构造描述这个要求的格式结构，再调用 IsFormatSupported。\n这两个方法的区别是：GetMixFormat 问“系统现在用什么”，IsFormatSupported 问“我希望用这个，可不可以”。\n下面是共享模式的查询示意。假定 requestedFormat 已完整填写为你希望使用的格式：\nWAVEFORMATEX* closest = nullptr; HRESULT hr = audioClient-\u0026gt;IsFormatSupported( AUDCLNT_SHAREMODE_SHARED, // 准备使用的设备模式 \u0026amp;requestedFormat, // 希望使用的格式说明 \u0026amp;closest // 接收系统建议的接近格式 ); 结果要分情况处理：\n返回结果 意思 下一步 S_OK 支持你提出的格式 可以把请求格式交给 Initialize，仍须检查初始化结果 S_FALSE 原格式不完全匹配，但提供了接近格式 检查 closest 的实际内容，再决定接受它还是采用其他方案 AUDCLNT_E_UNSUPPORTED_FORMAT 不支持请求格式，也没有可用的接近匹配 换一种格式，或采用受支持格式捕获后自行转换 其他失败结果 可能是设备失效、参数错误等 按错误原因处理，不应一律当作格式不支持 注意：S_FALSE 不是失败码，不能只用 SUCCEEDED(hr) 就认定“原格式受支持”。 如果系统返回了 closest，用完也要通过 CoTaskMemFree 释放。\n查询独占模式时，第一个参数改为 AUDCLNT_SHAREMODE_EXCLUSIVE，第三个参数传 nullptr；需要测试设备实际接受的格式，不能直接假设共享混合格式适用。\n四、核心接口：找到设备，然后打开音频流 接口 职责 可以怎样理解 IMMDeviceEnumerator 枚举或查找默认端点 设备目录 IMMDevice 表示选中的端点 选中的设备入口 IAudioClient 初始化、启动、停止流 音频流管理器 IAudioCaptureClient 取得与释放捕获包 数据读取接口 前两个属于设备管理接口，后两个负责 WASAPI 音频流。它们配合使用，不是同一个对象换了几个名字。\n五、初始化：一步步准备好音频采集 以“共享模式 + 事件驱动”为例。先不用记 API 的名字，把整个准备过程理解成：找音源、确定数据格式、设置通知方式，最后开始录音。\n1. 先分清：对象、方法和参数不是一回事 类别 例子 说明 接口：提供一组操作能力 IAudioClient 音频流的“控制面板”，可以设置、启动、停止 方法：让接口执行某个动作 Initialize、Start 控制面板上的“设置”和“开始”按钮 参数或选项：说明动作怎么执行 AUDCLNT_SHAREMODE_SHARED 告诉系统“这次使用共享模式” 变量：保存程序中的对象或信息 audioClient、format 保存控制面板接口、音频格式等内容的名字 例如 audioClient-\u0026gt;Start() 的意思就是：通过 audioClient 保存的音频接口，执行“开始”操作。 Start 不是参数，也不是一种音频模式。\n2. 先看流程，再对应 API ① 准备使用 Windows 接口的环境 这里的音频接口通过 COM 提供。COM 可以先理解为 Windows 提供和管理接口对象的一套机制。在线程上使用这些接口前，需要先完成相应初始化，例如调用 CoInitializeEx。这一步准备的是接口运行环境，还没有开始录音。\n② 找到要录制的声音来源 先创建一个设备枚举器 IMMDeviceEnumerator，它负责“查设备列表、找默认设备”。选好后得到 IMMDevice，代表这次要使用的具体端点。\n录麦克风：找输入端点。 录电脑播放的声音：找对应的播放端点，并在后面启用回环捕获。 ③ 取得管理音频流的接口 在选中的设备上调用 Activate，请求得到 IAudioClient。后面通过它设置音频流、启动采集和停止采集。\n可以理解为：第二步选中了设备，第三步拿到了操作这个设备上音频流的“控制面板”。Activate 本身不等于开始录音。\n④ 问清楚：音频数据是什么格式？ 调用 GetMixFormat，取得共享音频引擎使用的格式，包括采样率、声道数和采样表示等。将结果保存在 format 这样的变量中，后面初始化和解释数据都会用到它。\n入门时先使用返回的混合格式。若想换成其他格式，需要另外检查支持情况；GetMixFormat 自身不会替你协商任意格式。\n⑤ 告诉系统：这次怎样采集？ 调用 Initialize 配置音频流。这里要表达三个意思：\n共享使用设备：选共享模式。 有数据时通知我：打开事件驱动选项。 如果录的是播放声音：再打开回环选项。 上一版图中的 SHARED、EVENTCALLBACK、LOOPBACK 就是这三个选项的缩写，不是额外的三个处理步骤。完整名称和用法见下一节。\n⑥ 取得真正读取数据的接口 通过 IAudioClient 的 GetService 方法取得 IAudioCaptureClient。\n两个接口的分工是：IAudioClient 负责“设置、开、关”，IAudioCaptureClient 负责“取出一包数据、用完归还”。\n⑦ 告诉系统：用哪个通知信号叫醒我？ 程序先创建一个 Windows 事件对象，再调用 SetEventHandle 把它交给音频系统。可以把事件对象理解为“通知铃”，把它的句柄理解为系统用来找到这只铃的标识。\n有数据可处理时，系统发出这个事件信号；等待它的线程被唤醒，然后自己调用读取接口。这里不是注册一个让系统自动调用的 C++ 函数。\n⑧ 准备退出信号和自己的存储空间 除了“有数据了”的通知，通常再创建一个“该停止了”的事件，确保等待中的线程也能及时退出。\n应用还需要准备自己的缓冲区或队列，保存从系统复制出来的数据，方便后续写文件、分析或编码。这属于应用的组织方式，不是一个名为“准备缓冲区”的 WASAPI 方法。\n⑨ 开始采集，并等待数据 调用 Start 才正式启动音频流。此后采集线程等待数据通知，醒来后读取可用包；收到停止通知时退出。下一章会展开“读取一包数据”具体做什么。\n串起来记：准备环境 → 选设备 → 拿控制接口 → 查格式 → 做配置 → 拿读取接口 → 设置通知 → 准备退出与缓存 → 开始采集。\n3. 代码里的选项分别是什么意思？ 先看“选择设备”这一句：\nenumerator-\u0026gt;GetDefaultAudioEndpoint(flow, eMultimedia, \u0026amp;device); 名称 是什么 这一句中的意思 enumerator 保存设备枚举接口的变量 通过它查找设备 GetDefaultAudioEndpoint 方法名 获取某类默认音频端点 flow 输入参数，类型是 EDataFlow 枚举 指定查输入端点还是播放端点 eCapture flow 的一种取值 输入端点，例如麦克风 eRender flow 的另一种取值 播放端点，例如耳机输出 eMultimedia 设备角色参数 选择“多媒体”角色对应的默认设备 \u0026amp;device 用于接收结果的地址 让方法把找到的设备接口写入 device eMultimedia 不决定输入还是输出；方向已经由 flow 决定。另一个角色值 eCommunications 表示通信用途的默认设备，按应用需求选择。\n再看音频流的三个配置选项：\n图中缩写 完整名称 用在哪里 表达的意思 SHARED AUDCLNT_SHAREMODE_SHARED Initialize 的第一个参数 与其他应用共享设备 EVENTCALLBACK AUDCLNT_STREAMFLAGS_EVENTCALLBACK 第二个参数中的标志 使用事件通知驱动数据读取 LOOPBACK AUDCLNT_STREAMFLAGS_LOOPBACK 第二个参数中的标志 从播放端点回环捕获声音 共享模式和另外两个标志不在同一个参数里。 事件标志与回环标志可以用 C++ 的按位或运算符 | 组合，表示同时启用；不能把图上的加号当成实际代码写法。\n4. 配置阶段 下面仍是示意片段，不是可直接运行的完整程序。假定已经初始化 COM、创建枚举器，并准备好接收接口的变量；实际调用时必须逐步检查返回值。\n// loopback 是应用自己定义的布尔变量，不是 WASAPI 的固定参数名。 const bool loopback = true; // true 录播放声音；false 录输入端点 EDataFlow flow = loopback ? eRender : eCapture; enumerator-\u0026gt;GetDefaultAudioEndpoint(flow, eMultimedia, \u0026amp;device); // 此处省略：通过 device-\u0026gt;Activate 取得 audioClient， // 并通过 audioClient-\u0026gt;GetMixFormat 取得 format。 // DWORD 是 Windows 常用的 32 位无符号整数类型，用于存放标志位。 DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK; if (loopback) { flags |= AUDCLNT_STREAMFLAGS_LOOPBACK; // 保留事件标志，再加回环标志 } HRESULT hr = audioClient-\u0026gt;Initialize( AUDCLNT_SHAREMODE_SHARED, // 1. 设备使用模式 flags, // 2. 本次启用的功能标志 0, // 3. 请求的缓冲区时长 0, // 4. 请求的设备处理周期 format, // 5. 音频格式描述 nullptr // 6. 不指定自定义会话标识 ); Initialize 的六个位置不能随意调换：\n位置 正式参数名 本例的值 为什么这样填 1 ShareMode AUDCLNT_SHAREMODE_SHARED 使用共享模式 2 StreamFlags flags 启用事件驱动；录播放声音时再加回环 3 hnsBufferDuration 0 共享事件模式下请求系统选择所需的最小缓冲区时长，不表示分配零字节 4 hnsPeriodicity 0 此初始化接口在共享模式下要求该参数为零，不表示线程不等待 5 pFormat format 指向采样率、声道、采样表示等格式描述，不是音频数据本身 6 AudioSessionGuid nullptr 不传入自定义音频会话标识，使用默认行为；与网络会话无关 第 3、4 个参数的时间单位是 100 纳秒，但本例都传入特殊值 0，不需要自行换算。音频会话用于把相关音频流归组管理，初学时无需为这个示例单独创建会话标识。\n返回值 hr 的类型是 HRESULT，它是本次调用的结果码。用 FAILED(hr) 等方式判断是否失败。初始化成功也不代表已经录音：还要取得读取接口、设置事件，最后调用 Start。\n六、读取音频包：借用、处理、归还 1. 每次唤醒要做什么？ 收到事件不代表“恰好只有一个包”。一次唤醒后可能积累了多个包，应循环处理当前可用数据，再回去等待。\n2. GetBuffer 返回什么？ 输出 用途 数据指针pData 指向当前捕获包 音频帧数 用于计算数据字节数和持续时长 flags 标记静音、数据不连续或时间戳异常等状态 可选设备位置与时间信息 用于定位采样在时间轴上的位置 GetBuffer 与对应的 ReleaseBuffer 必须在同一个线程上成对使用。一般消费整包并归还整包帧数，不要把一次读取当作可以随意切割的字节流操作。\n3. 为什么不能一直保存 pData？ 通俗解释：系统把一块内存暂时借给你读取，ReleaseBuffer 表示用完归还。这个指针不因此变成应用自己的长期存储。\n可以在归还前直接完成短小处理；如果要交给另一个线程，就先复制到自己持有的内存。不要只把原始指针放入队列，再归还系统缓冲区。\n4. 静音、无包和数据不连续不是一回事 状态 表示什么 应怎样理解 当前没有包 这次没有可读数据 回到等待，不要忙循环 AUDCLNT_BUFFERFLAGS_SILENT 这批帧代表静音 按帧数产生对应时长的静音数据，不依赖原始数据内容 AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY 数据流发生不连续 记录情况，按业务要求处理时间线 AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR 当前包的时间信息存在问题 不盲目信任该包的时间戳 这些状态是接口返回信息的一部分，不能只复制字节而忽略标志。\n对于常见的有符号整数 PCM 和 IEEE 浮点格式，零值可以表示静音；8 位无符号 PCM 的静音中点则是 128。填充方式必须与实际格式一致。\n简记：没有包是“现在无数据”，静音包是“这段时间有数据，但声音幅度为零”。两者对时间线的意义不同。\n七、从“能录到”到“稳定地录” 1. 采集线程应保持轻量 采集线程适合做：等待事件、读取状态、复制必要的数据、归还系统缓冲区。文件写入、编码和网络传输可以放到后续线程，避免长时间占用读取路径。\n队列需要明确同步方式和容量限制。只管往里堆数据，可能使内存持续增长，也可能让播放或传输越来越滞后。\n2. 为什么还需要应用缓冲区？ 系统每次返回的包大小，未必等于编码器或算法每次需要的数据量。例如，假设每包 480 帧，而处理器每次要 1024 帧，就需要累计、拼接，再按要求取出。这里是说明机制的数字示例，不是 WASAPI 固定的包大小。\n延迟还取决于队列中积压的音频时长。例如 48 kHz 下积压 4800 帧，仅排队就增加约 100 ms。线程每隔 1 ms 查询一次，不代表端到端延迟只有 1 ms。\n3. 停止时需要唤醒等待线程 事件驱动线程可能正在阻塞等待。如果退出时只改一个布尔值，线程不一定马上醒来。可以同时等待“音频就绪事件”和“停止事件”，由停止事件触发退出。\n一种便于管理的安排是：控制线程发停止信号；采集线程完成当前包、停止音频流、清理接口，然后退出；控制线程再完成 join。不要在线程仍访问接口时提前销毁接口。\nGetMixFormat 分配的格式内存需用 CoTaskMemFree 释放。COM 的成功初始化与反初始化应在对应线程配对；事件句柄也需要关闭。\n4. 设备变化需要重新处理 拔掉 USB 麦克风、禁用设备或重配置端点，都可能使音频流失效。收到设备失效错误时，应停止当前路径，根据需求重新选择设备并初始化。\n默认设备发生变化，也不意味着已打开的流会自动切过去。如果产品需要“始终跟随默认设备”，应设计设备变化通知与流重建逻辑。\n八、捕获之后：保存、分析或传输 需求 后续还要做什么 保存录音 按文件格式写入格式信息与音频数据，例如 WAV 显示音量或波形 正确解释采样值，计算幅度或生成绘图数据 语音识别 按识别接口要求转换采样率、声道与采样格式 网络通话或直播 编码、传输、接收端解码和播放，并处理时间与缓存 AAC、Opus 是编码选择，RTMP 等是传输方案；它们都不是 WASAPI 捕获的必选组件。学习采集时，可以先验证音频格式和保存结果，再逐步接入后续处理。\n","permalink":"https://toyezi.cn/posts/wasapi-audio-capture/","summary":"Windows 音频捕获指南，用图解、对比表和伪代码串起一次完整的 WASAPI 采集。","title":"WASAPI 音频捕获入门：从工作模式到完整采集流程"},{"content":"负载均衡是什么？ 负载均衡，就是把请求分配给多台服务器，避免某一台太忙，其他服务器却闲着。 例如：有三台服务器，一台已经处理了800个连接，另外两台各处理100个连接。负载均衡服务可以把新连接优先分配给后两台，从而提高整个系统的处理能力。配合检查，还能绕过故障点。\n负载均衡服务器怎样工作：\n方式 工作过程 特点 代理转发 客户端-\u0026gt;负载均衡服务器-\u0026gt;业务服务器 请求和响应经过代理，客户端使用同一接口 返回节点地址 客户端先检查节点，在直接连接业务服务器 调度服务器只选址，客户端需要支持查询与重试 注意：这里均衡的是新请求或新连接的分配。已经建立的 TCP 连接不会因为其他节点变空闲而自动迁移。\n常见的负载均衡算法？ 算法 描述 优点 缺点 轮询 依次将请求分配给每台服务器 简单易实现，适用于负载均衡需求较低的场景 不考虑服务器的当前负载能力和处理能力，可能导致负载不均衡 加权轮询 为每台服务器分配权重，权重高的接收更多请求 考虑了服务器的处理能力，能更好地分配负载 权重设置和调整需要根据实际情况优化 最小连接数 将请求分配给当前活动连接数最少的服务器 动态调整，适合长连接的场景 需要持续跟踪服务器的连接数，计算量较大 加权最小连接数 在最小连接数的基础上引入权重概念 结合了权重的和最小连接数的优点，能够精确地分配负载 需要实时跟踪服务器状态，增加了一定的复杂性 源地址哈希 根据客户端IP地址的哈希值将请求分配到固定的服务器 适合于需要会话保持的场景 服务器数量变化时，哈希算法可能导致请求重新分配 随机 随机选择一台服务器处理请求 简单适用，适合负载均衡需求较低的场景 不考虑服务器的当前负载能力和处理能力，可能导致负载不均衡 最小响应时间 将请求分配给平均响应时间最短的服务器 能动态适应服务器的负载情况，适用于对响应时间要求较高的场景 需要实时监控和统计服务器的响应时间，增加了实现复杂性 如何实现负载均衡服务器？ 主要由三个部分组成：\n部分 职责 负载采集与上报 读取内存信息，上报自身地址和内存使用率（登录服务器） 节点信息维护 保存各上报连接对应的节点信息（负载均衡服务器） 节点选择 按内存使用率升序排序，选择第一个（负载均衡服务器） 具体流程：\n登录服务器启动后，连接负载服务的对用端口。 立即上报一次状态，并通过定时器每秒上报一次。 负载服务器接收状态消息，保存对应IP、端口和内存使用率。 客户端发送节点查询请求。 负载服务器选择内存使用率最低的节点，将地址返回客户端。 客户端连接登录服务器。 需要注意：内存占用低不一定代表处理能力最空闲，CPU、连接数、请求耗时也可能成为瓶颈。\n如何构建一个可用的负载均衡服务？ ","permalink":"https://toyezi.cn/posts/load_balancing_server/","summary":"从代理转发、返回节点地址两种方式，到负载采集上报、节点维护与最低负载选点的完整流程。","title":"负载均衡服务器"},{"content":"Muduo的本质：\nMuduo 是一个基于 Reactor 模式、One loop per thread 线程模型的高性能 C++ 网络库。\n关键词 对应章节 大白话 Reactor 模式 第一章 事件驱动，来了事件才干活，不傻等 One loop per thread 第四章 一个线程配一个事件循环 高性能 各章 非阻塞 + epoll + 缓冲区复用 整个库的零件清单如下，后面逐章拆解：\n零件 职责 一句话 EventLoop 事件循环 大总管，负责分发所有事件 Channel 事件载体 每个 fd 配一个，知道\u0026quot;我对什么事件感兴趣\u0026quot; Buffer 数据缓冲区 每个连接配一个，暂存收发数据 Poller 多路复用器 封装 epoll，负责\u0026quot;等事件\u0026quot; TimerQueue 定时器队列 管理所有定时任务 Acceptor 连接接收器 只干一件事：accept 新连接 TcpConnection 连接对象 代表一条已建立的连接 TcpServer 服务器 把上面所有零件组装起来 简记：TcpServer 是工厂，Acceptor 是门卫，EventLoop 是车间主任，Channel 是工人，Buffer 是工人手里的货筐。\n一、Reactor 模型 1. 什么是 Reactor 通俗解释：传统 I/O 像\u0026quot;派个人守在门口，挨个问\u0026rsquo;你好了没\u0026rsquo;\u0026quot;，Reactor 像\u0026quot;装了个门铃，谁按门铃我才去开门\u0026quot;。\nReactor 是一种基于事件驱动的 I/O 处理模式：\n程序不主动轮询每个连接。 把连接交给内核的 I/O 复用机制（epoll / poll）监听。 事件就绪了，内核通知程序，程序再去处理。 2. 三种 Reactor 变体 Reactor 按线程数量演进出三种形态，一张表对比：\n变体 线程结构 谁 accept 谁处理读写 瓶颈 单 Reactor 单线程 1 个线程 同线程 同线程 任意一处阻塞全卡死 单 Reactor 多线程 1 接收 + N 工作 接收线程 工作线程池 accept 仍是单点 多 Reactor 多线程 1 主 + N 子 主 Reactor 子 Reactor 各管一摊 无明显瓶颈，主流方案 Muduo采用的就是第三种：多 Reactor 多线程。\n3. Muduo 的多 Reactor 分工 主 Reactor（Main Reactor）：只 accept 新连接，做最轻的事。 子 Reactor（Sub Reactor）：每个子线程跑一个 EventLoop，各自负责一部分连接的读写。 连接处理器（Connection Handler）：把新连接轮询分配到某个子 Reactor，实现负载均衡。 一句话：主 Reactor 只\u0026quot;接客\u0026quot;，子 Reactor 负责\u0026quot;干活\u0026quot;，各忙各的，互不阻塞。\n4. Reactor vs Proactor 对比项 Reactor Proactor I/O 操作 同步（内核只通知\u0026quot;就绪\u0026quot;，程序自己 read/write） 异步（内核直接完成 read/write，再通知\u0026quot;完成\u0026quot;） 代表 libevent、libuv、Muduo、Netty、Redis IOCP（Windows）、Boost.Asio 复杂度 简单、主流 复杂，依赖操作系统异步 I/O 支持 5. 为什么底层选择使用 epoll 对比项 select / poll epoll 查找就绪事件 每次遍历所有 fd（O(n)） 直接返回就绪列表（O(1)） fd 数量上限 select 默认 1024 无上限 拷贝 每次把 fd 集合在用户态/内核态来回拷 只注册一次 触发方式 仅水平触发 支持水平 + 边缘 水平触发（LT）：只要缓冲区还有数据，就反复通知，不漏数据但可能重复。 边缘触发（ET）：只在状态变化时通知一次，高效但要一次读干净。\nMuduo 用的是 LT + 非阻塞 I/O，稳妥不易出错。\n简记：\n为什么不用 select？—— O(n) 遍历 + fd 上限 + 每次拷贝。 LT 和 ET 的区别？—— LT 反复通知，ET 只通知一次。 Muduo 为什么选 LT？—— 简单可靠，配合非阻塞读也不会出错。 二、Socket 编程关键点 1. TCP 连接与 Socket API 的对应 步骤 Socket API 说明 建立连接 connect() / accept() 背后是 TCP 三次握手 传输数据 send() / recv() 背后是内核缓冲区的搬运 关闭连接 close() 背后是 TCP 四次挥手 2. TIME_WAIT 与 SO_REUSEADDR 通俗解释：服务器 close() 后，端口不会立刻释放，而是进入 TIME_WAIT 状态\u0026quot;停留几十秒\u0026quot;，防止旧连接的迟到数据干扰新连接。如果此时重启服务器，会报 Address already in use。\nSO_REUSEADDR：允许新 Socket 绑定到仍处于 TIME_WAIT 的端口。 作用：服务器重启后能立刻再绑定同一个端口，不用干等几十秒。 服务器几乎必开 SO_REUSEADDR。\n3. 粘包 / 拆包 通俗解释：TCP 是\u0026quot;字节流\u0026quot;，没有消息边界。你发两个\u0026quot;包裹\u0026quot;，对端可能一次收到一个半，或两个一起收到。\n三种常见解决方案：\n方案 做法 优缺点 定长包 每个包固定长度 简单，但浪费空间 分隔符 用特殊字符隔开（如 \\r\\n） 简单，但数据里不能出现分隔符 长度前缀 包头 4 字节存长度 + 数据体 最通用，Muduo 推荐 4. SO_KEEPALIVE vs 应用层心跳 对比项 SO_KEEPALIVE 应用层心跳 谁实现 内核 应用程序 默认探测间隔 很长（通常 2 小时） 自定义（如 30 秒） 能发现对端\u0026quot;进程死了\u0026quot; 能 能 能发现对端\u0026quot;进程活着但业务卡死\u0026quot; 不能 能 结论：SO_KEEPALIVE 是兜底，业务上通常还要自己做应用层心跳。\n5. send / recv 的正确姿势 情况 返回值 正确处理 recv 返回 0 对端关闭了连接 关闭连接 recv 返回 -1 且 errno==EAGAIN 缓冲区暂时无数据 等下次事件，不是错误 send 返回 -1 且 errno==EAGAIN 缓冲区满 关注写事件，稍后再发 send 返回 \u0026lt; 预期长度 只发了一部分 把剩余数据存起来继续发 关键：非阻塞 I/O 下，EAGAIN 不是错误，是\u0026quot;现在没数据，别急\u0026quot;。这是最容易误解的点。\n三、Channel —— 事件的载体 1. Channel 是什么 通俗解释：每个 fd 配一个 Channel，它就像 fd 的\u0026quot;秘书\u0026quot;——记着\u0026quot;老板对什么事件感兴趣\u0026quot;（events_），以及\u0026quot;刚才发生了什么事件\u0026quot;（revents_）。\n核心规则：一个 Channel 始终对应一个 fd，两者生命周期绑定。\n2. 关键成员深挖 成员 类型 含义 大白话 fd_ int 监听的 fd 盯哪个人 events_ int（位掩码） 感兴趣的事件 我想知道啥 revents_ int（位掩码） 就绪的事件 刚才发生了啥 index_ int 在 Poller 中的状态 门卫登记没 tie_ weak_ptr 防悬空 防止主人没了 events_ / revents_ 的位掩码（用二进制的位表示不同事件）：\n事件宏 含义 POLLIN / EPOLLIN 可读 POLLOUT / EPOLLOUT 可写 POLLERR 出错 POLLHUP 挂断（对端关闭） events_ 是我主动注册的，revents_ 是 epoll 返回的。epoll_wait 后，revents_ 会被内核填上实际就绪的事件。\n3. tie_ 如何防悬空（重点） 场景：Channel 的回调里可能要用到所属的 TcpConnection 对象。如果连接已经销毁了，回调还在跑，就会访问已释放的内存（悬空指针），导致崩溃。\n解决办法：tie_ 是一个 std::weak_ptr，指向所属的连接对象。\n关键点：Channel 不拥有 TcpConnection，只持有 weak_ptr，这样连接销毁时 Channel 能感知到，避免悬空。\n4. Channel 的状态机 Channel 在 Poller 中有三种状态，用 index_ 记录：\n状态 值 含义 kNew -1 还没注册到 Poller kAdded \u0026gt;= 1 已注册 kDeleted 2 已注册但待删除 常用方法：\n方法 作用 enableReading() 关注读事件，events_ |= POLLIN enableWriting() 关注写事件，events_ |= POLLOUT disableAll() 取消所有关注 setReadCallback() 绑定读回调 5. 回调绑定与分发 Channel 上挂着几个回调函数，EventLoop 分发事件时直接调用：\n回调 触发时机 readCallback_ 可读事件就绪 writeCallback_ 可写事件就绪 closeCallback_ 连接关闭 errorCallback_ 出错 分发逻辑一句话：epoll_wait 返回就绪事件 → 找到对应 Channel → 按 revents_ 调用对应回调。\n简记：\nevents_ 和 revents_ 的区别？—— 前者是\u0026quot;关心\u0026quot;，后者是\u0026quot;实际发生\u0026quot;。 tie_ 为什么用 weak_ptr？—— 防连接销毁后回调悬空。 Channel 和 fd 的关系？—— 一一对应。 四、EventLoop —— 事件循环（核心） 1. 什么是 EventLoop 通俗解释：EventLoop 是\u0026quot;车间主任\u0026quot;，它永远在一个循环里干活——\u0026ldquo;等事件 → 处理事件 → 再等\u0026rdquo;。\n2. One loop per thread（核心原则） 一个线程至多有一个 EventLoop，一个 EventLoop 只属于一个线程。\n为什么这样设计？\n好处 说明 无锁 所有操作都在本线程，不需要加锁 简单 不用考虑跨线程访问数据的安全问题 高效 避免线程切换和锁竞争 这就是 Muduo 高性能的根基：用\u0026quot;一个线程一个 loop\u0026quot;换掉\u0026quot;一把大锁\u0026quot;。\n3. runInLoop / queueInLoop（源码级） 因为 EventLoop 属于某个线程，别的线程不能直接操作它。所以提供两个安全入口：\nvoid EventLoop::runInLoop(Functor cb) { if (isInLoopThread()) { // 就在 loop 线程里 cb(); // 直接执行 } else { queueInLoop(cb); // 塞进队列，唤醒 loop } } void EventLoop::queueInLoop(Functor cb) { pendingFunctors_.push_back(cb); // 加入任务队列 if (!isInLoopThread() || callingPendingFunctors_) { wakeup(); // 唤醒阻塞中的 loop } } 两者的区别：\n接口 语义 适用场景 runInLoop 在 loop 线程里执行；不在则转投队列 用户想\u0026quot;确保在 loop 线程里跑\u0026quot; queueInLoop 无条件丢进队列，异步执行 不需要立即执行，排队即可 4. wakeup() 与 eventfd（唤醒机制） 问题：loop 线程正阻塞在 epoll_wait 上睡觉，别的线程往队列塞了任务，怎么叫醒它？\n答案：用 eventfd 创建一个\u0026quot;唤醒管道\u0026quot;，把它也注册进 epoll。\neventfd 比 pipe 轻量，且是 Linux 专门为\u0026quot;线程间唤醒\u0026quot;设计的。\n5. EventLoopThread / EventLoopThreadPool 多 Reactor 需要多个 loop 线程，靠这两个类管理：\n类 作用 EventLoopThread 封装\u0026quot;一个线程 + 一个 EventLoop\u0026quot;的启动与停止 EventLoopThreadPool 管理多个 EventLoopThread，提供轮询分配 分配策略：TcpServer 拿到新连接后，从池子里**轮询（round-robin）**挑一个 loop 给它。\n新连接1 → 子loop0 新连接2 → 子loop1 新连接3 → 子loop2 新连接4 → 子loop0 ← 轮询回来了 简记：\nOne loop per thread 为什么？—— 避免加锁，简化模型，提高性能。 runInLoop 和 queueInLoop 区别？—— 前者同线程直接跑，后者总是排队。 怎么唤醒阻塞的 loop？—— eventfd，写字节触发 epoll 可读。 五、Buffer —— 数据缓冲区 1. 为什么需要 Buffer 通俗解释：TCP 是字节流，一次 recv 读到的数据量不确定。你得有个\u0026quot;货筐\u0026quot;先把数据接着，攒够了再按业务处理。\nMuduo 里，每个 TcpConnection 都有一个输入 Buffer 和一个输出 Buffer。\n2. Buffer 的内存布局（核心） Buffer 底层用 vector\u0026lt;char\u0026gt;，用两个下标管理数据，布局如下：\n下标 含义 readIndex_ 可读数据的起点 writeIndex_ 可写空间的起点 readIndex_ 和 writeIndex_ 之间的，是已写入、还没读走的数据。 readIndex_ 前面留了一段 prependable 空间。 3. prepend 区的作用 为什么读数据前面要留 8 字节？\n当需要在数据前面插入东西（比如粘包处理里补一个长度头）时，可以直接往 prepend 区写，不用把整个数据后移，省一次拷贝。\n4. Buffer 的四种状态 关键：读写都是在原地滑动指针，不用反复 malloc/free，这就是 Buffer 高效的原因。\n5. 分散读 readv（源码级） readFd 用 readv 一次读多个缓冲区，减少系统调用：\nssize_t Buffer::readFd(int fd, int* savedErrno) { char extrabuf[65536]; struct iovec vec[2]; vec[0].iov_base = writable(); // 主缓冲区可写区 vec[0].iov_len = writableBytes(); vec[1].iov_base = extrabuf; // 栈上备用缓冲 vec[1].iov_len = sizeof(extrabuf); // 一次 readv，能读多少读多少 ssize_t n = readv(fd, vec, 2); ... } 好处：数据量小时直接读进 Buffer；数据量大时溢出到 extrabuf，一次系统调用搞定，不用先探测再读。\n6. 扩容策略 Buffer 的 writable 空间不够时，会自动扩容（vector 增长）。 扩容时通常按需增长，而不是固定翻倍，避免内存浪费。 7. 基于 Buffer 拆包 结合第二章的粘包问题，用 Buffer 的 readIndex_ 就能实现拆包：\nrecv 数据 → 追加到 Buffer → 检查 Buffer 里可读数据够不够一个完整包（长度前缀） → 够 → 取出来处理，readIndex_ 前移 → 不够 → 继续等下一次事件 这就是\u0026quot;长度前缀\u0026quot;方案在 Muduo 上的落地：Buffer 负责攒数据，业务负责判断边界。\n简记：\n为什么用两个下标不用一个？—— 支持\u0026quot;读了不消费\u0026quot;和 prepend 预留。 prepend 区干嘛的？—— 前面插数据不用搬移。 readv 干嘛的？—— 一次系统调用读多块缓冲。 六、Timer —— 定时器 1. Timer 是什么 通俗解释：让程序能\u0026quot;到点了自动干某件事\u0026quot;，是心跳、超时、限流等功能的地基。\n2. 常见应用场景 场景 说明 定时发送 周期上报、定时推送 会话超时 连接长时间无响应就清理 心跳包 定期发心跳，检测对端是否存活 限流控制 按时间窗口控制速率 延迟重试 失败后隔一段时间再试 3. Timer 相关组件 组件 作用 Timer 单个定时器：到期时间 + 回调 + 是否周期 TimerQueue 定时器队列，按到期时间排序 TimerId 定时器句柄，用于取消 timerfd 内核定时器，用于唤醒 loop 4. TimerQueue 的底层结构（源码级） TimerQueue 内部用 std::set 存储定时器，按到期时间排序：\nstd::set\u0026lt;Timer\u0026gt; （按 expiration 排序） ├── Timer(10:00:01) ← 最近到期 ├── Timer(10:00:05) └── Timer(10:00:30) ↑ 每次只看最前面一个，到期就执行 用 set 的好处：最早到期的永远在最前面，取出来 O(1)，插入/删除 O(log n)。\n5. timerfd_create 的作用 问题：loop 线程阻塞在 epoll_wait，怎么在\u0026quot;定时器到期\u0026quot;时精确醒来？\n答案：用 timerfd_create 创建一个内核定时器，到期时它对应的 fd 会变可读，正好能被 epoll 监听到。\n定时器到期 → 内核把 timerfd 置为可读 → epoll 返回 → loop 醒来处理定时任务 这样定时器事件和 I/O 事件统一了——都通过 epoll 通知，loop 不需要额外 sleep 或轮询时间。\n6. 定时器的生命周期 操作 行为 addTimer 新建 Timer 插入 set，更新 timerfd 的到期时间 cancel 从 set 移除 到期 执行回调；若是周期定时器，重新计算时间再插回 set 7. 时间精度与开销 timerfd 精度可达纳秒，实际使用毫秒级足够。 定时器太多会带来 set 维护开销，但 Muduo 场景下可忽略。 简记：\nTimerQueue 用什么结构？—— std::set，按到期时间排序。 为什么要 timerfd？—— 让定时事件统一走 epoll，精确唤醒 loop。 七、TcpConnection —— 一条连接的完整生命周期 1. 什么是 TcpConnection 通俗解释：TcpConnection 是\u0026quot;一条已建立连接的化身\u0026quot;，代表你和某个客户端的通话。它拥有这条连接的 Socket、Channel、输入/输出 Buffer。\nTcpConnection 拥有的东西： ├── Socket（套接字） ├── Channel（事件载体） ├── input Buffer（收数据） └── output Buffer（发数据） 2. 状态机（四态） 一条连接从建立到关闭，经历四种状态：\n状态 含义 触发 kConnecting 正在建立 刚 accept（客户端是 connect） kConnected 已连接 握手完成 kDisconnecting 正在断开 主动 close 发起 kDisconnected 已断开 断开完成 为什么有 kDisconnecting 这个\u0026quot;中间态\u0026quot;？—— 因为发送缓冲里可能还有数据没发完，不能立刻断开，得先等数据发完。\n3. 关键回调 TcpConnection 对外暴露几个回调，用户实现业务逻辑：\n回调 触发时机 connectionCallback 连接建立/断开时 messageCallback 收到完整数据时 writeCompleteCallback 数据全部写完时 4. 优雅关闭（重点） 通俗解释：不能\u0026quot;啪\u0026quot;地挂电话，要先把手头的话说完（把缓冲区数据发完）再挂。\nclose() 请求 ↓ 如果 output buffer 还有数据 → 进入 kDisconnecting，先发完 如果 buffer 空了 → 真正 close 这解释了为什么需要 kDisconnecting 状态——优雅关闭的关键。\n5. 连接销毁时机（配合 Channel 的 tie_） 连接关闭时，TcpConnection 会被销毁。但因为 Channel 持有的是 weak_ptr（tie_），不会导致悬空，回调里能安全判断连接是否还活着。\n简记：\nTcpConnection 有哪些状态？—— 四态：connecting/connected/disconnecting/disconnected。 为什么要有 kDisconnecting？—— 等输出缓冲发完再关，实现优雅关闭。 八、Acceptor —— 迎接新连接 1. 什么是 Acceptor 通俗解释：Acceptor 是\u0026quot;门卫\u0026quot;，只干一件事——守监听套接字，来了新连接就 accept。\n2. 内部组成 Acceptor ├── listenfd（监听套接字，绑定在 listen 端口） ├── acceptChannel（监听 listenfd 的 Channel） └── newConnectionCallback（拿到新连接后回调） 3. accept 流程 4. 处理并发到达 一次只 accept 一个：用 LT 触发，只要还有连接没 accept，epoll 会反复通知，不会漏。 也可能在循环里连续 accept 多个，直到 EAGAIN。 为什么用 LT？—— LT 保证\u0026quot;只要还有未 accept 的连接，就会再通知\u0026quot;，天然不会漏接。\n九、TcpServer —— 把所有零件组装起来 1. 什么是 TcpServer 通俗解释：TcpServer 是\u0026quot;工厂总装线\u0026quot;，把前面所有零件组装成一个能跑的服务器。\n2. 组装清单 零件 数量 职责 Acceptor 1 主 loop 里 accept 新连接 EventLoopThreadPool 1 管理 N 个子 loop TcpConnection 每连接 1 代表一条连接 3. 启动流程 TcpServer 构造：传入主 loop、端口 ↓ start()： ├── 启动 EventLoopThreadPool（子 loop 们跑起来） └── 启动 Acceptor 监听 ↓ 新连接到来 → accept → 从池子轮询选子 loop → 建 TcpConnection → 绑定到该子 loop 4. 对外接口 方法 作用 setConnectionCallback 设置连接建立/断开回调 setMessageCallback 设置收到消息回调 setThreadNum 设置子 Reactor 数量 start 启动服务器 十、完整调用链 把前面所有零件串起来，一条数据从进到出的完整路径：\n一条数据经历：accept → 分配 loop → 注册 Channel → epoll 通知 → Buffer 缓冲 → 业务处理 → 回写。\n十一、Echo 服务器完整示例 一个最简的 echo 服务器，对照上面所有概念：\n#include \u0026lt;muduo/net/TcpServer.h\u0026gt; #include \u0026lt;muduo/net/EventLoop.h\u0026gt; #include \u0026lt;muduo/base/Logging.h\u0026gt; #include \u0026lt;string\u0026gt; using namespace muduo; using namespace muduo::net; // 收到消息的回调：原样回发 void onMessage(const TcpConnectionPtr\u0026amp; conn, Buffer* buf, Timestamp time) { std::string msg(buf-\u0026gt;retrieveAllAsString()); // 取出 Buffer 里全部数据 conn-\u0026gt;send(msg); // 原样发回（echo） } // 连接建立/断开的回调 void onConnection(const TcpConnectionPtr\u0026amp; conn) { if (conn-\u0026gt;connected()) { LOG_INFO \u0026lt;\u0026lt; \u0026#34;新连接来自 \u0026#34; \u0026lt;\u0026lt; conn-\u0026gt;peerAddress().toIpPort(); } else { LOG_INFO \u0026lt;\u0026lt; \u0026#34;连接断开\u0026#34;; } } int main() { EventLoop loop; // 主 Reactor InetAddress addr(8888); // 监听 8888 端口 TcpServer server(\u0026amp;loop, addr, \u0026#34;EchoServer\u0026#34;); server.setConnectionCallback(onConnection); // 绑定连接回调 server.setMessageCallback(onMessage); // 绑定消息回调 server.setThreadNum(4); // 4 个子 Reactor server.start(); // 启动 loop.loop(); // 主 loop 跑起来 return 0; } 代码对照概念：\n代码 对应概念 EventLoop loop 主 Reactor server.setThreadNum(4) 4 个子 Reactor（多 Reactor 多线程） setMessageCallback 注册 TcpConnection 的消息回调 buf-\u0026gt;retrieveAllAsString() 从 Buffer 读数据（readIndex 前移） conn-\u0026gt;send 写 output Buffer 问题解决 什么是 Reactor 模式？ —— 事件驱动 + I/O 复用，epoll 监听就绪后回调处理。 One loop per thread 有什么好处？ —— 避免加锁，简化模型，提高性能。 epoll 的 LT 和 ET 区别？Muduo 用哪个？ —— LT 反复通知，ET 只通知一次；Muduo 用 LT。 events_ 和 revents_ 的区别？ —— 前者是\u0026quot;关心的事件\u0026quot;，后者是\u0026quot;实际就绪的事件\u0026quot;。 Channel 的 tie_ 为什么用 weak_ptr？ —— 防连接销毁后回调访问悬空内存。 runInLoop 和 queueInLoop 的区别？ —— 同线程直接执行 vs 总是排队。 怎么唤醒阻塞在 epoll 的 loop？ —— eventfd，写字节触发可读。 Buffer 为什么要 prepend 区？ —— 前面插数据（如长度头）不用搬移。 TimerQueue 用什么数据结构？ —— std::set，按到期时间排序。 为什么要 timerfd？ —— 让定时事件统一走 epoll，精确唤醒 loop。 TcpConnection 有哪些状态？ —— 四态：connecting/connected/disconnecting/disconnected。 什么是优雅关闭？ —— 等输出缓冲发完再 close，对应 kDisconnecting 态。 总结 概念 一句话 Reactor 事件驱动 + I/O 复用，epoll 监听、就绪后回调 One loop per thread 一个线程一个事件循环，天然无锁 EventLoop 事件循环，分发 I/O / 定时 / 用户任务 Channel fd + 事件 + 回调的封装，最小事件单位 Poller 封装 epoll，负责\u0026quot;等事件\u0026quot; Buffer 连接级缓冲，readIndex/writeIndex 双指针 TimerQueue set 排序的定时器队列 Acceptor 门卫，只 accept 新连接 TcpConnection 一条连接的化身，四态状态机 TcpServer 总装线，把所有零件拼成服务器 eventfd 唤醒阻塞中 loop 的\u0026quot;闹钟\u0026quot; timerfd 让定时事件走 epoll 的内核定時器 ","permalink":"https://toyezi.cn/posts/network-programming-muduo/","summary":"以 Muduo 为例梳理 Reactor 网络库：事件循环、缓冲区、定时器、连接生命周期与多 Reactor 分工。","title":"网络编程与 Muduo 深入笔记"},{"content":"1. RTMP是什么？ RTMP（Real-Time Messaging Protocol，实时消息传输协议）是一种用来持续传输音频、视频和相关信息的协议。在直播中，它的一个常见用途是推流：主播一边录，直播软件一边把已经编码好的画面和声音发给平台，不必等正常直播结束后才上传。\n摄像头和麦克风复责采集画面和声音，编码器复责把这些数据压缩到网络能传的动的大小，而RTMP复责规定了直播软件和服务器该怎样传递这些数据。\n1.1 RTMP的特点 音视频可以共用一条连接。直播软件会交替发送一小段视频段、一小段音频，并附上时间戳。\n实时不等于零延迟。RTMP通常通过TCP传输。网络丢包时，TCP会重传以保证数据完整、有序。这也意味着网络不好时，直播可能出现卡顿或延迟增加。\n2.RTMP握手是什么？ 由于RTMP是基于TCP实现的。RTMP握手发生在TCP连接建立后、发送直播数据之前。可以把TCP连接理解为“电话接通了”，把RTMP握手理解为“双方在通话前说：我们都用同一种协议交流”。\n以下是RTMP公开规范里的简单握手。C代表客户端发出的数据，S代表服务端发出的数据。\n3. RTMP分块是什么？ 在RTMP握手后，RTMP把音频、视频和命令组织成消息（Message），再把每条消息切成一个或多个块（Chunk）来传输。\n3.1 为什么要分块？ 想象一条很大的视频消息正在发送，这时来了一条很短的音频消息。如果必须把整条视频发完，音频就要一直等。切成小块后，发送端可以在两个视频块间插入音频块，再接着发视频。\n3.2 分块的流程？ 发送端：先确定消息有多长、每块能装多少。假设FFmpeg准备发送一条300字节的RTMP音频消息(300字节指的是消息体，不包括后续要添加的块头)。RTMP默认每块最多装128字节的消息体。\n给每块加头：让接收端知道“这是谁的数据”。每块的结构是：基本头+消息头+可选的拓展时间戳+本地数据。基本头中最重要的是CSID（块流ID）和fmt（消息头格式）。可以把CSID理解为该往哪个拼装区放，把fmt理解为这次写了多少新信息。\n传输中：不同块流可以穿插，同一条消息保持顺序。例如音频使用 CSID=4，视频使用 CSID=6，发送顺序可以是：\n视频块① → 音频块① → 音频块② → 视频块② → 音频块③ CSID 6 CSID 4 CSID 4 CSID 6 CSID 4\n接收端：收到多少算一块，拼到什么时候算一条消息？\n关键是：首块说明的是整条消息的长度，每块实际读取的长度由接收端算出来。\n4. RTMP中的AMF0指令是什么？ 在RTMP中，connect、createStream、publish是命令；AMF0是把命令名和参数写出字节的规则。可以把它理解成一次远程函数调用，客户端告诉服务器“我要执行哪个操作”，再按照顺序附上参数。\n联系前面的分块操作，接收端的处理顺序是：先把Chunk拼成完整的RTMP消息，再根据消息类型解析消息体。如果消息体类型是20（十六进制0x14），就按AMF0命令消息读取。\n一条AMF0命令，里面依次放了什么？ 顺序 字段 常用 AMF0 类型 含义 1 命令名 String 要执行什么，例如 \u0026quot;connect\u0026quot;、\u0026quot;publish\u0026quot; 2 事务 ID Number 用来把请求和回复对上。例如请求编号为 2，对应的 _result 也带 2 3 命令对象 Object 或 Null 放这条命令的配置；不需要时，通常仍写一个 null 占位 4 及以后 命令参数 按命令而定 例如流名称、发布模式；有些命令没有额外参数 用一次推流看懂三个核心命令 假设要推流到rtmp://example.com/live/camera01。这里live是应用名，camera01是流名。握手完成后，关键对话如下（省略了协议控制消息）：\n方向 命令体示意 通俗解释 客户端 → 服务器 [\u0026quot;connect\u0026quot;, 1, {app:\u0026quot;live\u0026quot;, tcUrl:\u0026quot;rtmp://example.com/live\u0026quot;}] 连接应用：“我要进入 live 这个应用。” 服务器 → 客户端 [\u0026quot;_result\u0026quot;, 1, …, {code:\u0026quot;NetConnection.Connect.Success\u0026quot;, …}] “编号 1 的连接请求成功了。”此时还没开始推流。 客户端 → 服务器 [\u0026quot;createStream\u0026quot;, 2, null] 创建流：“请给我分配一条消息流。” 服务器 → 客户端 [\u0026quot;_result\u0026quot;, 2, null, 1] “编号 2 的请求成功了，给你的消息流 ID 是 1。”这个 1 是示例，以实际返回值为准。 客户端 → 服务器 [\u0026quot;publish\u0026quot;, 0, null, \u0026quot;camera01\u0026quot;, \u0026quot;live\u0026quot;] 发布流：“我要在刚分配的消息流上，以直播模式发布 camera01。” 服务器 → 客户端 [\u0026quot;onStatus\u0026quot;, 0, null, {code:\u0026quot;NetStream.Publish.Start\u0026quot;, …}] “这条流已开始发布。”接下来传送的音频、视频各自使用对应的 RTMP 消息类型。 AMF0怎么编码一条命令？ 以[\u0026quot;createStream\u0026quot;, 2, null]为例。\n字段 实际字节（十六进制） 怎样理解 命令名 \u0026quot;createStream\u0026quot; 02 · 00 0C · 63 72 65 61 74 65 53 74 72 65 61 6D 02 表示字符串；00 0C 表示后面有 12 B 文本；共 15 B 事务 ID 2 00 · 40 00 00 00 00 00 00 00 00 表示 Number；后面 8 B 是大端双精度浮点数 2.0；共 9 B 命令对象 null 05 05 本身就表示 Null，没有后续内容；共 1 B 5.主播如何通过RTMP推流 当主播收到服务器已接受发布请求。这时直播通道已经建好了，但观众还不能看到画面，后续两条可以同时进行的流程：\n主播 → 服务器：publish 成功 → 发送解码配置 → 持续发送音频、视频 观众 → 服务器：建立自己的连接 → play 请求同一条流 → 接收、解码、播放\n5.1 主播先告诉接收端：这些数据怎么解码 以常见的AAC音频+H264视频为例，推流刚开始时：\n消息 RTMP 消息类型 里面是什么 通俗理解 onMetaData 18，即 0x12（AMF0 数据） 可包含宽高、帧率、编码类型等 “这场直播大致是什么规格。”这是描述信息，可选。 AAC 配置消息 8，即 0x08（音频） AACPacketType=0，携带 AudioSpecificConfig，描述 AAC 类型、采样率、声道配置等 “后面的声音要按这些参数解码。” AVC 配置消息 9，即 0x09（视频） AVCPacketType=0，携带 AVC 解码配置，其中通常包括 SPS、PPS “后面的 H.264 画面要用这套参数解码。” 此处即使onMetaData写了视频是H.264、分辨率是1920x1080，也不能代替视频解码器需要的SPS、PPS。前者描述直播，后者参与实际编码。\n5.2 主播开始持续发送真正的声音和画面 **配置发出后，同样的RTMP消息类型开始承载媒体数据。判断”这是配置还是内容“，要再看消息体里的包类型：\n抓包看到什么 实际含义 音频消息 0x08，AACPacketType=0 AAC 解码配置，本身不产生可播放的声音 音频消息 0x08，AACPacketType=1 压缩后的 AAC 音频数据 视频消息 0x09，AVCPacketType=0 H.264 解码配置，本身不是一幅画面 视频消息 0x09，AVCPacketType=1 H.264 编码数据，可包含关键帧或依赖其他帧的画面 然后，观众加入：走自己的play流程。假设主播发布的流名叫camera01，观众也用RTMP播放。观众不会复用主播的TCP连接，而是自己向服务器发起请求： 观众 → 服务器：握手 → connect(\u0026ldquo;live\u0026rdquo;) → createStream → play(\u0026ldquo;camera01\u0026rdquo;) 服务器 → 观众：StreamBegin、onStatus(NetStream.Play.Start) 服务器 → 观众：解码配置 + 音视频数据\n其中：\npublish(\u0026quot;camera01\u0026quot;)是主播说”我要发布“这路直播；\nplay(\u0026quot;camera01\u0026quot;)是观众说”我要观看“这路直播。两端的应用名和流名找到同一份内容，各自连接上的消息流ID不要求相同。\n5.3 常见现象解析 为什么观众中途进入，有时先听到声音，过一会儿才看到画面？\n一种原因是：播放器已经拿到 AAC 配置和音频数据，可以开始放声音；但此时收到的视频帧依赖入场前的画面。视频解码器还需要配置和一个能独立开始解码的位置，例如 H.264 的 IDR 帧。如果服务器缓存了合适的关键帧及后续数据，就可以从那里开始发给新观众。\n5.4 直播过程中：媒体与控制消息 在建立连接时或传输期间会出现一些控制消息：\n控制消息 解决什么问题 Set Chunk Size 发送方通知对端：“接下来我按新的块大小切消息。” Window Acknowledgement Size / Acknowledgement 指定接收多少字节后报告一次，并回报累计接收量。不是每个音视频帧都回复一次。 PingRequest / PingResponse 一方探测对端是否仍有响应，对端回送对应的时间值。 5.5 结束直播/退出直播 主播正常下播时，如果编码器里还有延迟输出，推流程序应先排出并发送尾部音视频，再结束发布。RTMP 可以用 deleteStream 删除相应的消息流；如果这条连接不再使用，再关闭 TCP。若是网络突然中断，就不一定能看到完整的收尾命令。反过来，一个观众退出，只结束他自己的播放，不会让主播或其他观众下线。\n","permalink":"https://toyezi.cn/posts/rtmp_server/rtmp%E6%9C%8D%E5%8A%A1%E5%99%A8/","summary":"\u003ch2 id=\"1-rtmp是什么\"\u003e1. RTMP是什么？\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eRTMP（Real-Time Messaging Protocol，实时消息传输协议）是一种用来持续传输音频、视频和相关信息的协议。\u003cstrong\u003e在直播中，它的一个常见用途是\u003c/strong\u003e推流\u003c/strong\u003e：主播一边录，直播软件一边把已经编码好的画面和声音发给平台，不必等正常直播结束后才上传。\u003c/p\u003e","title":"RTMP 服务器"}]