UGUI源码学习笔记_第二篇_CanvasScaler篇

20260909-224415

源码来源说明:本篇的 C# 逻辑部分依据 UGUI 官方 CanvasScaler.csUnityEngine.UI 命名空间)逐方法还原;引擎侧属性(scaleFactor / referencePixelsPerUnit / renderingDisplaySize 等)依据 UnityCsReference-master/Modules/UI/ScriptBindings/UICanvas.bindings.cs 的 C#↔C++ 绑定确认。两个目录里并没有直接给出 CanvasScaler.cs 的托管源码(UnityCsReference-master/Modules/UI/Managed 只保留了 UGUIHelpURL.cs),但 UICanvas.bindings.cs 能反过来印证 CanvasScaler 写入的那两个值是引擎 UI::Canvas 的什么属性、什么时机被消费。


0. 概念定位 & 它解决什么痛点

CanvasScaler 是什么:挂在 Root Canvas 上的一个 UIBehaviour,它不逐个改 Graphic 顶点,也不去动 Canvas 根节点的 RectTransform.localScale。它每帧(或每次进入渲染前)只算出两个数字并写入 Canvas:

  • Canvas.scaleFactor —— UI 逻辑单位 → 输出像素的统一缩放比(屏幕空间)。
  • Canvas.referencePixelsPerUnit —— Sprite 像素 → UI 单位的换算基准(PPU 链)。

它解决什么痛点

  1. 多分辨率适配:1920×1080、1280×720、手机刘海屏、折叠屏……设计稿只画一套(Reference Resolution),CanvasScaler 把它换算到任意真实分辨率。
  2. 多 DPI 适配:不同设备每英寸像素数不同(手机 300+、桌面 96),“1 厘米按钮”在物理尺寸上想保持一致时,需要按 DPI 反推。
  3. 世界空间 UI 密度:VR/AR、场景内面板里,Text 这类“运行期动态生成位图”的内容需要显式控制像素密度(Dynamic Pixels Per Unit),否则近看糊、远看浪费。
  4. 统一比例 vs 逻辑画布范围:到底是“整体放缩保持一致”还是“逻辑画布范围随宽高比变化”,由模式与 Match 参数决定,把决策从每个 UI 元素手里收回中央。

一句话:CanvasScaler 把“分辨率/物理尺寸 → 统一比例”这件事集中处理,下游 Graphic、Layout、Text 都只认 scaleFactorreferencePixelsPerUnit 两个值。


1. 类声明与所在位置

// UnityEngine.UI,UGUI 包内
[RequireComponent(typeof(Canvas))]   // 没 Canvas 挂不上,编译期就报错
[ExecuteInEditMode]                  // 编辑器里也能实时算,不是只运行时
[AddComponentMenu("Layout/Canvas Scaler", 101)]
[DisallowMultipleComponent]          // 同一个 GameObject 只允许一个
public class CanvasScaler : UIBehaviour

逐条解释:

  • [RequireComponent(typeof(Canvas))]:CanvasScaler 必须和 Canvas 同体。它内部 GetComponent<Canvas>() 拿到的就是自己这个 GameObject 上的 Canvas。一个 CanvasScaler 只服务“自己这一棵 Root Canvas”。
  • [ExecuteInEditMode]:编辑器非运行状态下也会执行 OnEnable / 事件,所以你在 Scene 视图拖分辨率、改 Match,UI 能立刻跟着变,不用按 Play。
  • [DisallowMultipleComponent]:避免一个 Canvas 上叠两个 Scaler 互相覆盖写 scaleFactor
  • 继承自 UIBehaviour(不是 MonoBehaviour 直接派生),所以能吃到底层 OnEnable/OnDisable 的 UI 生命周期钩子。

重要边界(后面 Handle 里会再强调):CanvasScaler 只处理 Root Canvas。挂在子 Canvas(嵌套 Canvas)上不会独立跑缩放逻辑——嵌套 Canvas 继承父 Canvas 的 scaleFactor/referencePixelsPerUnit


2. 完整字段清单(每个字段干什么)

字段 类型 默认值 属于哪个模式 作用
m_UiScaleMode ScaleMode (enum) ConstantPixelSize 总开关 选三种屏幕空间模式之一
m_ScaleFactor float 1 Constant Pixel Size 手动写死的固定缩放比
m_ReferenceResolution Vector2 (800, 600) Scale With Screen Size 设计基准分辨率(逻辑画布)
m_ScreenMatchMode ScreenMatchMode (enum) MatchWidthOrHeight Scale With Screen Size 宽高比不一致时怎么取舍
m_MatchWidthOrHeight float (0~1) 0 Scale With Screen Size Match 模式的插值权重
m_PhysicalUnit Unit (enum) Points Constant Physical Size 目标“1 单位 = 多少物理长度”
m_FallbackScreenDPI float 96 Constant Physical Size 设备报 DPI=0 时的兜底
m_DefaultSpriteDPI float 96 Constant Physical Size Sprite 默认 PPU,用于 referencePixelsPerUnit 换算
m_DynamicPixelsPerUnit float 1 World Space 世界空间动态内容(Text)像素密度
m_ReferencePixelsPerUnit float 100 所有模式 Sprite→UI 单位换算基准(写进 Canvas)
m_Canvas Canvas null 缓存的自身 Canvas 引用
m_PrevScaleFactor float (NonSerialized) 1 上一帧写出的 scaleFactor,用于去重
m_PrevReferencePixelsPerUnit float (NonSerialized) 100 上一帧写出的 refPPU,用于去重
kLogBase const float 2 Match 公式 对数几何平均的底数

几个容易被忽略的点:

  • m_ReferenceResolution 不是“主目标设备分辨率”,它是设计坐标系基准。你完全可以用 1920×1080 当基准去做一款最终跑在 1280×720 上的游戏,只要 UI 坐标/布局/资产流程都以此为准即可。
  • m_PrevScaleFactor / m_PrevReferencePixelsPerUnit 标了 [NonSerialized],意味着它们不进序列化、不存档,只是运行期去重用的“上一帧缓存”。
  • m_ReferencePixelsPerUnit 默认值 100 是个关键约定,但它不等于“UI 里 100 像素 = 1 单位”(那是世界坐标 SpriteRenderer 的规则)。在 UGUI(Image)体系下,它和 Sprite 的 PPU(默认也 100)共同决定 Image 显示尺寸:当二者相等时 Image.pixelsPerUnit = 1,100×100 像素 Sprite 的 native 尺寸 = 100×100 单位(即 1 UI 单位 ≈ 1 参考像素)。这是 UGUI 的隐含约定,改了要全项目对齐(详见第 11 节推导)。

3. 三个枚举

3.1 ScaleMode(总开关,三选一)

public enum ScaleMode
{
    ConstantPixelSize,    // 固定像素:不自动算,手写 Scale Factor
    ScaleWithScreenSize,  // 跟随屏幕分辨率
    ConstantPhysicalSize  // 跟随物理尺寸(DPI)
}

