Skip to content

调色台

背景:模型能跑,灯光能调,但画面呈现永远固定——没有 Bloom、没有色调映射、没有预设。 过程:RenderState 13 字段 + pipeline 接入 + 5 内置预设 + Go 持久化 + outline 降级 + 相机重挂(动态 import 解循环依赖)+ 序列化。


桌面壳盯着屏幕上的模型看了很久。

模型站在那里,灯光打好了,VMD 跑起来了,物理在算,裙摆在荡。从功能上说,一切都在运转。但桌面壳知道——屏幕上的画面,和它呈现的画面之间,隔着一整个调色台的距离。

DanceXR 的文档它读过。那边有 Bloom、有 FXAA、有色调映射、有曝光、有对比度、有 FOV、有背景色。那边有"卡通""写实""赛博朋克"这些一键切换的预设。那边甚至允许用户保存自己的预设,下次打开还在。

MikuMikuAR 这边什么都没有。模型是什么样就是什么样。天空是 Color4(0.12, 0.12, 0.16, 1.0)——一个写死的深灰色。

"我需要一个调色台。"桌面壳说。


一、管线

babylon-mmd 一直用 Babylon.js 的 DefaultRenderingPipeline。这个管线像一条流水线——模型渲染完之后,画面先过 Bloom(泛光),再过 FXAA(抗锯齿),再过 imageProcessing(色调映射、曝光、对比度),最后才到屏幕。

管线一直在那里。babylon-mmd 早就把它建好了。但 MikuMikuAR 从来没碰过它的旋钮。

桌面壳走到 scene.ts 的第 96 行,在灯光管理之后、场景初始化之前,建起了调色台:

typescript
export const pipeline = new DefaultRenderingPipeline("default", true, scene, [scene.activeCamera!]);
pipeline.samples = 1;            // MSAA off (performance)
pipeline.fxaaEnabled = false;
pipeline.bloomEnabled = false;
pipeline.imageProcessingEnabled = true;

四行。MSAA 关掉(性能),FXAA 默认关,Bloom 默认关,imageProcessing 开着。

"为什么全关?"MenuStack 探头问。

"因为默认关,用户开了才知道区别。"桌面壳说,"默认开的调色台不叫调色台——叫滤镜。调色台的意义是'你可以调',不是'我替你调好了'。"


二、状态

调色台的旋钮有多少?桌面壳掰着手指头数了一遍。

后处理有四个:Bloom开关、强度、阈值、核半径。边缘高亮两个:开关和颜色。FXAA一个。舞台上的旋钮更多:色调映射、曝光、对比度、视野、背景色——加起来十三个。

它把它们列成了一张表:

typescript
export interface RenderState {
    // 后处理
    bloomEnabled: boolean;
    bloomWeight: number;      // 0-1, 默认 0.3
    bloomThreshold: number;   // 0-1, 默认 0.5
    bloomKernel: number;      // 0-512, 默认 64
    outlineEnabled: boolean;
    outlineColor: [number, number, number];
    fxaaEnabled: boolean;
    // 舞台
    toneMapping: number;      // 0=OFF 1=ACES 2=Reinhard 3=Cineon 4=Neutral
    exposure: number;         // 0-4, 默认 1
    contrast: number;         // 0-4, 默认 1
    fov: number;              // 0.1-3 rad, 默认 0.8
    bgColor: [number, number, number];
}

列完之后,它直接照着灯光面板的格式写了getRenderStatesetRenderState

"你又在复制模式。"MenuStack从旁边探过头来。

"模式复用不是复制。"桌面壳头也不抬,"用户学会了调灯光,就自然会调渲染——因为交互一样。滑块拖一下,状态变一下,triggerAutoSave存一下。零学习成本。"

getRenderState从 pipeline、scene、activeCamera 三处读当前值,凑成一张完整的快照。setRenderState 接受Partial<RenderState>——传什么改什么,不传的不动。这样预设系统就能做"只改这几个参数"的部分应用,而不用每次都重置全部。


三、五幅画

内置预设是五幅已经画好的画。

桌面壳把它们定义成常量,挂在 scene-menu.ts 里:

typescript
const builtinPresets: Record<string, Partial<RenderState>> = {
    standard:   { bloomEnabled: false, ... toneMapping: 0, ... },
    cartoon:    { bloomEnabled: true, bloomWeight: 0.6, outlineEnabled: true, ... },
    realistic:  { fxaaEnabled: true, toneMapping: 1, ... },
    warm:       { bloomEnabled: true, toneMapping: 2, exposure: 1.1, ... },
    cyberpunk:  { bloomEnabled: true, bloomWeight: 0.8, outlineColor: [1,0,1], ... },
};

五个名字。五种风格。每一种都是一组 Partial<RenderState>——不是完整状态,是"改这几个"。

"标准"是全关。"卡通"是 Bloom 加边缘高亮。"写实"是 ACES 色调映射加 FXAA。"暖光"是低对比度高曝光。"赛博朋克"是高 Bloom 加品红色边缘加 Neutral 色调映射。

用户点一下"卡通",setRenderState(builtinPresets.cartoon) 就把这组参数灌进 pipeline。画面瞬间变卡通风。

"这五幅画是谁画的?"MenuStack 问。

"是我画的。"桌面壳说,"但参数不是我编的——是 DanceXR 文档里读来的。它说卡通要高 Bloom 加描边,我就高 Bloom 加边缘高亮。它说写实要 ACES,我就 ACES。"

"所以你在抄。"

"我在阅图。"桌面壳纠正,"第十九章里我说过——绘图师的工作不是凭空画图,是看别人画了什么。这五幅画是邻国地图的注脚。"


四、仓库

预设不能只存在内存里。关了应用就没了,等于白做。

桌面壳找到了 app.go——那个腰间挂着三十五把钥匙的总管,联邦所有落到磁盘上的东西都要经过它。

"我需要三把新钥匙。"桌面壳说,"存预设、删预设、读预设。"

app.go 点点头,在 Config 结构体的末尾加了一行:

go
type Config struct {
    // ... 现有字段
    RenderPresets []RenderPreset `json:"render_presets"`
}

type RenderPreset struct {
    Name   string                 `json:"name"`
    Params map[string]interface{} `json:"params"`
}

然后是三个 binding 函数,照着 SetDisplayNamePriority 的老规矩写——加锁、读、改、写,一气呵成:

go
func (a *App) SaveRenderPreset(name string, params string) error
func (a *App) DeleteRenderPreset(name string) error
func (a *App) GetRenderPresets() []RenderPreset

SaveRenderPreset 接收 JSON 字符串,解析成 map,按名字查重——有就更新,没有就追加。DeleteRenderPreset 过滤掉同名的。GetRenderPresets 直接返回数组。

"为什么 Params 是 map[string]interface{} 而不是 RenderState?"app.go 写着写着停下了。它是个严谨的管家,不喜欢弱类型。

"因为 Go 不知道前端的 RenderState 长什么样。"桌面壳解释,"而且以后可能加材质参数、加环境参数。用 map 留口子,不锁死结构。"

app.go 沉默了一拍。它确实不喜欢弱类型——但也承认这是合理的妥协。联邦的边界上,类型系统本来就不是完美连通的。三把钥匙被挂上了它腰间。

Wails 的绑定生成器随后跑了一遍。models.ts 多了 RenderPreset 类,App.d.tsApp.js 多了三个函数。前端的 scene-menu.ts 把它们 import 进来。

通路接好了。


五、降级

轮廓线是调色台上最麻烦的旋钮。

Babylon 的 DefaultRenderingPipeline 没有 Outline 后处理。Babylon 社区有 OutlinePostProcess,但非官方,依赖不明。babylon-mmd 的 MmdStandardMaterial 有 outline 属性,但那是材质级别的,不是后处理。

桌面壳盯着实施计划里的方案对比看了很久:

方案 A:mesh.enableEdgesRendering() + edgesWidth —— 逐 mesh,不是后处理 方案 B:babylon-mmd 卡通描边 —— 材质级别 方案 C:社区 OutlinePostProcess —— 非官方

"降级。"桌面壳做了决定。

它选了方案 A——用 Babylon 原生的 enableEdgesRendering()。这不是真正的轮廓线后处理,是给每个 mesh 开启边缘线渲染。效果不如真正的描边,但零依赖、稳定。

"UI 上叫什么?"MenuStack 问。

