docs/zh/plans/render-pipeline-threads.md M4: the thread pipeline now
keeps its decode thread ahead of the render thread and the app's
playback window consumes in-process frames.
- Render queue: priority-ordered by JobSchedule.priority (Seek >
Playback > Background, FIFO within a class), so interactive frames
jump playback exports/autocache. Seek posts may over-admit the bound:
priority only reorders queued jobs, so a full queue of background work
must not park the UI thread until an export frame finishes.
- Decode queue: rendezvous Requests are served ahead of queued
Prefetches (a frame the renderer needs never waits behind speculative
decodes); Sync barriers stay FIFO. The queue is a bounded
Mutex+Condvar structure, preserving the request backpressure and the
wait_idle contract.
- Playback read-ahead: a Playback job's footage decode requests are
derived from its montage/footage spec on post (same media time, size
and force_format.unwrap_or(F32) as the eval) and queued immediately,
so frame N+1 decodes while frame N runs its GPU passes.
- App window: PreviewWindow slots are generalized to
PreviewSlot::{Shm, Video}; the pipeline's in-process TicketPayload is
cached and consumed by cpu_frame exactly like a worker slot.
PipelineBackend::preview_window_capacity reports the render-queue
headroom, so playback posts are capped to what the queue can take;
cancel_preview_frame drops queued frames the playhead has passed,
matched on the full (sequence, frame, version) key so one monitor's
window never drops the other sequence's same-numbered frame.
- Tests: decode-queue preemption/FIFO, render-queue ordering, request
derivation, and deterministic end-to-end M4 tests: a prefetch that
must be reused by the render request (LRU hit, single decode — the
read-ahead claim is falsifiable), a parked-render-thread priority test
where a full queue of background work still lets a Seek over-admit and
run first, and a sequence-aware cancel test. The playback prefetch
smoke asserts prefetches == distinct decodes == frames; it does not
claim zero heap copies (Frame.data is deep-copied at the eval-cache
and service-LRU boundaries today).
- bench_playback gains a pipeline mode with CPU (self+children) and
first-frame latency; both backends now produce F32 frames so the
comparison is like-for-like. The §3.4 backfill records the numbers:
at the proxy size the pipeline is faster with a lower first frame; at
1080p peak throughput is below the multi-worker pool, but that is an
artifact of the decode still being CPU software (M5), not a case for
pooling decode threads — GPU decode is a single device/queue and the
zero-copy import shares one GPU memory pool, so the single decode
thread stays the target shape.
39 KiB
渲染管线改造:单解码线程 + 单渲染线程 + 队列流水线 + GPU 零拷贝
面向实现者的任务书(2026-09-11)。本文只描述方案与工作项,不含已执行的代码修改。
用户要求(原文要点):
- 解码一个单独的线程执行——线程,不是进程
- 渲染一个单独的线程执行——同样线程
- 主进程负责显示上屏
- OpenFX 隔离在单独的进程,只有一个进程,崩了重新拉起来
- 使用队列提交渲染任务
- 流水线式作业:解码完成立刻进渲染队列,渲染完成进上屏队列
- GPU 内存零拷贝——内置效果全是 GPU 效果,除非遇见 CPU OpenFX 特效,不回读; 哪怕为此要写平台特定代码(分支实现 CUDA/Vulkan/OpenGL/DirectX 通用)
- 参考原版 Olive(github.com/olive-editor/olive)的渲染管线
- 参考原版 Olive 重写 resolve:match + Job 枚举(crates/oak-node/src/jobs.rs) 单次循环完成,而不是四次扫描
补充硬性要求(2026-09-11,用户追加): 解码必须尽可能 GPU,并与渲染共用同一片 GPU 内存——解码这一步也是零拷贝; CPU 解码后上传只作为 fallback。解码实现优先 FFmpeg(硬解),次选手写 GPU 解码代码。 本文 §2.7/§3.1/§3.4/§3.6/§4/§6 均按此修订。
补充硬性要求二(2026-09-11,用户追加): Job 图做成货真价实的图存储结构(不是线性表,也不是二维数组),允许非 全连通;每个项目图固定一对虚拟输入节点 / 虚拟输出节点(默认相连、不可 删除、要在节点编辑器里显示出来);resolve 从输入节点开始广度优先搜索, 逐个 match 处理 Job,直到全部分支汇聚于输出节点。见 §3.8。
配套调研:上游 Olive 已浅克隆到
.cache/olive-upstream(gitignore 内),本文 引用其文件时写作upstream/app/...。
1. 背景:为什么改
当前渲染在多进程里跑(M15 进程隔离战役引入):主进程经 NDJSON+shm 向 N 个
oak-worker 子进程派发 ticket,每个子进程单线程同步渲染。这个模型的前提(GPU
工作可以靠多进程并行、进程隔离保护一切)对内置效果全 GPU 化之后的 Oak 不再
成立:
- GPU 是单队列。wgpu 设备只有一个提交队列,N 个进程各自建 GPU 上下文并不能 并行 GPU 工作,反而为每帧付出 GPU→CPU 回读 + shm 搬运 + 上屏前 CPU→GPU 重传 的三次额外拷贝。
- 每个 worker 重复持有解码器会话、OCIO 处理器缓存、着色器缓存与 GPU 上下文, 内存与启动成本都按进程数翻倍。
- 进程间只能传 CPU 像素(shm 槽),GPU 纹理无法跨进程直接流动(除非上平台句柄 外联,成本高于收益)。
线程模型则完全够用:崩溃隔离只需要覆盖真正会崩的东西——第三方 OpenFX 插件, 而插件恰好本就是 CPU 边界(绝大多数 OFX 插件读写 CPU 图像),天然适合单独进程。 内置 GPU 效果不会崩出进程外(wgpu 的错误模型是校验+设备丢失,不是段错误), 不需要进程隔离。
2. 现状(关键事实,均带坐标)
2.1 进程池渲染后端
crates/oak-render/src/procpool.rs:1-40:TicketArena → ProcessDispatcher → N 个 WorkerHandle,stdio NDJSON 控制面 + shm FrameSlotPool 数据面;worker 崩溃 后已认领帧重新入队并重生进程(有界重启)。crates/oak-worker/src/worker.rs:子进程是单线程 NDJSON 循环,handle_render_batch_stream在循环线程上同步渲染整批 ticket——worker 内部 没有任何渲染线程,并行只来自进程池。crates/oak-render/src/scheduler.rs:预览帧调度(交错分片认领);自动缓存由autocacher.rs驱动。
2.2 帧传输是 CPU/shm,显示前再上传 GPU
crates/oak-app/src/oakui/renderops.rs:550-555:RenderedFrame只有CpuF32 { .. }与Shm(ShmFrameRef)两种——无论渲染在不在 GPU 上完成, 到上屏路径的都是 CPU 像素,显示时再经 staging 上传(M15 S2 已确认播放路径 主堆拷贝为 0,但 GPU↔CPU 的两次搬运仍在)。crates/oak-core/src/texture.rs:183-202:Texture已有Gpu { token, backend, ctx, .. }/Cpu(Frame)双变体,GPU 纹理自持Arc<dyn GpuContextLike>—— 零拷贝改造的数据类型基础已经存在,缺的是"全链路不把 GPU 纹理下载回来" 的管线。
2.3 解码在 worker 内随叫随做,硬解帧全部回读 CPU,无流水线
crates/oak-render/src/eval.rs:988-990:解码器会话本就进程级共享 (按 (filename, stream) 键控、内部互斥),但解码动作发生在每个 worker 的 ticket 处理里:一帧的"解码→渲染→回读"是串行的,帧与帧之间没有重叠。render_footage_frame(eval.rs,FootageJob 的落点)同步解码一帧并缩放到 目标尺寸。- 硬件解码已经存在,但硬件帧全部下载回 CPU(详见 §2.7)——GPU 解码 这块砖已经有了,缺的是"不把砖搬回 CPU"的下半截。
2.4 resolve 四次扫描表
crates/oak-render/src/eval.rs:919-930:RenderEvalHooks::resolve依次调用resolve_plugin_jobs→resolve_footage_jobs→resolve_shader_jobs→resolve_color_transform_jobs(eval.rs:406/480/520/561)。每个函数各自for (_, value, _) in table.rows_mut()全表扫描,逐行用handle::get_checked::<T>探测载荷类型——一张表被遍历 4 遍,每行最多被 错误类型探测 3 次。crates/oak-node/src/jobs.rs:35-40:Job枚举已存在(Footage/Shader/Plugin/ ColorTransform),但节点并没有把 Job 枚举塞进表里——表里塞的是裸载荷 box,靠 resolve 端逐类型猜测。注释自述"the graph-shaped job model lands in v0.6"(jobs.rs:31-33)。CacheJob缺失:process_video_cache_job(eval.rs:391-399)直接返回 "deferred" 错误,磁盘帧缓存加载没有 Job 表达。
2.5 OpenFX 在每个 worker 进程里渲染
crates/oak-worker/src/worker.rs:100-129:OFX 渲染发生在 worker 进程内 (注释自述"crash isolation"),进度经plugin_progressNDJSON 事件回报, 取消经主进程广播plugin_cancel。N 个 worker = N 份插件实例宿主,插件崩溃 杀掉的是某一个 worker(procpool 重生),语义正确但宿主数量与位置都不是 设计出来的,是进程池的副产品。- 插件失败时回退紫帧(eval.rs:386
purple_frame)的约定已存在。
2.6 原版 Olive 的参考点(upstream/app/...)
render/rendermanager.h:38-63:RenderThread(QThread)内部一个std::list<RenderTicketPtr> queue_+QWaitCondition;RenderManager 持有 一个 video 线程、一个 audio 线程、波形线程池、一个 dry-run 线程 (rendermanager.cpp:123-150 按类型派发)。render/renderprocessor.cpp:137Run():一个 ticket 的处理全流程 (取参数 → GenerateTexture → 隔行处理 → 按需 GenerateFrame/写缓存 → Finish)。node/traverser.cpp:351ResolveJobs:单函数 job 分发——先递归 resolve 子 job(job 的参数里嵌的 job),再按类型(CacheJob/ ColorTransformJob/ShaderJob/GenerateJob/FootageJob)调对应 Process*, 结果写回 value。这就是用户要求的"match + Job 枚举、单次循环"的范式。render/job/*.h:AcceleratedJob 基类 + 各 Job 的字段集(与我们 jobs.rs 的载荷一一对应,可对照补全)。
2.7 硬解已接通但逐帧下载(解码零拷贝的现状缺口)
crates/oak-codec/src/hwdecode.rs:17-42:FFmpeg 8 的 hwaccel 模型已落地 ——建平台硬件设备上下文(av_hwdevice_ctx_create)挂到软解码器上, VideoToolbox(macOS)/ CUDA(NVDEC)→VAAPI(Linux)/ D3D11VA→CUDA (Windows)按候选顺序尝试,软解兜底;OAK_HWACCEL=0与配置项HardwareDecoding双开关;HW_TRANSFERS计数器证明 hwaccel 真生效。- 但每帧硬件表面都用
av_hwframe_transfer_data下载到系统内存再走 swscale(hwdecode.rs:41-42 自述)——这就是"解码零拷贝"要消灭的那次 下载。项目 FFmpeg 构建已带齐平台硬解 (--enable-nvdec/vaapi/vdpau/d3d11va/dxva2/videotoolbox, tooling/ffmpeg/build-ffmpeg.sh)。 - 上游 Olive 没有硬解(
.cache/olive-upstream/app/codec/decoder.cpp全文无 hw_device/hwaccel 引用,纯软解+上传)——本计划的 GPU 解码超出 Olive 的范围,参考对象改为 FFmpeg hwaccel API 与本仓库 hwdecode.rs 已有 的设备模型;FFmpeg 覆盖不到的路径才手写 GPU 解码(Vulkan Video / 直接 NVDEC 绑定,见 §3.6)。
3. 目标架构
┌──────────────────────────── 主进程 ────────────────────────────┐
│ │
│ UI 线程(gpui) 调度层(manager/scheduler/autocacher) │
│ │ 消费上屏队列 │ 把播放/导出/缓存请求编成 ticket │
│ │ │ 投到 decode/render 队列 │
│ ▼ ▼ │
│ 上屏队列 ◄── present ── 渲染线程(唯一) ── render 队列 ◄──┐ │
│ (GPU 纹理) │ 持有唯一 GpuContext │ │
│ │ 图求值 + 全部 GPU pass │ │
│ ▼ │ │
│ decode 队列 ◄── 解码线程(唯一)─────────┘ │
│ │ 持有全部解码器会话+硬解设备 │
│ │ 硬解→GPU 导入(零拷贝,§3.6) │
│ │ 软解→staging 上传(仅 fallback) │
│ │
│ ┌─── 仅当链上出现 CPU OpenFX 特效时 ───┐ │
│ ▼ │ │
│ OFX 主机进程(唯一,oak-ofx-host) │ │
│ NDJSON 控制面 + shm/(后续)GPU 句柄数据面 │ │
│ 崩溃→有界重生+在途 job 重投 │ │
└────────────────────────────────────────────────┘
3.1 三个执行角色
- 解码线程(唯一):独占全部解码器会话(eval.rs:988 的进程级会话表
顺理成章地归它所有——会话本就互斥,集中后连互斥都可以去掉)。从
decode 队列取 FootageJob 解码,产出直接落在 GPU 上、与渲染共用同一
片 GPU 内存:硬解帧(NV12/P010 等 YUV 表面)经平台互操作原样导入为
GPU 纹理(§3.6,零拷贝),不执行
av_hwframe_transfer_data; CPU 解码 + staging 上传只作为硬解不可用时的 fallback(沿用OAK_HWACCEL/HardwareDecoding开关,hwdecode.rs:56/102)。 硬解表面到工作色彩空间 RGBA 的转换不再走 CPU swscale,而是渲染线程 上的一个内置 "YUV→RGB" GPU pass(色度上采样 + 色彩矩阵着色器, 与 §3.5 的全 GPU 中间纹理一致)。连续播放时由调度层预取(§3.4)。 - 渲染线程(唯一):独占进程唯一
GpuContext(GpuContext::shared()之外的第二条获取路径收编到这里;texture.rs:178-181 的 Arc 自持模型 不变)。从 render 队列取"图快照 + 时刻"作业,跑图求值与全部 GPU pass,产出Texture::Gpu投到上屏队列。 - 主进程(上屏):UI 线程从上屏队列取 GPU 纹理直接显示(§3.5 的上屏 互操作)。导出/磁盘缓存等 CPU 消费者在队列出口处显式回读——回读只 发生在这些边界。
3.2 OpenFX 隔离进程(唯一)
- 新建
oak-ofx-host可执行(或 oak-worker 的--ofx-host模式):进程内 加载全部 OFX 插件实例,渲染线程经 IPC 把 PluginJob 发给它,输入纹理 回读成 CPU 帧经 shm 传入,输出经 shm 传回后再上传 GPU。这是全链路唯一 的计划内回读点(仅当链上真有 CPU OFX 插件)。 - 崩溃处理复用 procpool 已验证的机制(procpool.rs:1-40:EOF/非零退出→ 在途帧重新入队、进程重生、有界重启),但池大小恒为 1;重复失败回退 紫帧(沿用 eval.rs:386 约定)。
plugin_progress/plugin_cancel协议(worker.rs:100-129)原样搬运。- GPU 型 OFX 插件(OpenGL/CUDA 上下文的)本期不支持直通,按 CPU 插件处理 (文档明示;真有需要时按 §3.6 的平台分支再开 GPU 句柄通道)。
M3 落地回填(2026-09-12):
- 采用
oak-worker --ofx-host模式(新增oak-worker/src/ofx_host.rs), 不新增可执行目标,省掉打包/路径解析的分支。宿主启动即install_render_executor+Host::cache.scan(),按插件标识符 (shared_plugin_instance,跨进程稳定)解析宿主本地实例。- 客户端在
oak-render/src/ofxhost.rs:RenderManager在 Pipeline 后端创建并安装进程级客户端(首个个 PluginJob 才 spawn,空闲不占进程),eval::process_plugin_job优先走它,失败/缺席再回退进程内 executor(进程池 后端在 M4 之前仍按现状在每个 worker 内渲染,见 §5 回退策略)。 数据面为输入/输出两个FrameSlotPool(handshake 的input_*字段首次启用): 命名 clip + src 各写一个输入槽,插件输出写输出槽;槽容量/数量按 job 需求 自动扩容(重启一次宿主,无在途任务时安全)。- 崩溃:reader 线程 EOF 即判死,
submit在同一调用内重生宿主并重投同一 job(输入帧只回读一次,重投不重复回读);连续HOST_MAX_FAILURES=3次崩溃后熔断,process_plugin_job落紫帧 (plugin_job_host_failure_yields_purple_frame直接断言)。- 进度/取消:宿主 reporter 即时 flush
plugin_progress(不再是 worker 的 批末缓冲,进度条可实时更新);宿主用独立 stdin 线程,plugin_cancel在该线程内直接置黏性 flag(渲染中也能生效),下次progressStart复位;request_plugin_cancel_all同时广播给 worker 池与宿主。取消的生效粒度 取决于插件是否回调 progressUpdate(与现状契约一致)。- 验收测试(
oak-worker/tests/ofx_host.rs,真实宿主 + 内置测试插件): 渲染与进度转发;--ofx-crash-once崩溃后重投成功;--ofx-crash-always连续三次崩溃后熔断(紫帧由 eval 单测覆盖);慢速测试插件 (org.oak.test-plugin.slow,progressUpdate x20ms)上取消在途渲染; 并发 submit 由客户端互斥串行化。
3.3 队列与 ticket
- 对上层(oak-app/oak-cli)ticket API 不变:RenderManager 仍是唯一入口, 完成仍走 exactly-once 的 TicketPayload。
- 对内,ticket 变成队列项:三条 SPSC/MPSC 环(decode/render/present),
复用
crates/oak-render/src/ipc.rs已有的无锁环实现;取消仍走cancelatom(Olive 的 CancelAtom 对应物)。 - 优先级:交互(seek/单帧刷新)> 播放预取 > 导出 > 自动缓存。调度层 (scheduler.rs)从"分片给 N 个进程"改为"按优先级与依赖关系投队列"。
3.4 流水线作业
- 调度层维护播放方向的预取窗口(autocacher 已有近似逻辑):帧 N 在渲染 线程上跑时,帧 N+1..N+k 的 FootageJob 已在解码线程上解码。
- 依赖表达:FootageJob 先进 decode 队列;渲染线程遇到"输入纹理是未决 FootageJob"时不是同步解码,而是把该渲染作业挂起到依赖完成(依赖计数 到位后自动入 render 队列)——Olive 是同步解码(renderprocessor.cpp:325 ProcessVideoFootage 在渲染线程内解码),我们按用户要求做真流水线。
- 硬解帧的流水走向:解码线程产出的硬件表面(YUV)以平台句柄形态
(DMA-BUF fd / D3D11 纹理 / CVPixelBuffer / CUDA 数组)经 §3.6 的互操作
导入为 GPU 纹理后投 render 队列;帧的生命周期由"持有 AVHWFramesContext
引用的 GPU 纹理包装"管理,渲染线程消费完(YUV→RGB pass 采样结束)即
释放回硬解帧池。全路径无
av_hwframe_transfer_data。 - 背压:三条队列都有界;上屏消费慢(暂停、窗口最小化)时 render 队列满 → 解码暂停预取;导出时上屏队列直通导出消费者,不存在"没人收"的积压。
M4 落地回填(2026-09-15):
渲染队列按
JobSchedule.priority排序(Seek > Playback > Background, 同类 FIFO);解码队列把 rendezvousRequest排在Prefetch之前 (渲染要帧绝不排在推测解码后面),Sync屏障保持 FIFO。播放预取:Pipeline 收到 Playback job 时立即按 montage/footage 推导
DecodeRequest(媒体时间/尺寸/F32 与 eval 完全一致,footage 路径取force_format.unwrap_or(F32)),投给解码线程,于是帧 N 在渲染线程跑 GPU pass 时解码线程已在解帧 N+1。pipeline_prefetch_is_the_frame_the_render_request_uses用"停在解码前"的确定性时序断言 LRU 命中且只解一次;pipeline_playback_prefetches_ahead_of_the_render只证明"每个 post 恰好投递一次预取、每帧只解一次"。上屏窗口:
PreviewWindow的槽位泛化为PreviewSlot::{Shm, Video}, 线程管线的TicketPayload::Video直接入窗、由cpu_frame消费;PipelineBackend::preview_window_capacity返回渲染队列余量,播放提交 被限制在余量内不阻塞 UI;Seek 提交允许超额插队(队列满时也立即 返回,插到 Background/Playback 之前),因为优先级排序只对已入队的 job 生效——若 Seek 被挡在门外,UI 会等一个导出/缓存帧跑完。cancel_preview_frame丢弃已过 playhead 的排队帧,按(sequence, frame, version)全键匹配 (两个监视器可同帧号同版本,只按帧匹配会误杀另一序列)。基准对比(本机 release,
oak-render/examples/bench_playback, 1080p/25fps MPEG-2 源、240 帧@480p(853×480) / 128 帧@1080p;两个后端 统一输出 F32 帧、同尺寸,否则进程池的 BGRA8 槽位与管线的 in-process F32 帧字节量/路径不同,数字不可比):
用例 fps 首帧延迟 CPU(user+sys) processes 480p(17 worker) 48.3 1416 ms 23.6 s pipeline 480p 78.6 127 ms 3.0 s processes 1080p(4 worker) 49.2 396 ms 12.0 s pipeline 1080p 28.5 161 ms 4.5 s 结论:线程管线在代理尺寸下全面胜出,首帧延迟低一个数量级;1080p 满幅 的峰值吞吐低于多 worker 进程池(单解码线程 vs 4 个并行 worker),但 CPU 低约 2.7×、首帧快约 2.5×,且 28 fps 足够覆盖 24/25 fps 实时播放。 按 M4 验收口径记录:CPU 不升、首帧不劣化成立;"fps 不低于"在 1080p 峰值吞吐上不成立(代理播放成立)。
"main-heap frame copies" 只统计进程池 shm 路径(管线不经过它),两边 的 0 都不构成"管线无堆拷贝"的证据——in-process 帧在 eval 缓存与 service LRU 边界仍有
Frame.data深拷贝(M5 收窄),文档与测试不得 再宣称管线零堆拷贝。根因是这一刻解码仍是 CPU 软解(M5 才上硬解):进程池的 4 个 worker 各自跑一个软解器并行解帧,线程管线按 §3.1 只有一条解码线程。不要 为此把解码线程池化:GPU 硬解单元(NVDEC/VAAPI/VideoToolbox)是单设备 单提交队列(§1 的出发点),§3.6 的零拷贝导入还要求"解码表面与渲染共 用同一片 GPU 内存",多解码线程只会争同一队列与显存池。M5 落地后单 解码线程就是正确形态。提前提升软解吞吐的正确方向是让 FFmpeg 解码器 自身多线程(frame/slice threading),而不是并列多个解码线程——本期 不立项。
3.5 GPU 零拷贝与上屏
- 内置效果全 GPU:图求值产生的中间纹理全部是
Texture::Gpu,整个 resolve 过程不分配 CPU 帧;generate_frame(eval.rs:941)这类 CPU 帧生产点改为 GPU 清屏。 - 上屏:gpui 自身用 wgpu 渲染。首选让预览画布与渲染线程共用同一 wgpu device(gpui 暴露 device/queue 的途径需调研 gpui 侧 API;若不可行, 次选方案是渲染线程把帧 blit 进一块与画布共享的纹理,或退一步用现有的 staging 上传但只此一处)。这一条的落地细节作为 M2 的第一个技术攻关点, 攻关结论回填本小节。
- CPU 边界的显式回读点只有三处:CPU OFX 插件(§3.2)、导出编码器输入、 磁盘帧缓存写入(FrameHashCache::SaveCacheFrame 对应物)。
M2 攻关结论(2026-09-11 回填):共享 device 路线可行且已落地,前提是 引擎与 gpui 统一到同一个 wgpu 大版本。攻关发现:
- gpui 的
Window::gpu_context()(Linux/FreeBSD)确实暴露窗口的(Arc<wgpu::Device>, Arc<wgpu::Queue>),但 vendored gpui_wgpu 用的是 wgpu 29,而引擎此前是 wgpu 25——两个大版本的wgpu::Texture是不同类型,纹理无法跨越。M2 把oak-core/oak-render升到 wgpu 29 + naga 29(backend.rs的 8 处破坏性 API 改动;其余代码 只经GpuContext),版本鸿沟消除。GpuContext::adopt(device, queue, kind)采用宿主 device;register_context(oak-app/src/oakui/gpu.rs)在窗口建立时调用install_shared把它装进进程级 shared 槽,渲染线程因此在预览器同一 device 上出帧。GpuContext::texture_handle把引擎纹理的Arc<wgpu::Texture>交给 gpui 的SurfaceSource::Texture,上屏零拷贝。- 色彩管理必须留在链上:GPU 路径不能跳过 output node 与显示器 ICC。 做法是把「工作空间 → 输出规格(
colormath::working_to_display_target) → 显示器 ICC(displaycolor::apply_f32_rgba)」在 CPU 上用原有精确 实现烘焙成 65³ 3D LUT(oak-core::lut::Lut3d,域[-0.25, 4]³),经GpuContext::set_display_lut上传为 GPU 3D 纹理, 由present_texture的 WGSL pass 做手工三线性插值(不依赖FLOAT32_FILTERABLE)。设置/显示器/ICC 变化(displaycolor::generation或项目色彩设置)时重建。CPU 路径的apply_f32_rgba一行未动,GPU 与 CPU 逐点一致(测试对拍,f16 输出量化内)。同一套 LUT 机制也用于图内ColorTransformJob:process_color_transform_job对 GPU 纹理把 OCIO processor 经 CPU 参考烘焙成 3D LUT,用GpuContext::apply_color_lut在 GPU 上应用(color_transform_lut按 processor cache id 缓存),不再 pass-through;只有无法烘焙时才走一次显式回读。- 平台边界:macOS/Windows 的 gpui 暂不暴露 device(macOS 走
oak_bridgeIOSurface 的未来路线,见 acescg 计划 P2),此时present_gpu_frame返回None,to_display走单点显式回读 (唯一一次 download,之后仍 CPU 上传到 gpui)。Linux 上若引擎 device 不是 adopted(例如进程池 worker 的独立 device),同样回退这条 单点路径。CPU OFX、导出编码器、磁盘缓存三处边界显式回读不变。install_shared对「引擎已创建但尚未创建任何 GPU 资源」的上下文 允许被 UI 设备替换(时序守卫:开窗前任何shared()触碰都不会 静默丢掉零拷贝上屏),用过的上下文拒绝替换并记一条错误日志。- 进程池后端(默认)仍在 worker 内完成图求值后显式回读成 shm 槽 (oak-worker/worker.rs 的 graph 分支),这是进程模型的必然;M4 通过 验收前进程池仍是默认,线程管线(
OAK_PIPELINE=threads)才走上述 零拷贝上屏。- 已知取舍:GPU 帧没有
Frame::timestamp(GPU 路径不需要);scopes/ 取色器的 CPU 兜底图是 1×1 占位(需要时可作为显式回读点按需填充); 上屏每帧新建一张Rgba16Float目标纹理(与 gpui 的取帧生命周期一致, 后续可做纹理环)。
3.6 平台互操作分支(解码零拷贝与上屏,用户硬性要求)
解码必须尽可能 GPU,并与渲染共用同一片 GPU 内存;CPU 解码后上传只作为
fallback。 解码上传与上屏共用一层 gpuinteop 抽象,按后端分支实现。
解码实现优先 FFmpeg 硬解(hwdecode.rs 的设备模型 + 下表的导入路径),
FFmpeg 覆盖不到的才手写 GPU 解码(Vulkan Video 扩展 / 直接 NVDEC
绑定——仅当某种编码或平台组合 FFmpeg 没有 hwaccel 时立项,不提前写):
| 平台/后端 | 硬解(FFmpeg hwaccel)→ GPU 导入(零拷贝) | fallback | 上屏/外部共享 |
|---|---|---|---|
| Linux + NVIDIA | NVDEC(CUDA 设备)→ cuArray → CUDA-Vulkan 外部内存互操作(cuImportExternalMemory 系)导入 wgpu 纹理 |
软解→staging 上传 | wgpu 同源纹理直通 |
| Linux + AMD/Intel | VAAPI → VASurface → DMA-BUF fd → Vulkan 外部内存(VK_EXT_external_memory_dma_buf + VK_EXT_image_drm_format_modifier) |
软解→staging 上传 | 同源直通;跨进程必要时 DMA-BUF fd |
| Windows | D3D11VA → D3D11 纹理 → DXGI shared HANDLE(NT 句柄)导入 wgpu(DX12/Vulkan 后端均可);NVDEC 候选经 CUDA 互操作 | 软解→staging 上传 | D3D11 shared HANDLE / DXGI |
| macOS | VideoToolbox → CVPixelBuffer → IOSurface → Metal 纹理(wgpu Metal 后端直接包裹) | 软解→staging 上传 | IOSurface 共享 |
| OpenGL(遗留/调试用) | 不做硬解导入 | 软解→glTexSubImage2D | 仅调试后端 |
要点:
- 硬解表面通常是 YUV(NV12/P010),导入后不是 RGB——YUV→RGB(色度 上采样 + BT.601/709/2020 矩阵 + 全/窄范围)是渲染线程上的内置 GPU pass(着色器),替代今天的 CPU swscale;HDR 元数据随纹理流转。
- 导入失败(驱动缺 modifier、句柄类型不支持等)按帧回退到
av_hwframe_transfer_data+ staging 上传,单帧失败不污染后续帧。 - 实施顺序:staging fallback 先行(正确性基线,任何平台都可跑),然后按 Linux(NVDEC/VAAPI) → Windows(D3D11VA) → macOS(VideoToolbox) 的顺序 落地零拷贝导入,每行一个独立 PR、独立开关、可单独回退。
3.7 resolve 重写(M0a,独立先行)
对照 upstream node/traverser.cpp:351 ResolveJobs:
- 节点往表里塞
Job枚举,不再塞裸载荷:jobs.rs 的Job补全为Footage / Shader / Plugin / ColorTransform / Cache(CacheJob 载荷: 缓存路径 + 时刻 + 回退纹理,对应 eval.rs:391-399 目前 deferred 的process_video_cache_job;Generate/Sample 暂不立 Job——生成器走 ShaderJob 变体、音频走 SampleBuffer 直通,评审时如需再补)。 RenderEvalHooks::resolve改为单次循环:逐行取载荷→一次match分发到process_footage_job / process_shader_job / process_plugin_job / process_color_transform_job / process_cache_job; 每个 process_* 在消耗自己的子值前递归 resolve 子 job(Olive 的for subval: ResolveJobs(subval)范式,traverser.cpp:362-366)—— 例如 ShaderJob 的 effect 输入里嵌着 FootageJob 时先解码再跑 pass。- 类型探测从"每行最多 4 次 get_checked 试错"降为"一次枚举判别"。
- 现有四个
resolve_*_jobs函数的逐类型逻辑原样搬进对应 process_*, 行为不变(本步是纯结构改造,现有测试全部应无修改通过;新增 CacheJob 的磁盘加载测试)。
3.8 Job 图:虚拟输入/输出节点与广度优先求值(用户硬性要求)
节点图的求值不再把"每个节点往一张线性表里 push 值、resolve 扫表找活干" 当作模型(jobs.rs:31-33 自述的临时形态、§2.4 的四次扫描即其症状),而是 Job 图即图:
- 图存储是货真价实的邻接结构:求值以
oak_node::graph::Graph的 节点/连接为骨架,节点把Job枚举(§3.7-1 补全后的)挂在自己的输出 上;没有"全图拍平成表"的中间形态,也没有二维数组。允许非全连通: 与输出无关的分支不参与成帧(下述活集规则)。 - 每个项目图固定一对虚拟节点:
GraphInput(虚拟输入节点):BFS 的唯一起点。它自身不产生 像素——它是"图的数据入口"语义(单帧单 clip 时其后挂素材/生成器; 序列合成时由轨道合成结构向其馈入)。只有输出端口。GraphOutput(虚拟输出节点):所有终末分支的汇聚点,它的值 就是这一帧。只有输入端口。- 两节点默认相连、不可删除、不可复制(图模型层强约束:新建图
自带这对节点;
remove_node/duplicate对它们拒绝;序列化把它们 作为图的固定端点写入/读出)。 - 要在节点编辑器里显示出来(
crates/oak-app/src/panels/node_editor.rs): 与普通节点同等的渲染与连线交互,但禁删、禁复制、禁改名;样式上 与真节点区分(固定标题/图标),连线规则校验(输入节点不接受入线、 输出节点不接受出线)。
- resolve = 从输入节点出发的广度优先搜索:每个项目/序列的求值以
GraphInput为根做 BFS,出队一个节点就用一次match(§3.7-2 的 五个 process_*)处理它挂出的 Job,直到全部分支汇聚于GraphOutput为止。遍历规则:- 多输入汇合:入度到零才出队(Kahn 形态的 BFS)——一个节点的 全部输入都 resolve 完毕它才进入处理队列;这保证汇合节点拿到的 每一路输入都是成品纹理。
- 多输出分叉:一个节点的输出可以喂多个后继,沿邻接边自然扇出; 后继各自按自己的入度等待。
- 顺序语义:出队序即 Job 处理序,且对同一图是确定性的(邻接按 连接建立序迭代)——用户明确要求"节点被应用的先后顺序可能影响 最终画面",该顺序由图结构显式表达,而非由表扫描次序隐含。
- 环:visited 集防死循环;发现回边时报错并断开该分支(图模型 现有约束本就不鼓励环,此处把行为写成明文)。
- 活集:正向(从 GraphInput)可达 ∩ 反向(从 GraphOutput)可达 的节点才是活集;不可达节点从不入队(非全连通图的天然剪枝), 正向可达但到不了输出的分支是可选的二次剪枝(先求正确,再求省)。
- 与 resolve 重写的合流:§3.7 的单循环 match 是本 BFS 的"出队即 处理"循环体;M0 分两步走——先在现有线性表上完成枚举化与单循环 (M0a,纯结构改造、独立可验收),再把表升级为图、引入虚拟端点与 BFS(M0b,见 §4)。上游 Olive 没有虚拟端点概念(其遍历从 viewer 输出节点倒推),这一对端点是 Oak 自己的设计,语义对齐用户的 "从输入开始、汇聚于输出"。
4. 里程碑
| 里程碑 | 内容 | 验收 |
|---|---|---|
| M0a Job 枚举化 + 单循环 resolve | §3.7 全量:Job 枚举补全(含 CacheJob)并挂进输出表、resolve 单循环 match、子 job 递归 resolve | 全 workspace 测试绿;新增 CacheJob 磁盘缓存往返测试;resolve_*_jobs 四函数删除 |
| M0b Job 图 + 虚拟端点 + BFS | §3.8 全量:图固定 GraphInput/GraphOutput 虚拟节点(默认相连、禁删禁复制、序列化往返)、节点编辑器显示两节点、resolve 改为从输入节点的 Kahn 形态 BFS | 新增测试:多输入汇合等齐全部输入、多输出分叉各自成帧、非全连通图不可达节点不执行、环报错断支、虚拟节点删除/复制被拒、序列化往返后端点仍在;节点编辑器 UI 测试(端点可见、入线/出线规则);既有测试全绿 |
| M1 线程管线骨架 | 解码/渲染/上屏三线程+三队列进 oak-render(pipeline 模块);RenderManager 增加线程后端,进程池后端保留,OAK_PIPELINE=processes 可回退 |
同一套渲染测试在两个后端下都绿(测试矩阵化);播放/seek/导出 smoke 等价 |
| M2 GPU 零拷贝 | 图内全程 Texture::Gpu(合成/转场/调整层不再逐帧回读);wgpu 29 统一 + 采用 gpui device(§3.5 攻关已回填);GPU 色彩管理(工作空间→输出规格→显示器 ICC 烘焙 3D LUT,GPU 执行);导出/缓存/OFX 三处边界显式回读;内置 YUV→RGB GPU pass(M5 解码导入的依赖项,解码接线随 M5) |
图播放路径 GPU→CPU 回读为 0(oak_core::backend::gpu_transfer_counters 计数断言,M1 帧缓存范式);RenderedFrame::Gpu + to_display 上屏在 adopted device 上零拷贝(app 测试);YUV→RGB pass 与 colormath::yuv444p16_to_rgb_f32 对拍;全 workspace 测试绿 |
| M3 OFX 独立进程 | oak-worker --ofx-host 单进程宿主 + oak-render/ofxhost 客户端(Pipeline 后端安装,首 job 惰性 spawn);PluginJob 经 NDJSON + 输入/输出 shm 槽;崩溃重生+在途 job 重投+三次熔断紫帧;plugin_progress/plugin_cancel 搬运(宿主即时 flush) |
植入确定性崩溃钩子:--ofx-crash-once 杀掉宿主 → 在途 job 重投成功;--ofx-crash-always 连续三次崩溃 → 客户端熔断、eval 紫帧;进度事件(含 0.5/1.0)到达 app 回调;取消 flag 语义单测(oak-worker/tests/ofx_host.rs + eval/ofx_host 单测) |
| M4 流水线预取 | 渲染队列优先级(Seek>Playback>Background);Playback job 投递即预取 Footage 解码(解码队列 Request 优先于 Prefetch);PreviewSlot 泛化让线程管线入窗;队列余量作播放背压 + Seek 超额插队保证 UI 不停摆 |
确定性单测:停在解码前的时序下预取命中 LRU 且只解一次(可证伪"读前于渲染");Seek 在队列满时超额插队并按序优先执行;cancel_preview_frame 按 (sequence,frame,version) 全键匹配;播放预取 smoke(prefetches==decodes==帧数);bench_playback 双后端同格式(F32)对比留档(§3.4 回填:CPU 与首帧不劣化成立;1080p 峰值吞吐低于多 worker 池,代理尺寸胜出) |
| M5 GPU 解码零拷贝 | §3.6 表逐行落地:staging fallback 基线 → Linux NVDEC/VAAPI 导入 → Windows D3D11VA 导入 → macOS VideoToolbox 导入;FFmpeg 无 hwaccel 的组合才评估手写 GPU 解码 | 硬解路径 HW_TRANSFERS 计数归零(不再下载);逐平台导入开/关对比测试;每行独立 PR 可回退 |
依赖关系:M0a 独立;M0b 依赖 M0a;M1 依赖 M0a+M0b;M2 依赖 M1;M3 依赖 M1(与 M2 可并行);M4 依赖 M2;M5 依赖 M2(YUV→RGB pass 与互操作抽象), 可拆成并行子项。
全程验收(用户要求):M5 完成、整个任务收官后,跑一轮分支覆盖率 (branch coverage)测量并留档,作为管线改造整体的质量闸门。
5. 不变量与边界
- 不动:ticket API(oak-app/oak-cli 无感)、撤销/重做、缓存磁盘格式、 OFX 插件 ABI、CI 的 OFX fixture 探针(ci.yml 的 scan_probe 流程)。例外(M0b 明确改变的两处):图模型新增 GraphInput/GraphOutput 固定端点,序列化格式随之携带这对端点(旧工程 载入时自动补挂,等价于一次无损迁移,配套序列化往返测试)。
- 回退开关:线程后端落地期间进程池后端完整保留,
OAK_PIPELINE环境 变量 + 配置项双开关;M4 验收通过前进程池仍是默认。硬解导入逐平台 独立开关(§3.6),OAK_HWACCEL=0一键回到全软解。 - 线程亲和:wgpu device 可在专用线程独占使用(Device/Queue 均 Send);
渲染线程是唯一触摸
GpuContext的线程;硬解帧池与硬件设备上下文 (AVHWDeviceContext)归解码线程所有,硬件表面经互操作导入后才交给 渲染线程消费(§3.4)。 - 音频:本计划只覆盖视频管线;音频采样链(SampleJob 对应路径)维持 现状,如需统一另立计划。
6. 风险与对策
- 上屏互操作不确定(gpui 的 wgpu device 能否共享):M2 已攻关并落地
(结论见 §3.5 回填):把引擎从 wgpu 25 升到 29 后,
register_context采用 gpui 的 device,Texture::Gpu的原始纹理经SurfaceSource::Texture直通 gpui,上屏零拷贝;颜色由 CPU 烘焙的 3D LUT 在 GPU 应用,不跳过色彩管理。macOS/Windows 的 gpui 暂不暴露 device, 自动回退到单点 staging(仍只此一处)。 - 解码器线程安全性:oak-codec 会话当前按进程级互斥共享,集中到一个 线程后语义更简单,但 hwaccel 解码上下文可能有线程亲和(VAAPI/NVDEC), M1 先做软解路径,hwaccel 随 M5 逐项验证。
- OFX 插件的 GPU 直通:本期明确不支持,文档化;插件渲染结果统一按 CPU 帧回传(§3.2)。
- 测试环境无 GPU:CI 的 lavapipe/xvfb 路径已在跑 wgpu(ci.yml 的
Test 步骤),线程后端必须在该环境下同样可用;
GpuContext::create的 CPU 回退(backend.rs:1422 测试所示)保持可用。 - BFS 求值的兼容性(M0b):虚拟端点改变了图模型与求值顺序的显性 语义,是全部里程碑里对既有行为扰动最大的一步。对策:M0a 先把 枚举化与单循环做掉(行为不变、纯结构),M0b 单独成 PR、配全套 §4 列举的行为测试;旧工程载入自动补挂端点。
- GPU 解码零拷贝的平台碎片化(M5):各平台导入路径(DMA-BUF modifier 协商、DXGI NT 句柄、IOSurface、CUDA-Vulkan 互操作)都有 驱动/版本坑,且 wgpu 对外部内存导入的公开 API 覆盖有限,可能要 落到 wgpu-hal 原生句柄层写 unsafe 胶水。对策:staging fallback 是 永久基线(任何导入失败按帧回退);按 §3.6 顺序逐平台落地,每平台 独立开关;CUDA-Vulkan 互操作作为 NVDEC 路径的最后手段(先试 更通用的 DMA-BUF——Linux 上 NVIDIA 也经 VAAPI 可达时优先 VAAPI)。
- 范围蔓延:手写 GPU 解码严格限于"FFmpeg 没有对应 hwaccel"的 组合(用户原话"次选"),不提前立项;Job 图的序列化格式版本化 不在本期(沿用工程文件现有版本策略)。