注意:这三个都只针对 Screen Space(Overlay / Camera)。World Space 走另一条独立分支 HandleWorldCanvas(),根本不看这个枚举。

3.2 ScreenMatchMode(仅在 ScaleWithScreenSize 内生效)

public enum ScreenMatchMode
{
    MatchWidthOrHeight, // 按 Match 值在宽、高倍率间插值
    Expand,             // 取较小倍率 → 逻辑画布“包住”参考分辨率
    Shrink              // 取较大倍率 → 逻辑画布“被参考分辨率包住”
}

3.3 Unit(仅在 ConstantPhysicalSize 内生效)

public enum Unit
{
    Centimeters, // 厘米
    Millimeters, // 毫米
    Inches,      // 英寸
    Points,      // 点(1/72 英寸)
    Picas        // 派卡(1/6 英寸)
}

这些单位的本质是一张“每单位对应的 DPI 表”(见第 9 节)。


4. 生命周期与触发流程(什么时候算)

protected override void OnEnable()
{
    base.OnEnable();
    m_Canvas = GetComponent<Canvas>();          // 缓存自身 Canvas
    Handle();                                    // 进场景立刻算一次
    Canvas.preWillRenderCanvases += CanvasScalerOnPreWillRenderCanvases;
    Canvas.willRenderCanvases    += CanvasScalerOnWillRenderCanvases;
}

protected override void OnDisable()
{
    base.OnDisable();
    Canvas.preWillRenderCanvases -= CanvasScalerOnPreWillRenderCanvases;
    Canvas.willRenderCanvases    -= CanvasScalerOnWillRenderCanvases;
}

protected virtual void CanvasScalerOnPreWillRenderCanvases() { }

protected virtual void CanvasScalerOnWillRenderCanvases()
{
    Handle();   // 每次渲染前由 Canvas 触发,重算并写入 scaleFactor / referencePixelsPerUnit
}

两个关键点

  1. 真正的"每帧驱动"来自 Canvas.willRenderCanvases 事件,不是 Update()。Canvas 在准备渲染前会广播这个静态事件,所有订阅的 CanvasScaler 都趁机 Handle() 一遍。这就是为什么你改了分辨率/窗口大小,UI 下一帧就变——因为渲染前一定走一遍。→ 这条机制为什么成立、事件由谁触发、在帧循环哪一步,详见 4.1 深入拆解
  2. OnEnable 里先 Handle() 一次:编辑器里(或刚进场景)立刻算一次,保证没等渲染就已经有正确比例;同时 [ExecuteInEditMode] 让这条在 Scene 视图也生效。
  3. OnDisable 严谨退订:否则对象被禁用/销毁后事件还指着它,会空引用或污染其他 Canvas。
  4. preWillRenderCanvases 是给子类(或将来扩展)留的钩子,基类里是空实现;真正的逻辑都在 willRenderCanvases 那条。

引擎侧印证(UICanvas.bindings.cs):willRenderCanvases / preWillRenderCanvasesCanvas 上的 static event WillRenderCanvases,文档原话“called just before Canvas rendering happens”,正是 CanvasScaler 借以重算的时机。

4.1 深入拆解:什么叫"真正的每帧驱动来自 Canvas.willRenderCanvases 事件"

上面那一句"不是 Update()"其实信息量很大。要真正理解,得把事件本身是什么、谁每帧触发它、它在帧循环里站在哪个位置、为什么偏偏挑这个位置这四件事讲清楚。下面逐条拆。

4.1.1 事件本身是什么 —— 一个全局静态事件

先看引擎侧(UICanvas.bindings.cs)的真实定义:

// 行 360:事件用的委托,注意它是“无参”的
public delegate void WillRenderCanvases();

// 行 364:渲染“前”的小钩子(给子类/扩展用)
[AutoStaticsCleanupOnCodeReload]
public static event WillRenderCanvases preWillRenderCanvases;

// 行 368:渲染"前"的主事件 —— CanvasScaler 订阅的就是它
[AutoStaticsCleanupOnCodeReload]
public static event WillRenderCanvases willRenderCanvases;

几个要点:

  • 它是 static event,不是实例事件。这意味着:整个进程里只有一个 Canvas.willRenderCanvases 事件对象,所有 CanvasScaler(无论挂在哪个 Canvas 上)都往同一个事件上 +=。事件一触发,所有订阅者同时收到调用。
  • 委托 WillRenderCanvases() 无参无返回值。所以它只是个"闹钟",不往回调里塞数据(不会告诉你"哪块画布要渲染""分辨率变了没")。CanvasScaler 自己通过 m_Canvas 缓存 + renderingDisplaySize 去重新取值,这也是为什么 §4 里 OnEnable 要先 GetComponent<Canvas>() 缓存它。
  • [AutoStaticsCleanupOnCodeReload]:静态事件在代码热重载(domain reload,比如你改了脚本 Unity 重新编译)时会被自动清空订阅。这反过来说明——为什么 OnDisable 里必须 -=。如果不退订,旧的(已被销毁的)CanvasScaler 实例可能还"挂"在事件上;更糟的是热重载后旧委托链可能被引擎清掉一次,你再 + 一次就乱套。严谨的退订是对这条引擎约定的呼应。

4.1.2 谁在每帧触发它 —— 是 Native 引擎,不是你的 C# 代码

事件的"触发函数"也写在 bindings 里,但关键是它的修饰符:

// 行 576-580:pre 事件发射
[RequiredByNativeCode]
private static void SendPreWillRenderCanvases()
{
    preWillRenderCanvases?.Invoke();
}

// 行 582-586:主事件发射
[RequiredByNativeCode]
private static void SendWillRenderCanvases()
{
    willRenderCanvases?.Invoke();
}

[RequiredByNativeCode] 是整件事的命门:它表示这俩 SendXxx 方法是被 C++ 引擎侧主动调用的,不是被 C# 的 Update/LateUpdate 调的。也就是说——

每帧渲染流程走到"该画 UI 了"这一步时,是引擎 native 代码反过来调进 C#,把 SendWillRenderCanvases() 跑一遍,进而 willRenderCanvases?.Invoke(),把事件广播出去。

所以"每帧驱动"的"每帧"二字,指的是引擎的渲染帧,不是你脚本的 Update 帧。二者绝大多数时候重合,但本质来源不同:事件来自渲染管线,Update 来自脚本调度。CanvasScaler 刻意选择"挂靠渲染管线的事件",而不是自己写 Update()

4.1.3 它在帧循环里的位置(时序)

把一帧里相关环节排个序(这张图是"时间先后"的快照,不是"谁调用谁"的调用栈——箭头 表示时序推进,不表示 A 调用 B):

