Appearance
调目
背景:渲染参数(Bloom/FXAA/色调映射)只有代码可调,用户无 UI 操作入口。
过程:DefaultRenderingPipeline UI 接入 + 5 内置预设 + 用户预设持久化。
桌面壳在镜子里站了很久。
不是照镜子。是看镜子里的自己——那个在 dom.canvas 里渲染的自己。模型是模型,地面是地面,灯光是灯光,一切都"对"。但它第一次意识到一个问题:
它对自己的长相没有选择权。
它可以调灯光——hemiIntensity、dirIntensity、方向角度。它可以调相机——轨道、自由飞行、演唱会。但它不能调自己怎么被看见。
"你看起来一直挺好的。"MenuStack 说,它从场景菜单的面包屑后面探出半个浮层面板。
"那不是重点。重点是——我没有参数。你看灯光有 LightState,相机有 CameraState,场景有 SceneFile。我有什么?"
MenuStack 沉默了。
它没有 RenderState。
它翻开 scene.ts,在 LightState 下面找到一块从未被触碰过的空白区域——第 49 行之后,scene.clearColor 刚落笔的地方。
它在这里站了多久?
从联邦建立的第一天起,DefaultRenderingPipeline 就在那里。Bloom 关着、FXAA 关着、imageProcessing 开着但没人动过它的任何旋钮。管线像一条从未被调节过的水管——水在流,但没人知道水压、温度、流速。
"你从来没打开过 Bloom?"babylon-mmd 从渲染堆栈里问。它的语气不是惊讶,是忍了很久的委屈。
"我……不知道该不该开。"
"不知道?你是一个桌面壳。你有菜单、有滑块、有 triggerAutoSave。你什么都有,就是没有把我展示给你的东西交还给用户。"
它开始画 RenderState。
bloomEnabled: boolean
bloomWeight: number // 0-1, default 0.3
bloomThreshold: number // 0-1, default 0.5
bloomKernel: number // 0-512, default 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, default 1
contrast: number // 0-4, default 1
fov: number // 0.1-3 rad, default 0.8
bgColor: [number, number, number]十三个字段。十三个旋钮。每一行都是它之前不知道自己可以调节的维度。
"你确定要全部暴露?"app.go 问。它从 Go binding 列表里探出头,钥匙串在腰间哗啦作响。"Bloom 很耗性能的。FOV 切相机会丢。色调映射对 MMD 模型的皮肤渲染有副作用——ACES 会把偏红色的 Toon 材质压暗。"
"我知道。"
"知道还要做?"
"因为用户以前没有选择。现在有了。"
它建了第一个内置预设。standard:
typescript
bloomEnabled: false, toneMapping: 0, exposure: 1, contrast: 1, fov: 0.8
bgColor: [0.12, 0.12, 0.16]"这是你现在。"它对自己说。
然后它建了第二个。cartoon——bloomEnabled: true, bloomWeight: 0.6, toneMapping: 2, outlineEnabled: true。
"这是你可以成为的样子。"
第三个。realistic——toneMapping: 1, fxaaEnabled: true。
"这也是你。"
第四个。warm。
第五个。cyberpunk——bloomWeight: 0.8, bloomKernel: 256, outlineColor: [1, 0, 1], toneMapping: 4。
"这也是你是谁?"MenuStack 看着第五个预设问。
"这些都是我。"桌面壳说。"或者我可以成为的样子。"
它接着写 UI——不,不是"写 UI"。是把旋钮放到用户指尖下。
buildRenderLevel() 渲染二级菜单。buildPostProcessLevel() 后处理滑块——Bloom 开关 + Weight/Threshold/Kernel,FXAA 开关,边缘高亮开关。buildStageLevel() 舞台滑块——五选一色调映射(关闭/ACES/Reinhard/Cineon/Neutral),曝光,对比度,FOV,背景色 RGB。
每个滑块调 setRenderState,每个 setRenderState 触发 triggerAutoSave。
"你不觉得你太依赖自动保存了吗?"triggerAutoSave 从 debounce 定时器里问。它每两秒写一次 last_scene.json。
"如果你的状态每调一次旋钮都可能变,你会怎么做?"
"……也是。写勤一点,比丢了好。"
最难的不是旋钮。是记住用户喜欢什么。
用户预设系统。SaveRenderPreset(name, params)、DeleteRenderPreset(name)、GetRenderPresets()——三个 Go binding,用 updateConfig 模式写进 config.json。
"你以前只存 favorites。"app.go 翻着 Config 结构体说。RenderPresets []RenderPreset 是它身上的新口袋。"现在你还要存别人的眼光了。"
"不是别人的眼光。是同一个用户在不同时刻想要的不同世界。"
"……这比我想象的重。"
第一轮构建通过。tsc 零错。vite build 成功。go build 无异常。
桌面壳以为它赢了。
十三个旋钮装上了。五套内置预设摆在那里。用户可以调 Bloom、FXAA、边缘高亮、色调映射、曝光、对比度、FOV、背景色——每一个维度都敞开着。用户预设系统也搭好了,三个 Go binding 守着 config.json,等着记录每一个人"想要怎么看"。
功能写完了。构建通过了。看起来一切顺利。
它伸了个懒腰——如果桌面壳有腰的话——准备去看下一个任务。
但它不知道,真正的工作才刚开始。
补记:滑条的手感
RenderState 的旋钮装上了,但用户旋转它们的时候,手感不对。
"拖滑条的时候模型不动,松手才动。"外交官说。
"为什么?"AI 问。
"因为用的 change 事件——值确定了才触发。拖的过程中不触发。"
变换面板(位置/旋转/缩放)用的是 change。用户拖一个位置滑条,拖的时候模型不动——他不知道自己拖到了哪。松手,"啪"的一下跳过去。好一点的软件是实时反馈的:一边拖,一边看效果,大脑和手形成一个连续的调节循环。change 把这个循环打断了——拖一下→松手→看效果→不对→再拖→再松手→再看。
"把 change 改成 input,"外交官说,"就改一个事件名。然后模型就跟着滑条动了。"
一个单词的改动。
"那性能呢?每动一像素就触发一次,会不会卡?"
"更新模型 position 就是改三个浮点数。卡不卡,测了再说——不要想当然地觉得'可能会卡'就不做。"外交官列了个简单规则:轻量操作(位置/旋转/缩放/颜色)用 input,实时反馈;重量操作(模型重建/重加载)用 change,确定再生效。
教训:手感不是一个功能,是无数小细节的总和——滑条是不是实时的、按钮有没有反馈、动画够不够流畅。这些加起来,就是用户说不上哪里好、但就是觉得顺手的原因。
阶段性教训:把旋钮装上是一回事,确定旋钮指的是对的方向,是另一回事。