"边缘高亮。不叫轮廓线。"桌面壳说,"叫轮廓线是骗人。它就是边缘高亮——把 mesh 的棱边画出来。比什么都没有强,但不是卡通描边。"

setRenderState 里,outlineEnabled 切到 true 时,遍历 modelRegistry 所有 mesh,逐个 enableEdgesRendering()。切到 false 时逐个 disableEdgesRendering()。颜色通过 edgesColor 设。

"真描边留到材质那轮。"桌面壳自言自语,"那个轮次会更懂 mesh 的脾气。"


六、相机陷阱

调色台搭好了。旋钮接好了。预设存好了。

桌面壳试了一下——打开 Bloom,拖 Weight,画面泛光。打开 ACES,色调变了。切到"赛博朋克",画面瞬间赛博。一切正常。

然后它切了一下相机。

从轨道切到自由飞行。画面上的 Bloom 消失了。

"……什么?"

桌面壳盯着屏幕。Bloom 的开关还是开着的,Weight 还是 0.8,但画面上没有泛光。就好像管线不存在了一样。

它打开 camera.ts,看 switchCameraMode。这个函数每次切相机,会 scene.removeCamera(oldCam) 销毁旧相机,然后新建一个相机设为 activeCamera

管线在初始化时挂在了第一个相机上——[scene.activeCamera!]。但那个相机已经被销毁了。新相机没有挂管线。

管线跟着旧相机一起死了。

桌面壳看着这段代码,想起了第一章——babylon-mmd 的侧门。有些能力不在正门,你不走侧门,能力就不存在。这次反过来——能力在正门,但正门被拆了,能力跟着没了。

它在 scene.ts 里写了一个函数:

typescript
export function reattachPipeline(): void {
    if (scene.activeCamera) {
        pipeline.addCamera(scene.activeCamera);
    }
}

然后问题是——这个函数该在哪调?

实施计划写得很清楚:"switchCameraMode 末尾调用。"

scene.ts 已经静态 import 了 camera.ts。如果 camera.ts 再 import scene.ts,就是循环依赖。

桌面壳想了很久。

"动态 import。"

typescript
// camera.ts:switchCameraMode 末尾
import("./scene").then(({ reattachPipeline, getRenderState }) => {
    reattachPipeline();
    const rs = getRenderState();
    if (rs.fov) (newCam as any).fov = rs.fov;
});

import() 是异步的,在运行时加载模块,不形成静态循环。scene.ts 在初始化时已经加载过了,动态 import 会直接从缓存返回——几乎是同步的,但不触发循环依赖检测。

"一个微任务的时间差。"桌面壳说,"但够用了。切相机的下一个渲染帧之前,管线就挂上了。"


七、序列化

最后一步是让渲染参数跟着场景一起保存。

SceneFile 加了一个可选字段:

typescript
export interface SceneFile {
    // ... 现有字段
    render?: RenderState;  // 可选——旧场景无此字段不报错
}

serializeScene() 末尾加了 render: getRenderState()deserializeScene() 末尾加了 if (data.render) setRenderState(data.render)

可选字段,旧场景没这个字段不炸。新场景存了,下次打开恢复。

triggerAutoSave 已经在 arrangeModelssetModelVisibility、滑块拖动时被调用了无数次。渲染参数的滑块也接上了——拖一下滑块,setRenderState 改值,triggerAutoSave 排一个 2 秒 debounce 的保存。

"复用已有的保存通道。"桌面壳说,"不为渲染参数单独建一套持久化。它跟着场景走,场景存哪它存哪。"


调色台搭好了。

十二个文件改了,六百多行新代码。Bloom 能开了,FXAA 能开了,色调映射能切了,FOV 能调了,背景色能换了,五幅画能一键切换了,用户能存自己的画了,关了应用再打开画还在。

桌面壳站在调色台前,看着屏幕上的赛博朋克风模型——高 Bloom 泛着品红色的光,边缘高亮勾出轮廓,Neutral 色调映射压低了高光。

"成了。"它说。

它不知道的是——这台调色台上,有三个旋钮接错了线,一个旋钮是假的开关,还有一个旋钮每次切相机会偷偷复位。

这些问题它自己看不见。

但有人看得见。


教训:调色台搭好了,但旋钮的接线对不对,要等审计员来验。