┌─────────────────────────────────────────────────────────┐
│ 1. 你自己的 MonoBehaviour.Update()                       │
│      (此时可能改了屏幕分辨率 / 窗口尺寸 / 设备旋转)          │
│ 2. LateUpdate()                                         │
│ 3. 引擎收尾:布局/裁剪/批处理准备(native 内部)              │
│ 4. ★ 引擎准备渲染 Canvas → 调 SendPreWillRenderCanvases()│
│      └─> preWillRenderCanvases   →(空实现钩子)           │
│ 5. ★ 引擎调 SendWillRenderCanvases()                    │
│      └─> willRenderCanvases      → CanvasScaler.Handle()│
│          └─ 重算 scaleFactor / referencePixelsPerUnit   │
│ 6. Canvas 用"第 5 步刚写进去的新值"真正光栅化渲染            │
└─────────────────────────────────────────────────────────┘

可以看到:CanvasScaler 的 Handle() 发生在第 5 步,紧挨着真正的渲染(第 6 步)。它比 Update(第 1 步)晚一整轮。

⚠️ 关于"是不是 LateUpdate 驱动了 SendPreWillRenderCanvases"——直接回答两个猜测

两个猜测都不对,或者说都只说对了一半:

  • (A) "LateUpdate 驱动 SendPreWillRenderCanvases" → 错。 二者没有任何因果/调用关系。回顾 4.1.2:SendPreWillRenderCanvases() 标了 [RequiredByNativeCode],是引擎 native 渲染管线自己调的;而 LateUpdate引擎脚本调度器在帧末回调的 C# 方法。它们是两个互不相干的引擎子系统,LateUpdate 的代码里不会、也不可能去调 SendPreWillRenderCanvases。图里第 2 步在第 4 步"上面",只表示时间上更早发生,绝不表示第 2 步"导致"了第 4 步。
  • (B) "LateUpdate 和 SendPreWillRenderCanvases 频率一致" → 也不严谨。 它们确实"都大约每帧一次",但频次模型不同
    • LateUpdate每个定义了它的 MonoBehaviour 实例,每帧各调一次 → 一帧里可能有 N 次N = 挂了 LateUpdate 的脚本数)。
    • SendWillRenderCanvases整个进程每帧只 Invoke 一次那个全局静态事件(willRenderCanvases?.Invoke() 这一句只跑一遍),随后每个订阅者(CanvasScaler.Handle)作为事件的副作用各跑一次。所以它本质是"1 次事件广播 → N 个订阅者各响应一次",不是"每帧 N 次事件"。
    • 所以严格说:二者都是"每帧级别"的发生频率,但 LateUpdate 是 per-behaviour 频次、willRenderCanvases 是 per-frame 单次广播,不能简单画等号成"频率一致"。
      那到底谁驱动 SendWillRenderCanvases?只有两个来源:
  1. 引擎渲染管线自动驱动(正常路径):每帧渲染前,native 端走到 Canvas 更新阶段,自动 SendPreWillRenderCanvases()SendWillRenderCanvases()
  2. 手动 Canvas.ForceUpdateCanvases()(主动路径):你或某段代码显式调它,它内部就是 SendPre → SendWill(见 4.1.5 的源码)。注意——ForceUpdateCanvases 本身也不是 LateUpdate 调的,除非你自己在某个 LateUpdate 里写了 Canvas.ForceUpdateCanvases(),那才构成"LateUpdate → 事件"的间接链路,但那是你手写的逻辑,不是 CanvasScaler 的默认行为。
    一句话收口:LateUpdate 和 willRenderCanvases 是引擎在同一帧的不同子阶段各自独立安排的(脚本阶段 vs UI 渲染阶段),前者在前、后者更靠后;它们"都在帧末附近发生"只是巧合式的时序重叠,不存在驱动关系,也不存在严格一致的频率。这层关系图只能看"先后",不能看"因果"。

4.1.4 为什么偏偏要放在"渲染前" —— 引擎自己的设计理由

UICanvas.bindings.csForceUpdateCanvases() 上方留了一段极关键的 remarks(行 567),直接讲清了设计哲学:

"A canvas performs its layout and content generation calculations at the end of a frame, just before rendering, in order to ensure that it's based on all the latest changes that may have happened during that frame. This means that in the Start callback and the first Update callback, the layout and content under the canvas may not be up-to-date."

翻译过来就是:

  • "延迟到帧末、渲染前"是为了吃到这一帧里发生的所有最新改动。 比如你的 Update() 在第 1 步改了分辨率/窗口大小,CanvasScaler 在第 5 步才读 renderingDisplaySize——此时读到的是更新后的值。如果它放在 Update 阶段(第 1 步)算,可能读到的是上一帧的旧尺寸。
  • "在 Start 和首个 Update 回调里,画布下的布局/内容可能还是过时的。" 这正是为什么 CanvasScaler 不靠 Update:刚启动那一帧,渲染还没发生,layout 还没算好,你 Update 里拿到的尺寸不准。挂在"渲染前事件"上,则保证每次重算都基于"即将渲染"的确定性状态。
  • 一句话:把"算比例"这件事推迟到信息最全的时刻(一切帧内改动都已落定、马上就要画了),而不是自作主张在 Update 里提前算一版半成品。

4.1.5 preWillRenderCanvaseswillRenderCanvases 的区别与顺序

事件其实有两个,顺序由 ForceUpdateCanvases()(行 570-574)定调:

// 行 570-574:手动强制刷新也会按这个顺序
public static void ForceUpdateCanvases()
{
    SendPreWillRenderCanvases();   // 先 pre
    SendWillRenderCanvases();      // 再 will
}
  • preWillRenderCanvases:字面"渲染前的前奏"。基类 CanvasScaler 里对应的 CanvasScalerOnPreWillRenderCanvases()空实现protected virtual void ... { }),它就是给"你想在真正重算之前插一脚"留的扩展点(比如某些自定义 Scaler 子类要在主逻辑前先准备点什么)。
  • willRenderCanvases:真正干活的主事件,CanvasScaler 的 CanvasScalerOnWillRenderCanvases()Handle() 就挂在它上面。
  • 顺序上 pre 一定先于 will。引擎每帧的发射也遵循同一顺序(SendPreSendWill 之前被调用)。所以你若同时扩展两个钩子,能保证 pre 的副作用先发生。

补充:ForceUpdateCanvases() 是你手动触发整条链路用的(比如你改了什么想立刻让画布重算,不必等下一帧)。它内部的顺序再次印证了"pre 先于 will"这个契约。

4.1.6 和"改了分辨率下一帧就变"的因果链

用户最直观的体感是:"我把窗口拖大/切到副屏,UI 下一帧就自适应了。" 用上面的机制串一下:

  1. 窗口尺寸变化由操作系统/引擎在某一帧的 Update 阶段(或更早)更新了底层 display 尺寸;
  2. 同帧走到第 4/5 步,引擎触发 willRenderCanvases
  3. CanvasScaler 的 Handle() 被调用,此时 m_Canvas.renderingDisplaySize(见 §10)已是新尺寸;
  4. HandleScaleWithScreenSize() 等用新尺寸重算 scaleFactorSetScaleFactor() 写入 m_Canvas.scaleFactor
  5. 第 6 步渲染直接用新 scaleFactor 投影——于是"下一帧就变"。

关键在于:你不需要自己写任何监听分辨率变化的代码,因为渲染前这件事必然发生,而 CanvasScaler 把"读最新尺寸→重算"绑定在了这个必然发生的事件上。这就是"事件驱动"比"自己 Update 轮询"优雅的地方——少一处手动登记,少一处遗漏。

