下载 App
10/165 浏览案例分享
【案例分享】把 3D 场景画进 UI:RenderToTexture 到 nvgCreateVideo,我在这条链路上补了 5 个释放点
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:《别停下》开发实录 —— engine-docs/recipes/scene-to-nanovg.md、recipes/video-screen-3d.md、recipes/camera.md,对照 .emmylua/RenderSurface.d.lua、NanoVG.d.lua、View3D.d.lua
一句话结论
3D 场景进 UI 只有一条链路:Texture2D(RENDERTARGET) → Viewport → RenderSurface → nvgCreateVideo → nvgImagePattern。代码不到 40 行。
难的不是建,是拆。nvgCreateVideo 返回的句柄、RenderSurface 里的 viewport 引用、Texture2D 本身,是三个各自独立的引用。漏一个就是一次显存泄漏,而且它不报错。
一、链路只有四步
步:建什么 关键调用
1:离屏纹理 Texture2D:SetSize(w, h, format, TEXTURE_RENDERTARGET)
2:视口 Viewport:new(scene, camera)
3:渲染面 texture:GetRenderSurface() + SetViewport(0, vp)
4:NanoVG 句柄 nvgCreateVideo(nvg, texture)
第 4 步那个函数名字里带 Video,但它收的是任意 Texture2D。3D 预览走的就是它。
顺带一句:nvgCreateVideo 内部已经把预览纹理的上下方向处理掉了,按普通图片画即可,不用自己翻 Y。这点和 3D 世界里贴视频屏幕不一样——那条路要手搓 UV 翻转,这条路不用。
二、建纹理:512×512、1 级 mip、BILINEAR
-- 预览纹理的尺寸按"最终显示尺寸 × DPR"倒推
-- 面板里显示 256 逻辑像素,2x DPR 屏就是 512 设备像素,1:1
local PREVIEW_SIZE = 512
local texture = Texture2D:new()
texture:SetNumLevels(1) -- 离屏 RT 不需要 mip 链
assert(texture:SetSize(PREVIEW_SIZE, PREVIEW_SIZE,
Graphics:GetRGBAFormat(), TEXTURE_RENDERTARGET))
texture:SetFilterMode(FILTER_BILINEAR) -- UI 里会被缩放,双线性必须开
SetNumLevels(1) 那行容易被人省掉。省掉不崩,只是引擎会给这张 RT 生成一整条 mip 链,白白多占约 1/3 的显存,而且你一张都不会用到。
SetSize 用 assert 包住是有意的。它是这条链路上唯一会明确告诉你失败的地方,后面的步骤全是静默的。
三、渲染路径:把 CMD_CLEAR 改成全透明
local viewport = Viewport:new(previewScene, previewCamera)
viewport:SetRenderPath(assert(cache:GetResource("XMLFile", "RenderPaths/Forward.xml")))
do
local path = viewport:GetRenderPath()
for i = 0, path:GetNumCommands() - 1 do
local command = path:GetCommand(i)
if command.type == CMD_CLEAR then
command.useFogColor = false -- 别让雾色糊上背景
command.clearColor = Color(0, 0, 0, 0) -- 全透明,才能叠在 UI 上
end
end
end
这两行是整套东西能不能"贴"在界面上的分水岭。
默认的 CMD_CLEAR 会把整张 RT 涂成场景雾色。你不改,画出来就是一个雾蒙蒙的方块压在 UI 上,面板背景全被盖住。useFogColor = false 和 clearColor = Color(0,0,0,0) 要一起改——只改后者,雾色照样会把 alpha 顶回 1。
四、更新模式:三档,选错就是白烧 GPU
local surface = texture:GetRenderSurface()
surface:SetViewport(0, viewport)
surface:SetUpdateMode(SURFACE_MANUALUPDATE) -- 0
surface:QueueUpdate() -- 建完先渲一帧,别留黑
常量:值 什么时候用
SURFACE_MANUALUPDATE:0 预览面板。只在打开、换模型、转视角时 QueueUpdate()
SURFACE_UPDATEVISIBLE:1 常驻小地图那种,可见就渲
SURFACE_UPDATEALWAYS:2 每帧都要变的,代价也最高
MANUALUPDATE 是预览场景的正解。面板关着、或者角色没动,这张 512×512 的 RT 一次都不用重渲。
QueueUpdate() 的调用点有三个:创建完成后、模型加载完成后、相机姿态变化后。放在 HandleUpdate 里无脑每帧调,等于把 MANUALUPDATE 用成了 UPDATEALWAYS。
五、画:句柄只建一次
local image, imageContext
local createFailed = false
local function DrawScenePreview(nvg, x, y, size)
if size <= 0 or not texture or createFailed then return end
if not image then
image = nvgCreateVideo(nvg, texture)
imageContext = nvg -- 删的时候要用同一个 ctx,必须记
if image == 0 then -- 失败返回 0,不是 nil
createFailed = true
return
end
end
nvgSave(nvg)
nvgIntersectScissor(nvg, x, y, size, size) -- 裁掉,防止画出面板外
nvgTranslate(nvg, x, y)
nvgBeginPath(nvg)
nvgRect(nvg, 0, 0, size, size)
nvgFillPaint(nvg, nvgImagePattern(nvg, 0, 0, size, size, 0, image, 1))
nvgFill(nvg)
nvgRestore(nvg)
end
nvgCreateVideo 失败返回的是 0。用 if not image 判,0 在 Lua 里是 truthy,判不出来。
接自定义 Widget 时,在 Render(nvg) 里取 local l = self:GetAbsoluteLayout(),调 DrawScenePreview(nvg, l.x, l.y, math.min(l.w, l.h)) 就行。
六、拆:五个释放点,顺序不能乱
local function ReleasePreviewImage()
if image and image ~= 0 then nvgDeleteVideo(imageContext, image) end
image, imageContext = nil, nil
createFailed = false -- 允许下次绘制重试
end
local function DestroyScenePreview()
ReleasePreviewImage() -- 1. 先删 nvg 句柄
if surface then surface:SetNumViewports(0) end -- 2. 再断 viewport 引用
if texture then texture:Dispose() end -- 3. 后放纹理本体
surface, viewport, texture = nil, nil, nil -- 4. 清 Lua 侧引用
end
序:动作 漏了会怎样
1:停绘制 释放后 Render 还在画,句柄已经失效
2:nvgDeleteVideo(imageContext, image) nvg 侧图像注册表泄漏,开关一次漏一个
3:surface:SetNumViewports(0) surface 仍持有 viewport,viewport 拖着 scene 和 camera 一起不回收
4:texture:Dispose() 那张 512×512 RT 的显存不还
5:Lua 变量置 nil 下次打开时旧表还在,判"已经建过了",直接画到一张死纹理上
第 3 步和第 4 步的先后最容易搞反。反过来的话,Dispose 时 surface 还挂在纹理上,那个 viewport 引用会残留成悬空指针。
第 5 步漏掉的表现很有迷惑性:第一次打开正常,关掉再开全黑。因为 Lua 侧的 image 还在,代码认为不用重建,实际上纹理已经被 Dispose 了。
独立预览场景由创建它的代码释放。别的模块共享的资源别碰。
踩过的坑
坑 1:每帧调 nvgCreateVideo
-- ❌ 画一次建一次
local function DrawScenePreview(nvg, x, y, size)
local image = nvgCreateVideo(nvg, texture)
...
end
-- ✅ 建一次,存起来,绘制时复用
if not image then image = nvgCreateVideo(nvg, texture) end
后者是每帧一次 nvg 图像注册,注册表按帧增长。开个面板挂十分钟,这个数字你自己算。
坑 2:每次打开面板都重建整套
-- ❌ 面板每次 Show 都从头 new 一遍
function Panel:Show()
texture = Texture2D:new()
...
end
-- ✅ 建一次,反复开关只切可见性
function Panel:Show()
if not texture then BuildScenePreview() end
end
区别在于显存曲线是锯齿还是直线。前者开一次涨一次,关的时候如果释放路径没走全,就永远回不去。
坑 3:换 context 时删晚了
-- ❌ 先把旧 context 删了,再去删句柄
nvgDelete(oldNvg)
nvgDeleteVideo(oldNvg, image) -- ctx 已经没了
-- ✅ 在旧 ctx 仍然可用时先删句柄,再删 ctx
nvgDeleteVideo(oldNvg, image)
image, imageContext = nil, nil
nvgDelete(oldNvg)
句柄是挂在 context 上的。ctx 先没,那个句柄就再也删不掉了。这条在引擎示例 20 里是反着写的——它 Stop() 里直接 nvgImageHandle = nil 就完事,因为那个例子整个进程只建一个 ctx,不存在切换。你的项目里如果有重建 UI 根节点的逻辑,就会碰上。
坑 4:换纹理/改尺寸时不重建句柄
-- ❌ 换了张新纹理,句柄还是旧的
texture = NewBiggerTexture()
-- ✅ 先释放句柄,再换纹理
ReleasePreviewImage()
texture = NewBiggerTexture()
nvgCreateVideo 对同一个纹理是幂等的,但换了纹理对象之后,旧句柄指向的是一张已经销毁的纹理。画面不是黑,是花屏或者上一帧的残影——这个比黑更难查。
坑 5:创建失败后无限重试
-- ❌ 失败就 return,下一帧又试一遍
if image == 0 then return end
-- ✅ 标记失败,停止重试
if image == 0 then createFailed = true; return end
失败原因通常是资源还没下完。每帧重试一次,等于每帧打一次失败日志,帧率直接掉一半。标记住,等玩家重开面板(ReleasePreviewImage 会把 createFailed 清回 false)再试。
适用边界
这套写法覆盖的范围:需要把 3D 内容嵌进自绘 UI 的场景——角色预览、装备展示、关卡缩略图、小地图、3D 图标。你自己控制渲染路径、控制透明背景、控制什么时候重渲。
先看看 View3D 再决定要不要手搓。 引擎里有一个现成的 UI 控件,接口是 SetView(scene, camera, ownScene)、SetAutoUpdate(enable)、QueueUpdate()、GetRenderTexture()、SetFormat(format)。如果你的需求就是"在 UI 里显示一个场景、自动更新",它省掉上面所有释放点的管理。
不适用或要另外处理的:
情况:原因
每帧都要变的实时预览:MANUALUPDATE 下每帧 QueueUpdate(),等于常开一个额外视口,帧率换来的
要预览透明物体:CMD_CLEAR 的 alpha 得是 0,物体自身还得用不透明 technique,两件事
多面板同时开:一个面板一套纹理,4 个面板就是 4 张 RT,显存按平方涨
移动端低端机:512×512 RT 在低端机上是可观的,降到 256 并接受模糊更划算
还有一条:预览场景里的灯光和主场景是两套。LightGroup 加载到预览场景才有光照,不然模型是一坨纯黑。
可迁移的经验
1. 离屏渲染的成本不在建,在拆。 建的时候有 assert 兜着,拆的时候什么都没有。我给每一类资源都配了一个 Release* 函数,命名上就成对。写新代码时先问一句"这个谁删",比事后翻显存曲线便宜。
2. 把释放顺序写成表,别记在脑子里。 上面那张五行表我抄进项目注释了。顺序错了不报错,只在开关几十次之后以显存增长的形式冒出来——那时候你早就忘了自己改过什么。
3. 句柄和它所属的上下文绑在一起存。 imageContext 这个变量看着多余,直到你要支持 UI 重建。多存一个字段,比事后追一个悬空句柄划算。
4. 失败返回 0 比返回 nil 更容易漏判。 Lua 里 0 是 truthy,if not x 判不出来。看到 API 文档写"失败返回 0",条件就写成 x == 0。
5. 这套链路我是在一个 256 逻辑像素的角色预览面板上用起来的。真正的收获不是那 40 行创建代码,是那张释放顺序表——它让我后面写别的离屏渲染时,第一次就把关闭路径写对了。
(完)




