ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写一个流式音频加载器:用 C# 代码看清 Streaming 的底层机制

手写一个流式音频加载器:用 C# 代码看清 Streaming 的底层机制 从知道原理到看见代码上一篇我们把流式加载的逻辑讲透了一个小缓冲区加上后台不停地续杯就化解了连续播放和省内存这对矛盾。但那都是概念。这篇我们往下再走一层——用真正的 C# 代码把那个缓冲区续杯的循环写出来。当你亲眼看到代码里那个Unity 问我要数据、我从磁盘读一段给它的回调是怎么运转的流式加载对你就不再是个抽象名词而是一套你能亲手复现、甚至定制的机制。我们分两条线讲内置方式——你平时该用的一句话搞定手写方式——用 Unity 的底层 API 把流式逻辑自己实现一遍这才是真正看见原理的地方。第一条线内置方式——你实际该用的先说清楚99% 的情况你不需要写任何代码。流式加载是 Unity 内置能力你只要在音频的导入设置里把 Load Type 设成 Streaming 就行引擎在底层自动帮你完成缓冲和续杯。如果你想用代码确认或设置它也可以在编辑期通过导入设置改// 编辑期通过 AssetPostprocessor 自动把 BGM 目录下的音频设为流式publicclassAudioImportRule:AssetPostprocessor{voidOnPreprocessAudio(){AudioImporterimporter(AudioImporter)assetImporter;if(assetPath.Contains(/BGM/)){AudioImporterSampleSettingssettingsimporter.defaultSampleSettings;// 关键加载类型设为 Streaming流式settings.loadTypeAudioClipLoadType.Streaming;// 顺手BGM 用矢量量化压缩体积小settings.compressionFormatAudioCompressionFormat.Vorbis;importer.defaultSampleSettingssettings;}}}运行时播放就是普通的播放不需要任何特殊代码// clip 是一个 Load Type Streaming 的 AudioClipaudioSource.clipbgmClip;audioSource.looptrue;audioSource.Play();// 底层Unity 自动边从磁盘读、边解码、边喂给缓冲区你什么都不用管这就够了。但这样你看不到内部发生了什么——所有续杯逻辑都被引擎藏起来了。所以我们进入第二条线。第二条线手写流式加载把续杯循环暴露出来Unity 提供了一个底层 API能让我们自己接管喂数据这个过程从而亲眼看到流式的运转。这个 API 就是带回调的AudioClip.CreateAudioClip.Create(stringname,intlengthSamples,// 整段音频总共多少采样点intchannels,// 声道数intfrequency,// 采样率boolstream,// ★关键true 流式按需回调false 一次性填满PCMReaderCallbackpcmreadercallback,// ★Unity 需要数据时回调这个函数PCMSetPositionCallback pcmsetpositioncallback// 播放位置跳转时回调);注意那个stream参数和PCMReaderCallback。这就是上一篇讲的续杯机制在代码里的样子当stream trueUnity不会一次性把整段音频要走而是在播放过程中每当缓冲区快空了就自动回调你的PCMReaderCallback函数向你要下一小段数据你在这个回调里从磁盘读一小段、填进它给你的数组——这就是续杯的那一下。Unity 播放器 你的 PCMReaderCallback │ │ │ 缓冲区快空了回调你 ──▶ │ │ │ 从磁盘读下一小段 │ ◀── 你把数据填进数组 ──── │ 填进 data[] │ │ │ 拿到数据继续播放 │ │ │ │ 缓冲区又快空了...再次回调你循环往复看懂这个来回没有这就是边播边读最真实的样子——Unity 负责什么时候该续杯你负责续杯时从哪读、读多少。整首歌永远不会完整地待在内存里内存里只有当前这一小段。完整代码实现一个流式播放器下面是一个可运行的简化实现。为了聚焦原理我用最直白的写法展示从文件流式读取 PCM 数据来驱动播放。usingSystem;usingSystem.IO;usingUnityEngine;/// summary/// 手写流式音频加载器演示 Streaming 的底层续杯机制。/// 从磁盘上的原始 PCM 文件边读边喂给 AudioClip 播放整首歌不进内存。/// /summarypublicclassStreamingAudioPlayer:MonoBehaviour{[SerializeField]privatestringaudioFilePath;// 磁盘上的音频文件路径[SerializeField]privateintchannels2;// 声道数[SerializeField]privateintfrequency44100;// 采样率privateFileStreamfileStream;// 文件流始终指向磁盘数据不整个进内存privateBinaryReaderreader;privatelongdataStartOffset;// PCM 数据在文件里的起始位置privateinttotalSamples;// 总采样点数privatereadonlyobjectstreamLocknewobject();// 回调在音频线程触发需加锁voidStart(){OpenStream();// 创建一个流式 AudioClip// stream true → Unity 会按需回调 OnPCMRead而不是一次性要走全部数据AudioClipstreamingClipAudioClip.Create(name:StreamingBGM,lengthSamples:totalSamples,// 声明总长度但数据并不预先装载channels:channels,frequency:frequency,stream:true,// ★★★ 核心开关pcmreadercallback:OnPCMRead,// ★续杯时调这个pcmsetpositioncallback:OnPCMSetPosition// ★跳转时调这个);AudioSourcesourceGetComponentAudioSource();source.clipstreamingClip;source.looptrue;source.Play();}/// summary/// 打开文件流。注意这里只是打开没有把内容读进内存。/// 文件数据始终躺在磁盘上用到哪读到哪。/// /summaryprivatevoidOpenStream(){fileStreamnewFileStream(audioFilePath,FileMode.Open,FileAccess.Read);readernewBinaryReader(fileStream);// 真实场景这里要解析 WAV/OGG 头部拿到数据偏移和长度dataStartOffset44;// 简化假设是标准 WAV数据从第 44 字节开始longdataBytesfileStream.Length-dataStartOffset;totalSamples(int)(dataBytes/2/channels);// 16-bit 2 字节/采样}/// summary/// ★★★ 这就是续杯的核心 ★★★/// 每当播放缓冲区快空了Unity 就在【音频线程】回调这个方法/// 要求你填满 data 数组。你从磁盘读下一小段填进去即可。/// data.Length 就是 Unity 这一次想要的采样量一小段不是全部。/// /summaryprivatevoidOnPCMRead(float[]data){lock(streamLock){for(inti0;idata.Length;i){// 到文件尾了读不到就填静音循环播放时 SetPosition 会把指针拨回开头if(fileStream.PositionfileStream.Length){data[i]0f;continue;}// 从磁盘读一个 16-bit 采样点转成 Unity 要的 -1.0~1.0 浮点shortrawreader.ReadInt16();data[i]raw/32768f;}}// 这次回调只处理了一小段data.Length 个。// 播放器把这段播掉后会再次回调本方法要下一段 —— 循环往复这就是流式。}/// summary/// 播放位置跳转时比如 loop 回到开头、或手动 seekUnity 回调这里/// 你把文件读取指针拨到对应位置即可。/// /summaryprivatevoidOnPCMSetPosition(intnewPosition){lock(streamLock){// newPosition 是采样点位置换算成字节偏移longbyteOffsetdataStartOffset(long)newPosition*2*channels;fileStream.Seek(byteOffset,SeekOrigin.Begin);}}voidOnDestroy(){// 记得关闭文件流释放句柄reader?.Dispose();fileStream?.Dispose();}}对着代码把原理再走一遍现在把这段代码和上一篇的概念逐条对上你会发现每个概念都能在代码里找到对应上一篇的概念 → 代码里的对应 数据不进内存留在磁盘 → FileStream 只是打开文件 读取指针在磁盘上移动不整体加载 缓冲区 → OnPCMRead 收到的那个 data[] 数组 Unity 内部的播放缓冲 后台持续续杯 → Unity 反复回调 OnPCMRead 每次只要一小段data.Length 用到哪读到哪 → 每次回调里 reader 从磁盘当前位置 往后读一小段 循环播放回到开头 → OnPCMSetPosition 把文件指针拨回起点 内存占用极小 → 内存里只有当前这个 data[] 缓冲 整首歌从不完整驻留最值得盯住的还是OnPCMRead。它每次只填data.Length个采样——这是一小段不是整首歌。填完这段播放器播掉后又来要下一段。整个播放过程就是这个方法被反复调用、每次读一小段的循环。这就是边播边读在代码层面最真实的样子。三个必须注意的关键点手写流式有几个坑必须知道否则会出现杂音、卡顿甚至崩溃① 回调发生在音频线程不是主线程。OnPCMRead是 Unity 的音频系统在独立线程上调用的不是你的游戏主线程。这意味着→ 回调里【不能】调用大部分 Unity API那些只能主线程用 → 访问共享数据如 fileStream必须加锁代码里的 streamLock → 回调里【绝不能】做耗时操作比如大文件寻址、复杂计算 否则跟不上播放速度 → 缓冲区被掏空 → 断音、爆音② 回调必须快这是流式的生命线。上一篇讲过流式的连续性靠补充速度 ≥ 消耗速度。放到代码里就是OnPCMRead每次必须在极短时间内把data[]填满。磁盘读取如果太慢跟不上缓冲区就空了声音就断了。这也是为什么真实的流式实现往往还会在音频线程之外再开一个预读线程提前把磁盘数据读进一个中间环形缓冲让OnPCMRead只做从内存搬运这种快操作。更健壮的结构真实项目常用 磁盘 ──预读线程──▶ 环形缓冲中间层稍大一点──OnPCMRead──▶ Unity 播放 慢操作 只做快速搬运 提前偷偷做好 绝不碰磁盘③ 记得关流。FileStream会占着文件句柄播放结束或对象销毁时务必Dispose否则句柄泄漏。为什么讲这段代码不是让你造轮子是让你看穿轮子必须强调实际项目里你几乎永远该用内置的 Streaming设个 Load Type 就好而不是手写这套。Unity 内置实现比这段示例健壮得多、优化得多。那为什么还要看手写实现因为它让流式加载从一个你只能相信的黑盒变成了一个你能理解的机制你看到了stream true到底改变了什么——从一次性要走全部变成按需反复回调;你看到了缓冲区续杯不是比喻就是OnPCMRead那个反复被调用、每次填一小段的循环;你看到了它的代价从何而来——那个必须极快返回的音频线程回调就是持续 CPU 开销和跟不上就断音的根源;你也就明白了为什么它适合长音频、不适合海量短音效——每条流都要养一个这样的实时回调循环。理解了这层你用内置 Streaming 时心里是有画面的你知道勾下那个选项后引擎内部就在跑一个类似OnPCMRead的循环一小段一小段地从磁盘往缓冲区喂数据。它不再是魔法而是一套你看得懂、说得清、必要时还能自己定制的机制。总结流式加载的底层落到 C# 代码就是三件事 ① AudioClip.Create(..., stream: true, ...) → 告诉 Unity别一次性要数据按需问我要 ② PCMReaderCallbackOnPCMRead → 续杯的核心Unity 反复回调每次要一小段 → 你从磁盘读一小段填进去整首歌从不完整进内存 ③ PCMSetPositionCallbackOnPCMSetPosition → 循环/跳转时把磁盘读取指针拨到对应位置 三个注意回调在音频线程要加锁、不碰主线程 API、 回调必须极快跟不上就断音、记得关闭文件流。从知道 BGM 该用 Streaming到知道它内部靠一个按需回调的续杯循环运转再到能用AudioClip.Create亲手把这个循环写出来——这条路走下来你对流式加载的理解就完整了。这也是我一直想传递的东西很多引擎功能勾一个选项就用上了但如果你只停在会勾这一层遇到问题就只能瞎猜。往下走一层看清它底层是怎么跑的那个功能才真正变成你的。下次你在导入设置里把一首 BGM 设成 Streaming脑子里浮现的就不再是一个陌生的下拉选项而是这段OnPCMRead一小段一小段喂数据的循环——你知道机器在为你做什么也知道它的边界在哪。
返回列表