4.1.7 一个常见误区:CanvasScaler 完全用不到 Update 吗?

对,官方的 CanvasScaler 本身一个 Update() 都没有。它的全部"每帧"逻辑都来自 willRenderCanvases 事件。容易混淆的点:

  • OnEnable 里的那次 Handle()(§4 代码第 119 行)不是每帧,它只在组件启用/进场景时跑一次,属于"初始化兜底",让 UI 在第一次渲染前就有正确比例;
  • 如果你自己写了一个 CanvasScaler子类,并 override 了 CanvasScalerOnPreWillRenderCanvases()(那个空钩子),那你确实在"渲染前的前奏"插了逻辑,但它依然是通过事件触发,不是 Update
  • 真正可能用到 Update 的是别的组件(比如某个 ResolutionWatcherUpdate 里读 Screen.width 然后改 Scaler 的某个参数)——但那属于"喂数据",重算比例这件事始终在事件里。

4.1.8 一句话总结

Canvas.willRenderCanvases 是一个由引擎 native 代码在每帧渲染前广播的全局静态事件CanvasScalerOnEnable 时往它身上挂了 Handle(),于是"每帧要不要重算比例"完全由渲染管线节奏决定,而不是由某个脚本的 Update 轮询决定。这样设计的好处是:永远在帧内所有改动都已落定、马上要画的瞬间才重算,吃到最新尺寸、避免首帧半成品,且无需任何手动分辨率监听代码。


5. Handle() 总调度

protected virtual void Handle()
{
    if (m_Canvas == null || !m_Canvas.isRootCanvas)
        return;

    if (m_Canvas.renderMode == RenderMode.WorldSpace)
    {
        HandleWorldCanvas();
        return;
    }

    switch (m_UiScaleMode)
    {
        case ScaleMode.ConstantPixelSize:    HandleConstantPixelSize();    break;
        case ScaleMode.ScaleWithScreenSize:  HandleScaleWithScreenSize();  break;
        case ScaleMode.ConstantPhysicalSize: HandleConstantPhysicalSize(); break;
    }
}

两个边界分支,必须记牢

  • 分支一(非 Root 直接 return)m_Canvas.isRootCanvas 为 false 就什么都不做。挂子 Canvas 上的 Scaler 是“哑”的,比例由父 Root Canvas 决定。这是第 1 节“只服务 Root Canvas”的代码级落地。
  • 分支二(World Space 优先)renderMode == WorldSpace跳过整个 m_UiScaleMode 的 switch,直接 HandleWorldCanvas()。也就是说 World Space 根本不认 ScaleMode 三件套,它只看 m_DynamicPixelsPerUnitm_ReferencePixelsPerUnit

引擎侧依据(UICanvas.bindings.cs):

  • renderModeextern RenderMode renderMode,枚举 ScreenSpaceOverlay = 0 / ScreenSpaceCamera = 1 / WorldSpace = 2
  • isRootCanvasextern bool isRootCanvas —— 引擎在 UI::Canvas 里判定自己是不是根画布。

6. 两个输出 & 写入缓存机制

所有 HandleXxx 最终都会调用下面两个方法,而不是直接 m_Canvas.scaleFactor = xxx

protected void SetScaleFactor(float scaleFactor)
{
    if (scaleFactor == m_PrevScaleFactor)
        return;                               // 没变就跳过,避免无效写入
    m_Canvas.scaleFactor = scaleFactor;      // 写进引擎 Canvas
    m_PrevScaleFactor = scaleFactor;         // 更新缓存
}

protected void SetReferencePixelsPerUnit(float referencePixelsPerUnit)
{
    if (referencePixelsPerUnit == m_PrevReferencePixelsPerUnit)
        return;
    m_Canvas.referencePixelsPerUnit = referencePixelsPerUnit;
    m_PrevReferencePixelsPerUnit = referencePixelsPerUnit;
}

为什么要做这个缓存

  • Canvas.scaleFactor / referencePixelsPerUnit 一旦改变,引擎侧 UI::Canvas 认为“画布变了”,可能触发后续重建(Rebuild)/ 重排。每帧无脑赋值会造成无意义的重建抖动
  • 先比对 m_PrevXxx,数值没变就 return,等于“脏检查”。只有真正变化时才写一次 Canvas。
  • 这是 UGUI 里很典型的“写前去重”手法(类似 Graphicm_SkipLayoutUpdate / 脏标记)。

引擎侧这两个属性是什么UICanvas.bindings.cs):

// 行 429-430
public extern float scaleFactor { get; set; }
// “Scales the entire canvas, ensuring it fits the screen. It only applies when Canvas.renderMode is set to Screen Space.”

// 行 431-433
public extern float referencePixelsPerUnit { get; set; }
// “The number of pixels per unit that is considered the default.”
// Sprites 有自身 PPU,和 Canvas 的 referencePixelsPerUnit 一致时像素密度 1:1。

注意 scaleFactor 的文档写“only applies when Screen Space”,但 World Space 的 HandleWorldCanvas 其实也会 SetScaleFactor(m_DynamicPixelsPerUnit)——所以更准确的说法是:屏幕空间下 scaleFactor 决定整体像素缩放;世界空间下 scaleFactor 被设成动态像素密度,作用维度不同(见第 10 节)。文档字符串是偏屏幕空间的简化表述。


记住:第 6 节说到,Scaler 每帧只往外吐两个数——scaleFactorreferencePixelsPerUnit。接下来这三节(7 / 8 / 9)我们就挨个看,三种屏幕模式各自是怎么算出这个 scaleFactor 的。先说最简单、但也最容易被误会的 Constant Pixel Size。(顺带一提,它就是 m_UiScaleMode 的默认值,所以你啥都不配时走的就是这一档。)

7. Constant Pixel Size(固定像素模式)

一句话记住:这一档 Scaler 完全不替你算。你在 Inspector 里填多少 Scale Factor,它就原样写进 Canvas。窗口怎么变,UI 都按“死像素尺寸”渲染。

7.1 源码就这两行,但信息量不小

protected virtual void HandleConstantPixelSize()
{
    SetScaleFactor(m_ScaleFactor);
    SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit);
}

它不读分辨率、不读 DPI,就是把手填的两个值直接丢给第 6 节那两个“写前去重”的方法。所以这一档的 scaleFactor 完全由你拍板——Scaler 当了个纯传声筒。

7.2 用一张图看懂它在干嘛

Constant Pixel Size:scaleFactor 直接决定“1 单位 = 几像素” 窗口示意 800×600 100×40 1 单位 = 1 像素 → 渲染 100×40 px 放大 窗口示意 800×600 100×40 单位 1 单位 = 2 像素 → 渲染 200×80 px(变胖) 关键点:窗口分辨率完全不参与计算。 把窗口从 800 拖到 1920,按钮像素数不变,只是相对窗口“显得更小”。 这就是它“不随屏幕适配”的字面意思——它只认你手填的 scaleFactor。

图里左边 scaleFactor = 1:一个 100×40 单位的按钮,渲染出来就是 100×40 像素,1 单位 = 1 像素,最朴素。右边 scaleFactor = 2:同样是 100×40 单位,渲染出来变成 200×80 像素——按钮“变胖了”,因为 1 个单位现在顶 2 个像素。

7.3 举个具体数字

你填的 Scale Factor 1 个 UI 单位 = 100×40 的按钮渲染成 观感
1 1 像素 100×40 px 标准
2 2 像素 200×80 px 整体放大、元素变胖
0.5 0.5 像素 50×20 px 整体缩小

7.4 什么时候用它

  • 调试期想锁定像素:你只想让 UI 死尺寸显示,不想被任何自适应逻辑干扰。
  • 你自己外面有一套适配逻辑:比如你写了个分辨率 watcher,自己算好比例填进来,Scaler 只当传声筒。
  • 纯临时场景:快速验证布局时图省事。

7.5 别踩的坑

  • “Constant Pixel Size 是不是完全不缩放?” 不是。它缩放值是你定的,只是不随分辨率自动变。填 2 就是放大 2 倍,该缩放还是缩放。
  • 手机上 factor=1 的 100px 按钮,在 2K 屏上会显得很小:因为像素数恒定,屏幕越大相对越小。这正是它“不随屏幕适配”的代价——也是为什么手游几乎不用这一档。

8. Scale With Screen Size(跟随屏幕模式)

一句话记住:把“设计稿(Reference Resolution)”按比例铺到“真实屏幕”上。这是手游 / PC 自适应最常用的一档——你画一套 UI,它帮你适配各种分辨率。

8.0 为什么它最常用

你想想日常需求:美术按 1920×1080 出图,玩家手机可能是 1080×2340、平板 2048×1536、PC 窗口还能随便拖。你不可能为每种分辨率重画一套 UI。Scale With Screen Size 的思路就是:定一个“设计坐标系”(Reference Resolution),所有 UI 坐标都基于它;运行时再把这套坐标整体投影到真实屏幕。 投影比例就是接下来要算的 scaleFactor

8.1 源码骨架

protected virtual void HandleScaleWithScreenSize()
{
    Vector2 screenSize = m_Canvas.renderingDisplaySize;   // 当前渲染区域像素尺寸
    float scaleX = screenSize.x / m_ReferenceResolution.x;
    float scaleY = screenSize.y / m_ReferenceResolution.y;
    float scale;
    switch (m_ScreenMatchMode)
    {
        case ScreenMatchMode.MatchWidthOrHeight:
            float logWidth  = Mathf.Log(scaleX, kLogBase);
            float logHeight = Mathf.Log(scaleY, kLogBase);
            float logWeightedAverage = Mathf.Lerp(logWidth, logHeight, m_MatchWidthOrHeight);
            scale = Mathf.Pow(kLogBase, logWeightedAverage);
            break;
        case ScreenMatchMode.Expand:
            scale = Mathf.Min(scaleX, scaleY);
            break;
        case ScreenMatchMode.Shrink:
            scale = Mathf.Max(scaleX, scaleY);
            break;
        default:
            scale = 1;
            break;
    }
    SetScaleFactor(scale);
    SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit);
}

注意 screenSize 用的是 m_Canvas.renderingDisplaySize,不是 Screen.width/height。引擎侧(UICanvas.bindings.cs 行 499-507)确认它是“当前 UI 实际渲染区域的像素尺寸”,多屏时还会读对应 Display 的 renderingWidth/Height。所以多屏 / 副屏要拿 renderingDisplaySize,别硬写 Screen.width

8.2 先建立直觉:Reference 和真实屏幕的关系

先建立直觉:把“设计稿”按比例投影到“真实屏幕” 设计稿 Reference 800×600 真实屏幕 1600×900 按 scaleX / scaleY 投影 scaleX = 1600 / 800 = 2(横向要放大 2 倍) scaleY = 900 / 600 = 1.5(纵向要放大 1.5 倍) scaleX、scaleY 是所有子模式算出“最终那个比例”的地基,下面每一档都从它俩出发。

先把两个轴倍率定义清楚,后面每一档都从它俩出发:

  • scaleX = 屏幕宽 / 参考宽:横向要把设计稿放大几倍才铺满。
  • scaleY = 屏幕高 / 参考高:纵向要把设计稿放大几倍才铺满。

上面例子里屏幕比设计稿既宽又高(scaleX=2、scaleY=1.5),实际更常见的是宽高比不一致——比如设计稿 16:9,手机却是 9:16 竖屏,那时 scaleXscaleY 会一个大于 1、一个小于 1,差距很大。怎么从这两个倍率里“取一个数”当最终 scaleFactor,就是下面几档的分歧点。

8.3 三句话抓住核心 + 三档一览

  1. 先算出 scaleXscaleY(两轴各自的倍率)。
  2. 再按 Screen Match Mode 选“一个数”当最终 scaleFactor
  3. 这一个数同时作用于宽和高(不分开乘),所以 UI 永远等比缩放、不会被拉成椭圆(详见 8.6)。

三档的区别只在第 2 步“怎么取这个数”:

ScreenMatchMode 最终 scale 取什么 效果直觉
Match Width Or Height scaleXscaleY 的加权(对数)几何平均 在“纯按宽”和“纯按高”之间连续滑
Expand Min(scaleX, scaleY) 逻辑画布包住参考分辨率
Shrink Max(scaleX, scaleY) 逻辑画布参考分辨率包住

8.4 Match Width Or Height —— 最绕的一档,配图讲透

这一档是整篇最容易被人用“一句对数”带过的地方,我们配着图彻底拆开。

Match Width Or Height:在“纯宽”和“纯高”之间滑动 m=0 纯宽 m=1 纯高 m=0.5 m=0 纯宽:宽刚好·高溢出 m=0.5:宽留边·高溢出 m=1 纯高:高刚好·宽留边 Match 越偏宽 → 宽度越准、高度越易溢出;越偏高反之。中间是折中。本质是 scaleX、scaleY 的加权几何平均(见下公式)。

图里那个滑块就是 m_MatchWidthOrHeight:拖到最左(m=0)纯按宽,拖到最右(m=1)纯按高,中间是折中。右边三个小屏分别画了三种取值下,设计稿内容在真实屏幕里的落点:

  • m=0(纯宽)scale = scaleX = 2。横向正好铺满,但纵向被放大到 1200,超过屏幕 900 → 高度溢出(上下被裁)。
  • m=1(纯高)scale = scaleY = 1.5。纵向正好,横向只到 1200,没铺满 1600 → 宽度留边
  • m=0.5(折中):两边都“沾一点”,宽略留边、高略溢出,是妥协。

公式逐步推导(别怕,五步就完)

第 1 步:定义两个轴倍率

scaleX = 屏幕宽 / 参考宽
scaleY = 屏幕高 / 参考高

scaleX > 1 表示屏幕比设计稿宽(要放大),< 1 表示窄(要缩小)。同宽高比且正好等于基准时 scaleX = scaleY = 1

第 2 步:取对数

logWidth  = log₂(scaleX)
logHeight = log₂(scaleY)

为什么要取对数?因为我们要在“放大倍数”和“缩小倍数”之间做公平的几何平均,不是简单算术平均。

直观例子:宽方向要放大 2 倍(scaleX=2),高方向要缩小一半(scaleY=0.5)。

  • 算术平均:(2 + 0.5)/2 = 1.25 → 整体放大 1.25 倍。但这不对:宽放大 2、高缩小 0.5,几何上应该相互抵消成 1(2 × 0.5 = 1)。
  • 几何平均:√(2 × 0.5) = √1 = 1 → 正好 1,符合直觉。

对数把“乘法”变成“加法”,于是几何平均 = 对数的算术平均再指数回去

log₂(√(scaleX·scaleY)) = (log₂(scaleX) + log₂(scaleY)) / 2

第 3 步:按 Match 在两条对数轴上插值

logWeightedAverage = lerp(logWidth, logHeight, m)
                   = (1 - m)·logWidth + m·logHeight

m = 0 → 纯 logWidth(纯按宽);m = 1 → 纯 logHeight(纯按高);m = 0.5 → 两数平均。

第 4 步:指数还原

scale = 2^logWeightedAverage

代进第 3 步,用 a^((1-m)·x + m·y) = (a^x)^(1-m) · (a^y)^m,得到显式形式:

scale = scaleX^(1 - m) · scaleY^m

这就是加权几何平均(power mean)

第 5 步:性质验证

  • m = 0scale = scaleX ✔ 纯按宽
  • m = 1scale = scaleY ✔ 纯按高
  • m = 0.5scale = √(scaleX·scaleY) ✔ 标准几何平均
  • 宽放大 2、高缩小 0.5、m=0.5√(2 × 0.5) = 1 ✔ 互相抵消

关于底数 kLogBase = 2:源码写死 2,但换成任意合法底数结果不变——因为先取对数再指数,底数在中间被约掉了。以 2 为底只是人读着直观(放大 2 倍就是 +1、缩小一半就是 −1)。

8.5 Expand / Shrink —— 配图讲透“包住”和“被包住”

这两档源码都只有一行(Min / Max),但光看代码很容易误以为“引擎自动帮你留黑边 / 自动裁切”。配图看清楚。

Expand / Shrink:“包住”与“被包住” Expand(取 Min 倍率) 参考分辨率 逻辑画布包住参考 → 内容一定全显示 Shrink(取 Max 倍率) 逻辑画布被参考包住 → 内容可能超出被裁 Scaler 只给一个比例,不主动留黑边也不主动裁切——留白/裁切是锚点 + 布局的事。
  • Expand(取较小倍率 Min:因为取的是 scaleX/scaleY 里更小的那个,算出来的逻辑画布宽和高都不会小于 Reference Resolution。图左能看到:逻辑画布比参考分辨率大一圈,设计稿内容稳稳坐在中间,一定全显示,多出来的边是“可显示更多内容”的空间(留白还是铺满由你的锚点/背景决定)。
  • Shrink(取较大倍率 Max:逻辑画布宽和高都不会大于 Reference Resolution。图右:逻辑画布被参考分辨率“包”在里面,设计稿超出逻辑画布的那块可能看不见(是否真裁掉取决于裁剪/遮罩,Scaler 不负责裁)。

关键认知:CanvasScaler 只给出一个统一比例,它不创建黑边、也不主动裁切 UI 对象。最终是留空、内容超出、还是锚点填满,全是 RectTransform 锚点 + 布局 + 背景 + 内容范围的事。把 Expand/Shrink 当成“引擎帮你留边/裁边”是常见误解。

8.6 为什么 UI 永远不被压扁

同宽高比窗口从 1920×1080 变成 1280×720:scaleX == scaleY(都是 1.5 倍缩小),三档算出的比例相同,UI 等比缩小,不会变形。

宽高比变化时(比如 16:9 切到 4:3),scaleX ≠ scaleY,但 CanvasScaler 仍然输出单一 scaleFactorMin/Max/加权平均都是一个数),所有 UI 元素被统一缩放。变化的是“逻辑画布范围”和“锚定布局结果”,而不是 X/Y 非均匀变形。UGUI 的 UI 元素永远不会被 Scaler 拉成椭圆——这点要和 RectTransformlocalScale 非均匀拉伸区分开,Scaler 不碰 localScale。

8.7 选型小抄

你的屏幕情况 推荐 理由
目标设备宽高比和参考一致(或接近) 三档都行,Match 随便拖 scaleX≈scaleY,比例一样
横屏手游,怕上下被裁 Expand 或 Match 偏宽 保住高度内容不溢出
竖屏手游,怕左右留边太多 Match 偏宽 / Expand 让宽度尽量铺满
PC 可缩放窗口 Scale With Screen Size(Match 0.5 起手) 折中最稳,再按项目调
内容必须 100% 可见、宁肯留边 Expand 逻辑画布包住参考,内容全显示

9. Constant Physical Size(跟随物理尺寸 / DPI)

protected virtual void HandleConstantPhysicalSize()
{
    float currentDpi = Screen.dpi;
    float dpi = (currentDpi == 0 ? m_FallbackScreenDPI : currentDpi);

    float targetDPI = 1;
    switch (m_PhysicalUnit)
    {
        case Unit.Centimeters: targetDPI = 2.54f;  break;
        case Unit.Millimeters: targetDPI = 25.4f;  break;
        case Unit.Inches:      targetDPI = 1;      break;
        case Unit.Points:      targetDPI = 72;     break;
        case Unit.Picas:       targetDPI = 6;      break;
    }

    SetScaleFactor(dpi / targetDPI);
    SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit * targetDPI / m_DefaultSpriteDPI);
}

推导 scaleFactor = dpi / targetDPI

dpi 的单位是“像素/英寸”(Screen.dpi)。targetDPI 这张表其实是“每 1 个 UI 单位对应的英寸数 → 换算成 DPI”:

Unit targetDPI 值 含义
Inches 1 1 单位 = 1 英寸 → 1 英寸 = dpi 像素 → scaleFactor = dpi/1 = dpi
Centimeters 2.54 1 英寸 = 2.54 厘米 → 1 厘米 = dpi/2.54 像素 → scaleFactor = dpi/2.54
Millimeters 25.4 1 厘米 = 10 毫米 → 1 毫米 = dpi/25.4 像素 → scaleFactor = dpi/25.4
Points 72 1 英寸 = 72 点 → 1 点 = dpi/72 像素
Picas 6 1 英寸 = 6 派卡 → 1 派卡 = dpi/6 像素

所以 scaleFactor = dpi / targetDPI 的物理意义是:“1 个 UI 单位在目标物理单位下应该占多少输出像素”。比如选 Centimeters,scaleFactor = dpi/2.54,于是 1 单位 = 1 厘米,无论屏幕 DPI 多少,按钮物理长度稳定。

推导 referencePixelsPerUnit

referencePixelsPerUnit = m_ReferencePixelsPerUnit · targetDPI / m_DefaultSpriteDPI

它的作用是把 Sprite 的 PPU 链也拉到同一套物理基准上:当 Sprite 自身 PPU 等于 m_DefaultSpriteDPI 时,经过 Image.pixelsPerUnit = spritePPU / referencePixelsPerUnit 这一换算,Sprite 会以“每目标物理单位 targetDPI 像素”的密度显示,和 scaleFactor 的物理语义自洽。

痛点与局限

  • 完全依赖 Screen.dpi。设备报 0 只能退 m_FallbackScreenDPI(默认 96,等于“按桌面假设”);设备报值不准(很多安卓机 DPI 是糊弄的),物理尺寸就偏。
  • 所以它是“确实以屏幕物理长度为需求”的 Screen Space UI 才用(比如需要保证按钮在手上真有 1 厘米大),不是普通分辨率自适应的默认答案

10. World Space(世界空间,独立分支)

protected virtual void HandleWorldCanvas()
{
    SetScaleFactor(m_DynamicPixelsPerUnit);
    SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit);
}
  • 不进入 m_UiScaleMode 的 switch,直接走这条。
  • SetScaleFactor(m_DynamicPixelsPerUnit):这里的 scaleFactor 不是“整体像素缩放”,而是控制 Text 等运行期动态生成位图内容的像素密度(Dynamic Pixels Per Unit)。
  • SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit):依旧设 Sprite→UI 单位基准。

为什么 World Space 用 Dynamic Pixels Per Unit 而不是 Scale Factor
世界空间 Canvas 的真实世界大小由 RectTransform + Canvas + 祖先 Transform 缩放共同决定(它是场景里的一个真实物体)。“1 Unity unit = 1 米”只是项目约定,不是 CanvasScaler 强制语义。Scaler 在这里能直接影响的,是 Text 这种“用 Font 临时光栅化出一张位图”的内容——密度给低了,镜头拉近就糊;给高了,字体图集/CPU 更新/显存/填充率成本上升。合理值必须结合相机距离和目标平台实测,没有“套一个范围就完事”的魔法数字。

VR/AR 贴墙 UI 的提醒:想让面板在物理上贴墙 1 米宽,应该去设计“世界尺寸 + 像素密度”,不能依赖 Constant Physical Size——因为 World Space 分支根本不执行 HandleConstantPhysicalSize()。把物理尺寸需求往 Constant Physical Size 上靠是典型踩坑。


11. 两个输出在引擎/下游怎么被消费(含完整公式推导)

CanvasScaler 只“生产”两个值:scaleFactorreferencePixelsPerUnit(World 下 dynamicPixelsPerUnitscaleFactor 通道送出)。这俩负责不同层次的换算。下面把每条转换的公式、每部分概念、所用原理、机制全部拆开。

11.1 转换一:scaleFactor —— UI 逻辑单位 → 输出像素

公式

输出像素      = UI 逻辑单位 × scaleFactor
UI 逻辑单位   = 输出像素   ÷ scaleFactor

也常写成“画布尺寸”版本(和上面等价,只是把“输出像素”换成 renderingDisplaySize 量级):

逻辑画布尺寸(单位) = 屏幕像素尺寸 ÷ scaleFactor
屏幕像素尺寸       = 逻辑画布尺寸(单位) × scaleFactor

公式每部分是什么概念

符号 概念
输出像素 最终在帧缓冲/屏幕上占据的像素数,量级等于 Canvas.renderingDisplaySize
UI 逻辑单位 所有 RectTransform 布局使用的单位,处于“参考分辨率坐标系”
scaleFactor CanvasScaler 算出的单一标量比例;只做缩放,不旋转、不剪切、X/Y 不分轴

用到的原理:uniform scale mapping(均匀缩放映射)

把“逻辑单位空间”线性映射到“物理像素空间,用一个标量比例完成。标量(而非 2×1 向量或矩阵)保证:

  • 所有 UI 元素等比缩放;
  • 不会被拉成椭圆/矩形(呼应第 8.4 节“UI 不会被压扁”);
  • 与 Transform 层级天然兼容——根 Canvas 一个比例,全体子节点继承。

机制(引擎侧如何落地)

  • 屏幕空间下,Canvas 把 Root Canvas 的 RectTransform逻辑尺寸设为 renderingDisplaySize / scaleFactor,再施加一个 scaleFactor 的缩放,使渲染时正好铺满 renderingDisplaySize 像素。
  • 实测行为(官方实现):scaleFactor = 1 时,800×600 屏幕 → 逻辑画布 800×600;scaleFactor = 2 时,逻辑画布被压成 400×300,再被放大 2 倍渲染回 800×600。可见 “1 个逻辑单位 = scaleFactor 个像素”
  • 所有子 UI 通过 Transform 层级继承这个根缩放,所以“改 scaleFactor”等价于把整棵 UI 树一次性缩放,无需逐顶点改、也无需动 RectTransform.localScale——这正是第 0/5 节反复强调的“CanvasScaler 不碰顶点、不碰 localScale”的根本原因。
  • 引擎属性:Canvas.scaleFactorUICanvas.bindings.cs 行 429-430,extern,背后是 UI::Canvas)、renderingDisplaySize(行 499-507)。

11.2 转换二:referencePixelsPerUnit —— Sprite 像素 → UI 单位

公式

Sprite 自带 pixelsPerUnit(导入设置,默认 100,记为 spritePPU)。Image 综合 Sprite 与 Canvas 算出自己的有效密度:

// Image.cs
public float pixelsPerUnit
{
    get
    {
        float spritePixelsPerUnit = 100;
        if (activeSprite != null) spritePixelsPerUnit = activeSprite.pixelsPerUnit;
        float referencePixelsPerUnit = 100;
        if (canvas != null) referencePixelsPerUnit = canvas.referencePixelsPerUnit;
        return spritePixelsPerUnit / referencePixelsPerUnit;   // ← 核心公式
    }
}

由此得到显示尺寸(即 SetNativeSize 所用的关系):

UI 单位 = Sprite 源像素 / Image.pixelsPerUnit
        = Sprite 源像素 × referencePixelsPerUnit / spritePPU

等价写法(与本文前述一致,令 effectivePPU = spritePPU / referencePixelsPerUnit):

UI 单位 = Sprite 源像素 ÷ effectivePPU

公式每部分是什么概念

符号 概念
Sprite 源像素 Sprite 在图集/纹理里的像素尺寸(sprite.rect.width/height
spritePPU 该 Sprite 的源像素密度(导入时设定,“每 Unity 单位吃多少源像素”)
referencePixelsPerUnit Canvas 的参考像素密度,全局统一(默认 100)
Image.pixelsPerUnit(= effectivePPU) 归一化后的“有效密度”,决定 1 UI 单位吃多少源像素
UI 单位 RectTransform 布局单位——和 11.1 的“UI 逻辑单位”是同一套单位(关键衔接点)

用到的原理:PPU 归一化(Pixels-Per-Unit normalization)

把所有 Sprite 各异的源像素密度,相对 Canvas 的参考密度归一化

  • spritePPU == referencePixelsPerUnit(都 100)→ Image.pixelsPerUnit = 1 → UI 尺寸 = Sprite 源像素数(100×100 像素 → 100×100 单位,即 1 UI 单位 ≈ 1 参考像素)。这就是 UICanvas.bindings.cs 文档说的“相同 PPU 时密度 1:1”。
  • 二者不等时按比例缩放,保证不同 PPU 的美术图在同一 Canvas 下视觉密度一致。

机制(Image.cs 侧如何落地)

  • Image 缓存 m_CachedReferencePixelsPerUnit,在 pixelsPerUnit 属性里取 sprite.pixelsPerUnit / canvas.referencePixelsPerUnit
  • SetNativeSize()w = overrideSprite.rect.width / pixelsPerUnit → 写入 rectTransform.sizeDelta(并把锚点收拢成一点,使 sizeDelta 即图片尺寸)。
  • OnPopulateMesh()spriteSize = size / pixelsPerUnit(size 取 sprite.rect),把源 rect 换算成绘制用的 UI 单位尺寸,再 fit 进 RectTransform
  • 分子/分母的牵动范围不同referencePixelsPerUnit分子,改它会放大/缩小 Canvas 下所有 Image(实测:RPPU 100→200,640px 图从 640 变 1280);spritePPU分母,改它只影响那一个 Sprite。所以工程上全局调密度应改 RPPU,单图调密度改 PPU——且别乱改 RPPU,会牵一发动全身(九宫格 border 也会跟着变粗/变细)。
  • 与世界坐标 SpriteRenderer 的区别:世界坐标里没有 RPPU,尺寸公式只是 world单位 = 像素 / PPU(PPU=100 → 100 像素 = 1 单位)。UI 体系里单位是“参考像素”,不是“100 像素 = 1 单位”。第 2 节最初误写“UI 里 100 像素 = 1 单位”,此处已更正。

11.3 两个转换的衔接:完整像素链

Sprite 源像素
  ──(÷ spritePPU × referencePixelsPerUnit)──▶  UI 逻辑单位   [转换二:像素 → 单位]
  ──(× scaleFactor)────────────────────────▶  屏幕输出像素   [转换一:单位 → 像素]

合并公式:

屏幕像素 = Sprite 源像素 × referencePixelsPerUnit / spritePPU × scaleFactor

两条链独立解耦:转换二解决“像素 → UI 单位”(密度/比例),转换一解决“UI 单位 → 屏幕像素”(整体缩放)。这也正是原 doc 那句“scaleFactor 与 PPU 解决不同层次,不能互换”的数学依据。

11.4 dynamicPixelsPerUnit(Text / 世界空间密度)

  • SetScaleFactor(m_DynamicPixelsPerUnit):World Space 下 scaleFactor 通道被复用为“动态内容像素密度”,主要供 Text 在运行期光栅化位图时决定每字符像素密度。
  • 数值越大近看越清晰,但字体图集/CPU/显存/填充率成本越高,需结合相机距离与平台实测,无固定“好值”。

11.5 物理尺寸链方向要记清(避免单位搞反)

  • 英寸 → 输出像素:乘 DPI像素 = 英寸 × dpi)。
  • 像素 → 英寸:除 DPI英寸 = 像素 / dpi)。
  • scaleFactor(单位 → 像素)与 referencePixelsPerUnit(像素 → 单位)方向相反、层次不同,不能互相替代。

12. 帧级调用链路(文字版时序)

[每帧渲染前]
  CanvasManager / Canvas 准备渲染
    └─> Canvas.SendWillRenderCanvases()
          ├─> preWillRenderCanvases  (CanvasScaler 空钩子,可扩展)
          └─> willRenderCanvases
                └─> CanvasScaler.CanvasScalerOnWillRenderCanvases()
                      └─> Handle()
                            ├─ 非 Root Canvas?  ── return
                            ├─ WorldSpace?      ── HandleWorldCanvas()
                            └─ 否则按 m_UiScaleMode:
                                 ConstantPixelSize / ScaleWithScreenSize / ConstantPhysicalSize
                            └─ SetScaleFactor() / SetReferencePixelsPerUnit()
                                 └─ 仅当数值变化才写 m_Canvas.*
                                        │
                                        ▼
                          Canvas 据此计算逻辑画布→像素映射
                                        │
                                        ▼
                  Graphic 重建(Rebuild):Image 用 referencePixelsPerUnit 算 PPU,
                  Text 用 scaleFactor(=dynamicPPU) 算位图密度,Layout 在逻辑坐标布局
                                        │
                                        ▼
                                  提交渲染

编辑器下([ExecuteInEditMode]OnEnable 还会额外 Handle() 一次,所以不进 Play 也能看到效果;运行时主要靠上面的 willRenderCanvases 事件驱动。


13. 工程选型速查表

场景 常用方案 关注点
手游固定方向 Scale With Screen Size 按布局约束选 Match / Expand / Shrink
PC 可缩放窗口 Constant Pixel Size 或 Scale With Screen Size 区分“固定像素需求”与“逻辑画布范围变化”
屏幕物理尺寸有硬要求 Constant Physical Size 实测设备 DPI,备好 Fallback
VR/AR、场景内面板 World Space 设计世界尺寸 + Dynamic Pixels Per Unit,别碰 Constant Physical Size

14. 常见误区 / FAQ

  1. “CanvasScaler 会改 RectTransform.localScale 或顶点?” 不会。它只写 scaleFactor / referencePixelsPerUnit 两个 Canvas 属性。
  2. “Expand = 自动黑边,Shrink = 自动裁切?” 错。它只给一个统一比例;黑边/裁切是锚点、布局、背景决定的。
  3. “宽高比变了 UI 会被压扁成椭圆?” 不会。Scaler 永远输出单一比例,UI 等比缩放;变的是逻辑画布范围和锚定结果。
  4. “World Space 能用 Constant Physical Size 保证 1 厘米?” 不能。World 走 HandleWorldCanvas,根本不跑 HandleConstantPhysicalSize
  5. “多屏/副屏要用 Screen.width?” 不行。用 m_Canvas.renderingDisplaySize,它才对应当前 Display 的真实渲染尺寸。
  6. “Match 底数 2 有什么讲究?” 没讲究,换成任意合法底数结果完全一样(见 8.2 第 5 步推导),纯表达方便。
  7. “Reference Resolution 必须等于主目标设备分辨率?” 不必。它是设计坐标系基准,选任意合理值都行,只要全项目坐标/布局/资产流程一致。
  8. “刘海屏/折叠屏安全区是 Scaler 管的?” 不是。安全区属于 RectTransform 锚点 + 布局层职责;Scaler 只管比例,不决定内容怎么排布。
posted @ 2026-09-09 22:43  昂流  阅读(4)  评论(0)    收藏  举报
//替换成自己路径的js文件 hhttp(s)://static.tctip.com/tctip-1.0.4.min.js