SLOW DAY · 3D LEARNING NOTES

从几何体到真实光影

用一间可漫游的 H5 咖啡店,理解基础 3D 模型、WebGL 渲染、PBR 材质、Blender 烘焙,以及 A → B → C → D 的优化路线。

3D 模型基础WebGL 渲染管线PBR 与 UVCycles 烘焙资源加载交互手机加载优化

01|先看结果:为什么“模型更多”不等于“更真实”

这间咖啡店最初由立方体、圆柱体和球体拼成。它能漫游,但像积木;后来精修近景模型、加入 PBR 材质,并用 Blender Cycles 烘焙光照和 AO,才开始像一个有空间氛围的室内场景。

初版纯色几何体咖啡店
初版 · 手写 WebGL纯色几何体 / 简单光照
Blender模型与烘焙版咖啡店
进阶版 · Blender + Three.jsPBR / 烘焙 / 实时补光

同一场景、同一漫游入口的前后交付截图。变化不只来自模型,还来自材质响应、接触阴影、间接光、曝光和构图。

75,646进阶版三角面
24浏览器统计绘制调用
2K × 2光照图与 AO 图
0运行时外部请求

核心观点:真实感的优先级通常是 光影与遮挡 → 材质响应 → 轮廓细节 → 后期效果。先堆高面数,往往只是得到“更精致的积木”。

进阶版咖啡店桌面端全景
进阶版桌面端实拍:近景有倒角和独立结构,静态光影来自 Blender 烘焙,漫游交互沿用初版。

02|基础 3D 模型:你看到的物体是怎样组成的

一个可渲染的 3D 物体,不只是一团“形状”。最少要理解下面这些概念:

顶点、边、面与三角形

顶点定义空间位置,边连接顶点,面围出表面。GPU 最终主要绘制三角形;四边面、多边形也会在导出或渲染时三角化。

法线 Normal

描述表面朝向,用来计算光线与表面的夹角。硬边、平滑法线和倒角会显著改变物体的观感。

UV 坐标

把二维图片贴到三维表面的坐标。可以把 UV 想成“包装盒展开图”:模型负责立体形状,UV 负责图片贴在哪里。

材质 Material

描述表面怎样响应光:颜色、粗糙度、金属度、透明度等。材质不是模型本身,也不是一张颜色图就结束。

变换 Transform

位置、旋转、缩放决定物体在空间中的状态。单位、坐标轴和原点不规范,会在导出后变成“模型飞了”。

场景 Scene

模型只是演员,还需要相机、灯光、环境和渲染器。没有相机,就没有可见画面;没有光,材质也无从表现。

倒角为什么比“多加几千条边”更值得

现实里的桌板、金属机身和柜门几乎没有绝对锋利的边。一个小倒角能产生高光边,让物体立刻更像制造出来的东西。倒角改善轮廓与高光,法线贴图改善细小凹凸,两者不能互相替代。

常见误区:法线贴图不会改变真实轮廓,也不会让杯子真正长出杯口厚度。近景轮廓交给模型,木纹、织物和细微凹凸交给贴图。

03|WebGL 在做什么:从数据到屏幕像素

WebGL 是浏览器提供的图形接口,让 JavaScript 能调用 GPU 绘制 2D/3D 图形。它本身不是建模软件,也不会自动提供灯光、材质或碰撞系统。

顶点、法线、UV
写入缓冲区
→
顶点着色器
变换空间位置
→
光栅化
三角形变像素
→
片元着色器
计算颜色
→
帧缓冲
显示到 Canvas

顶点着色器:决定“画在哪里”

初版代码使用模型、视图、投影三个矩阵,把局部坐标一步步变换到屏幕:

vec4 world = uModel * vec4(aPosition, 1.0);
gl_Position = uProjection * uView * world;
  • 模型矩阵 Model:物体自身的位置、旋转、缩放。
  • 视图矩阵 View:相机从哪里、朝哪里看。
  • 投影矩阵 Projection:透视、视野角、近远裁剪面。

片元着色器:决定“这个像素是什么颜色”

最基础的漫反射可以写成:表面越朝向光源越亮,背光面越暗。

float diffuse = max(dot(normalize(vNormal), lightDir), 0.0);
vec3 color = uBaseColor * (ambient + diffuse);

初版还叠加了点光、窗光近似和距离雾,但没有真实遮挡。因此,一个面即使被柜台挡住,仍可能按同一套公式得到亮度。

那 Three.js 又是什么

Three.js 是建立在 WebGL 之上的 3D 引擎。它把场景、相机、网格、材质、灯光、加载器和渲染器组织起来,减少手写缓冲区与着色器管理的成本。它不是 WebGL 的替代品,而是更高层的封装。

本项目的选择:初版用手写 WebGL 学习底层;进阶版用 Three.js 承载 glTF、PBR、烘焙贴图和色调映射。漫游、碰撞和界面逻辑则继续复用。

04|真实感从哪里来:模型、材质、烘焙各管一件事

组成主要负责典型内容不能替代什么
模型轮廓和结构杯口厚度、椅背、倒角、按钮不能自动产生真实光照
材质表面如何响应光颜色、粗糙度、金属度、法线不能改变大轮廓
烘焙预先计算静态结果AO、间接光、静态阴影不适合频繁变化的光和物体
实时光处理动态变化移动物体、可切换灯光、实时阴影需要持续消耗 GPU

PBR 材质里最常用的几张图

  • Base Color:表面本身的颜色,不应该把明显光影重复画进去。
  • Roughness:粗糙度。陶瓷、金属、木材和布料的高光差异,很大程度来自它。
  • Normal:用切线空间法线描述细小凹凸,让表面在光下产生细节。
  • AO:环境遮蔽,表现缝隙、接触面和角落不容易被环境光照到的程度。
  • Light Map:预计算的光照结果,可包含静态直接与间接光。

AO、光照贴图、实时阴影不要混为一谈

Cycles烘焙光照贴图
Light Map:保存光照分布,负责空间里的亮暗与色彩晕染。
Cycles烘焙AO贴图
AO:保存遮蔽程度,负责接触处、缝隙与角落的暗部。

实时阴影则是渲染时根据当前灯和物体重新计算。静态咖啡店适合把大量结果烘焙;如果灯要开关、桌椅要拖动,就需要实时光影补充。

相机会移动,不代表不能烘焙。只要物体和灯光保持固定,烘焙结果依然有效。第一人称在店内走动,只是换相机观察已经算好的光。

05|ABCD 递进关系:每一档到底在解决什么

这不是“越往后越正确”,而是从可运行 Demo 到商业展示的投入递增。B 与 C 也不是互相排斥:最实用的路线往往是 B 的静态基础 + C 的少量动态能力。

A

轻量润色:先摆脱积木感

做什么:保留手写 WebGL,加倒角、简单纹理、接触阴影,调整色温和雾。

解决:低成本提升观感。边界:光影仍是近似,材质系统有限。

C

实时光影:让动态变化也成立

做什么:动态软阴影、环境反射、SSAO、更多实时灯;可与 B 的烘焙叠加。

解决:灯光切换、物体移动、时间变化。边界:填充率、阴影和后期会增加手机压力。

D

展示级精修:画质与交付体系一起升级

做什么:完整室内资产、扫描材质、反射探针、烘焙与实时阴影结合、桌面/移动双档画质。

解决:商业空间展示与长期维护。边界:制作、压缩、加载和测试成本都明显增加。

A 建立基础,B 建立静态真实感,C 增加动态真实感,D 把画质、性能和交付做成完整体系。

06|B 方案实战:这间咖啡店具体怎样落地

1. 只精修视线附近的东西

吧台、咖啡机、桌椅、杯子和沙发是玩家会靠近观察的对象,因此有独立结构、杯口厚度、把手、圆角软包和金属倒角。墙体、天花和室外背景保持简单。

2. 材质使用法线与粗糙度,不盲目提高面数

木材、织物、石材和墙面使用本地生成的颜色与法线贴图,再分别设置粗糙度。金属咖啡机依赖环境反射,陶瓷杯子使用较低粗糙度,沙发则保留较高粗糙度。

3. Blender 负责静态空间与 Cycles 烘焙

场景合并为静态网格,保留材质分组;第一套 UV 用于颜色和法线,第二套 UV 用于光照与 AO。最终导出 glTF/GLB,H5 加载后把烘焙图绑定到第二套 UV。

// 本项目:glTF 的第二套 UV 对应 Three.js 的 channel 1
lightMap.flipY = false;
lightMap.channel = 1;
aoMap.flipY = false;
aoMap.channel = 1;

material.lightMap = lightMap;
material.aoMap = aoMap;

这里的 flipY = false 是配合本项目 glTF UV 的处理,不是任何图片都必须照抄的规则。

4. 实时光只做补充,避免重复照明

如果烘焙已经包含很亮的阳光,再把同强度实时太阳叠上去,就会过曝。进阶版降低实时平行光、窗光和吊灯强度,并使用 ACES 色调映射控制高光。

5. 漫游与渲染解耦

观景点、键盘、鼠标、摇杆和碰撞继续使用原来的逻辑。更换渲染器不等于重做产品交互,这也是 A → B 能复用成果的关键。

07|HTML 怎样加载模型与资源,并把交互接起来

一个 3D 页面不是“把 GLB 放进场景”就结束。HTML 负责画布与状态层,JavaScript 负责创建渲染器、取得资源、组装场景,最后才开放漫游。加载失败、资源尚未就绪和首帧尚未出现时,都应该有明确界面。

HTML 首屏
Canvas + 加载层
→
下载资源
GLB + 贴图
→
解析与解码
网格 / 材质 / 图片
→
GPU 准备
纹理与着色器
→
渲染首帧
开放交互

1. HTML 先提供能独立显示的加载层

加载页不依赖 3D 初始化也应可见。画布、加载状态、错误信息和重试按钮放在同一容器;漫游按钮先禁用,避免用户进入半初始化场景。

<main id="app">
  <canvas id="scene" aria-label="3D 咖啡店"></canvas>

  <section id="loading" role="status" aria-live="polite">
    <p id="loadingText">正在准备咖啡店…</p>
    <progress id="progress" max="100" value="0"></progress>
  </section>

  <section id="error" hidden role="alert">
    <p id="errorText">场景暂时无法打开</p>
    <button id="retry">重新加载</button>
  </section>

  <button id="enter" disabled>进入咖啡店</button>
</main>

状态文字用 aria-live 让辅助技术能读到变化;错误层用 role="alert"。重试按钮应是真正的 <button>,不能只画一个可点击的 <div>。

2. 用统一加载管理器串起模型与贴图

下面是正式拆资源版本的简化骨架。它表达关键顺序,不是要求原样替换当前单文件项目:GLB 用 GLTFLoader,独立烘焙图用 TextureLoader,两者共用 LoadingManager;全部就绪后组装场景、编译并绘制首帧。

const ui = {
  loading: document.querySelector('#loading'),
  text: document.querySelector('#loadingText'),
  bar: document.querySelector('#progress'),
  error: document.querySelector('#error'),
  enter: document.querySelector('#enter')
};
const manager = new THREE.LoadingManager();
manager.onProgress = (url, loaded, total) => {
  ui.bar.value = loaded / total * 100; // 按资源数,不是字节进度
  ui.text.textContent = `已准备 ${loaded} / ${total} 项资源`;
};

async function boot() {
  try {
    const renderer = new THREE.WebGLRenderer({
      canvas: document.querySelector('#scene'), antialias: true
    });
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(66, 1, .05, 50);
    const loader = new GLTFLoader(manager);
    const textures = new THREE.TextureLoader(manager);

    ui.text.textContent = '正在下载模型与光照…';
    const [gltf, lightMap, aoMap] = await Promise.all([
      loader.loadAsync('./assets/cafe.glb'),
      textures.loadAsync('./assets/lightmap.webp'),
      textures.loadAsync('./assets/ao.webp')
    ]);

    ui.text.textContent = '正在组装材质与光照…';
    for (const texture of [lightMap, aoMap]) {
      texture.flipY = false;
      texture.channel = 1;
    }
    scene.add(gltf.scene);
    // 在这里把 lightMap、aoMap 绑定到对应材质。

    ui.text.textContent = '正在准备第一帧…';
    await renderer.compileAsync(scene, camera);
    renderer.render(scene, camera);       // 真首帧,不是假进度
    ui.loading.hidden = true;
    ui.enter.disabled = false;             // 此时才允许进入
  } catch (error) {
    console.error(error);
    ui.loading.hidden = true;
    ui.error.hidden = false;
  }
}
document.querySelector('#retry').addEventListener('click', () => location.reload());
boot();

示例有意省略了尺寸处理和材质绑定细节,用来突出加载状态机。实际项目还应处理相机机位、窗口尺寸、第二套 UV、纹理色彩空间,这里的小场景用整页刷新重试,避免遗留旧渲染器和动画循环。

3. “下载完成”不等于“可以交互”

阶段界面应显示什么常见问题
网络下载资源数量或真实字节进度弱网、404、跨域、服务器未返回 Content-Length
解析与解码“正在解析模型 / 解码纹理”主线程阻塞、Draco 解码耗时、图片解码峰值内存
场景组装“正在布置空间”材质尚未绑定、UV / 色彩空间错误
GPU 准备“正在准备第一帧”首次纹理上传、着色器编译导致长帧
可交互隐藏加载层并启用按钮只等 Promise,未确认首帧就提前放行

LoadingManager 的默认进度是已加载资源个数,不能宣称是精确下载百分比。若需要字节进度,应自行 fetch() 读取响应流,并按各文件字节数加权;服务器没有 Content-Length 时,就改用阶段提示,不要伪造 99%。

4. 失败、重试与后台切换也是加载交互的一部分

  • 失败要可理解:区分浏览器不支持 WebGL、网络失败、模型解析失败和纹理失败;控制台保留原始错误,界面给可行动提示。
  • 重试要可恢复:小场景可整页刷新重试;复杂场景要取消旧请求、释放旧 GPU 资源,防止重复场景和重复动画循环。
  • 页面隐藏时暂停:监听 visibilitychange,后台停止漫游帧循环,回前台重置上一帧时间,避免突然位移。
  • 处理上下文丢失:监听画布的 webglcontextlost,显示“图形资源正在恢复”,恢复后重新确认首帧,必要时提供重新进入。
  • 不要反复自动重试:弱网下无限重试只会增加流量与发热;让用户决定重试,并提供轻量模式或静态封面。

当前项目的真实加载方式:交付版把 GLB、Light Map 和 AO 以 Base64 内嵌进 HTML,再用 atob()、GLTFLoader.parse() 和 TextureLoader 读取。因此它没有运行时外部资源请求,也没有真实下载进度;当前全屏加载层只表示“正在初始化”,不是联网下载百分比。

08|手机端加载页面:优化方向与可执行细节

手机加载体验的目标不是“转圈更漂亮”,而是尽快出现可信反馈、降低首屏成本、避免假死,并在性能不足时仍能离开或降级。需要同时优化网络、CPU 解码、GPU 首帧和界面反馈。

1. 先让加载页画出来,再做重活

如果脚本一执行就同步解析大模型、逐像素生成法线,浏览器可能来不及绘制加载文字。应让 HTML 和 CSS 先完成首屏,再在下一帧启动重型初始化;长任务按阶段拆分,在每阶段更新提示。

// 给浏览器一次实际绘制加载页的机会,而不是固定猜一个延时。
requestAnimationFrame(() => requestAnimationFrame(boot));

// 每个重阶段结束后更新文字;必要时让出主线程再继续。
ui.text.textContent = '正在生成材质…';
await new Promise(resolve => setTimeout(resolve, 0));
prepareMaterials();

让出主线程只能改善阶段之间的反馈,不能把一次几百毫秒的同步循环变成流畅任务。真正昂贵的法线生成、几何压缩和纹理转换,优先在制作阶段离线完成。

2. 当前咖啡店最值得改的不是颜色,而是初始化顺序

现有版本启动时会生成多组 Canvas 纹理,再通过 CPU 计算 normalFromTexture(),随后复制 Base64、解析 GLB、生成 PMREM 环境并编译材质。这些都可能发生在第一次可交互画面之前。可优先做:

  1. 把法线贴图离线生成:不要在每台手机启动时重复计算同一份像素;随资源直接交付 PNG/WebP,正式版进一步考虑 KTX2。
  2. 首屏只准备入口需要的资源:先显示房间、入口家具和低分辨率烘焙图,沙发或吧台近景细节在进入后再补。
  3. 先低清首帧,再渐进增强:限制初始 DPR、先关闭高成本后期,首帧稳定后再在目标设备允许时提升。
  4. 避免同一资源多份常驻副本:解析完成后释放不再需要的 Base64、临时数组和图像引用;纹理与几何销毁时调用对应的 dispose()。

3. 单文件适合交付,发布版要换一种资源策略

策略当前离线单文件正式上线建议
HTML引擎、GLB、贴图全部内嵌保持轻量,让加载页和静态封面先到达
GLBBase64 后解码,增加体积与临时内存独立文件,使用 Brotli/Gzip、长期缓存和版本号
纹理随 HTML 一次取得,之后解码合理尺寸,按区域/机位分批;评估 KTX2 GPU 压缩
再次访问整个 HTML 重新读取,资源难独立更新未变资源命中缓存,只更新变化的模型或贴图
首屏等待整份 HTML 与初始化HTML 封面先显示,再并行拉取必要资源

Base64 通常会比原始二进制多约三分之一体积;它不是“压缩”。当前单文件的优势是离线双击与完整打包,正式 H5 则更应利用独立请求、缓存和内容压缩。

4. 进度、文案和操作应该怎样设计

  • 第一阶段就给反馈:先显示品牌、静态场景封面和“正在准备”,不要白屏,也不要只放无限旋转动画。
  • 使用阶段进度:下载、解析、光照准备、首帧各有状态。资源数比例应明确是“已准备 2/3 项”,不要冒充真实字节百分比。
  • 长等待给下一步:明显超过正常耗时后显示“网络较慢,可重试或查看轻量版”;具体秒数根据真机数据设定,不凭空写死行业标准。
  • 首帧后短暂停顿:等构图稳定再淡出加载层,避免材质逐个弹出;动画只负责过渡,不掩盖仍在加载的事实。
  • 控制安全区:重试、进入和退出按钮考虑 env(safe-area-inset-*),触控目标保持足够尺寸,不贴近刘海和系统手势区。
  • 尊重减少动态效果:通过 prefers-reduced-motion 减少旋转、闪烁和大幅过渡,加载信息仍需清晰。

5. 弱网与低端机要有出口

轻量模式不是一句“建议换手机”,而是一组可执行降级:更低分辨率烘焙图、较少近景模型、限制 DPR、关闭实时阴影和 SSAO,最后才是静态封面 + 可观景点切换的图片模式。保留明确退出入口,避免 WebGL 初始化失败后用户被困在全屏遮罩里。

合理资源预算应从目标设备测出来。例如“首屏必要资源多少 MB、低端机首帧多少秒、允许多少峰值内存”都应成为项目指标,但不要在没有测试数据时把某个通用数字当作所有手机的硬标准。

6. 加载优化怎样验收

测量点记录内容要回答的问题
加载页出现首个文字/封面的时间用户是否很快知道页面没有死?
资源下载各类资源字节数、缓存命中、失败请求慢在网络,还是慢在设备?
解析与初始化Long Task、各阶段耗时、JS 堆趋势哪段同步代码让加载页不动?
首个真实帧从 boot 到第一次有效 render 的时间进度结束后是否真的看得见场景?
持续使用帧时间、发热后表现、后台恢复、上下文丢失能打开是否也等于能持续漫游?

至少在 Wi-Fi 与受限网络、冷缓存与热缓存、一台目标低端真机上分别测试。桌面 DevTools 的 CPU/网络节流适合复现瓶颈,不能替代手机 GPU、内存和热降频测试。

09|清晰度实战:为什么更真实反而更模糊

咖啡店升级到模型、PBR 和烘焙后,实际观感却可能比纯色初版更“糊”。这不等于精细模型失败,也不能直接推出“贴图不够高清”。清晰度由实际渲染像素、物体在画面中的大小、纹理过滤和明暗对比共同决定。

清晰化前的第二版咖啡店
第二版 · 清晰化前DPR 上限 1.5 / FOV 66° / 过滤 4×
清晰化后的第二版咖啡店
第二版 · 清晰版DPR 上限 2 / FOV 60° / 过滤 8×

两张图均使用 430×800 CSS 视口、设备 DPR 3 和同一入口机位。模型、材质、烘焙与光照不变,只调整渲染像素比、视野和纹理过滤。

1. 先看 Canvas 真正绘制了多少像素

CSS 里的 430×800 只是画布布局尺寸,不等于实际渲染分辨率。在同样的 DPR 3 测试环境下,第一版纯色场景的实际像素比上限为 2;第二版最初为了性能把手机限制到 1.5:

参数清晰化前清晰版实际影响
设备 DPR / 渲染像素比3 / 1.53 / 2浏览器缩放低像素画面的程度不同
Canvas 缓冲区645×1200860×1600总像素提高约 78%
垂直视野 FOV66°60°同一机位下,近景物体占屏更大
各向异性过滤4×8×斜视地板和木纹保留更多细节
模型与烘焙原有资产完全不变证明本轮不是重新制作模型
const ratio = Math.min(window.devicePixelRatio, 2);
renderer.setPixelRatio(ratio);
renderer.setSize(width, height, false);

camera.fov = 60;
camera.updateProjectionMatrix();

清晰化前,第二版的总像素比第一版少约 44%。这时再精细的模型也要经过低分辨率采样,边缘自然不如高像素比的纯色硬边利落。

DPR 不是越高越好。像素比从 1.5 提到 2,像素工作量约增加 78%;若直接跟随高端手机的 DPR 3,负载还会继续上升。应根据目标真机帧时间设上限,而不是无上限追求物理分辨率。

2. FOV 改变的是“细节占多少屏幕像素”

视野越宽,同一台咖啡机在画面里越小。第二版从约 56° 的初版改为 66° 后,虽然能看到更多空间,但近景物件获得的屏幕像素更少,菜单和杯子自然更难看清。收窄到 60° 是调整构图与细节分配,不会增加资产本身的精度。

3. 斜向表面要检查各向异性过滤

木地板以很斜的角度伸向远处时,一个屏幕像素会覆盖拉长区域内的许多纹素。普通三线性过滤容易把这些细节平均掉;各向异性过滤沿不同方向采样,通常能明显改善地板、路面和长桌面的斜视清晰度。

const anisotropy = Math.min(
  8, renderer.capabilities.getMaxAnisotropy()
);
// 项目实际遍历所有材质中的纹理,而不是只处理木纹。
if (texture.isTexture) texture.anisotropy = anisotropy;

本项目设备报告最高支持 16×,清晰版选择 8× 作为画质与成本的折中。还要确保菜单、装饰画等后续创建的纹理也被统一遍历,不能只修最初注册的那一批。

各向异性不是万能锐化。它主要帮助斜视纹理;如果源贴图本身分辨率不足、UV 分配过小,或文字正对着相机仍模糊,就要继续检查纹素密度,而不是把过滤倍数无限调高。

4. “更柔和”不一定都是技术故障

纯色初版的颜色边界和明暗对比更硬;写实版增加法线、AO、间接光和 ACES 色调映射后,明暗过渡会更连续。这是风格差异,不是分辨率问题。排查时先修可量化的分辨率和过滤,再讨论曝光与风格,不要第一步就加锐化滤镜。

5. 清晰化怎样验收

  • 固定测试条件:使用相同机位、CSS 视口和设备 DPR,保留前后截图。
  • 读取实际缓冲区:检查 canvas.width / canvas.height 和 renderer.getPixelRatio(),不要只看 CSS。
  • 裁切原始像素:直接检查菜单、杯沿和金属边的 1:1 像素,不凭缩略图判断源贴图清晰度。
  • 重新检查四个机位:收窄 FOV 后,吧台、窗边和沙发可能变得过近,构图要一起回归。
  • 补测持续性能:模型面数和 Draw Calls 没变,不代表 GPU 负载没变;像素比提高后仍要测手机帧时间与发热。

本轮最终保持 75,646 个三角面、24 次绘制调用,没有换模型或重烘焙。漫游、碰撞和离线回归均通过;真实手机持续帧率仍需单独验收。

10|后续优化:下一阶段不只看“还能不能更细”

视觉质量

  • 轮廓优先:继续精修近景高频物件,而不是平均增加所有物体面数。
  • 材质统一:控制贴图尺度、色彩空间和粗糙度范围,避免木纹像地板贴上去、金属像塑料。
  • 动态层:确实需要灯光切换或可移动物件时,再增加 C 方案的实时阴影与 SSAO。
  • 克制后期:Bloom、暗角和景深不能修复错误光照,过量反而会掩盖材质。

渲染性能

性能不是只看三角面数。还要看绘制调用、纹理显存、屏幕像素、透明叠加、阴影和着色器复杂度。

瓶颈观察指标常见优化
CPU / 调用开销Draw Calls、对象数量合批、实例化、共享材质
顶点处理三角面、骨骼与变形LOD、减少不可见细节
填充率分辨率、DPR、透明层限制像素比、减少过度绘制
阴影与后期阴影贴图、SSAO 成本降低分辨率、减少投影灯
显存与带宽纹理尺寸与格式KTX2/Basis、合理尺寸与 Mipmap

60 FPS 的单帧预算约为 16.7 ms,30 FPS 约为 33.3 ms。应记录帧时间与长帧,而不是只报一个平均 FPS;手机还要观察发热后的持续表现。

文件大小不等于显存。一张 2048×2048 的 RGBA 纹理,连 Mipmap 粗略估算就占约 22 MB 显存。磁盘上的 PNG/JPEG 再小,解码后仍可能很大,因此发布版应考虑 GPU 压缩纹理。

模型与加载

  • glTF / GLB:适合 Web 交付的标准格式,可携带网格、材质、UV 和场景结构。
  • Draco / Meshopt:压缩几何体,降低下载体积;同时要计入解码器与解码时间。
  • KTX2 / Basis:压缩纹理并减少显存占用,通常比单纯压 PNG 更接近渲染性能问题。
  • 按需加载:先显示入口,再加载近景细节;大场景可分区分批加载。

当前单文件版本的取舍

目前约 12.4 MB 的 HTML 把引擎、模型和贴图全部内嵌,适合离线演示和直接交付,运行时没有外部请求。但它不是发布优化的终点:正式上线可拆成 HTML + GLB + 纹理,利用缓存、压缩和按需加载。

测试与可维护性

  • 固定入口、吧台、窗边、沙发四个机位,持续做前后截图回归。
  • 检查 UV 通道、贴图方向、色彩空间、曝光和烘焙叠加,防止“技术接上了但画面错了”。
  • 漫游碰撞与渲染分离,避免换模型后交互逻辑全部重写。
  • 至少覆盖桌面与手机真机;桌面无报错不代表手机帧率合格。

11|推荐学习顺序:每一步都做一个能看的结果

阶段练习要理解的问题
1. 几何与相机画一个立方体并绕它旋转顶点、三角形、法线、MVP 矩阵
2. 光照与材质给立方体加颜色和漫反射点积、明暗、粗糙度、金属度
3. UV 与贴图制作木纹桌面和陶瓷杯UV、Base Color、Normal、色彩空间
4. Blender 与烘焙做一间只有桌椅的小房间倒角、第二套 UV、AO、Light Map
5. Web 集成导出 glTF 并用 Three.js 漫游加载器、坐标、贴图通道、交互解耦
6. 加载交互给 GLB 与贴图做加载、失败和重试界面资源管理、首帧、状态反馈与交互解锁
7. 清晰度诊断固定机位做不同 DPR、FOV 和过滤对比实际像素、纹素密度与主观锐利感
8. 性能与发布做桌面/移动两档画质帧时间、Draw Calls、DPR、压缩与缓存

最有效的练习方法:每次只改变一个变量。先只加倒角,再只加法线,再只加 AO,最后加烘焙。你会真正知道每一项技术贡献了什么。

12|一张总结表:遇到问题时先找哪一层

你看到的问题优先检查不要先做什么
物体像积木、边缘假轮廓、倒角、平滑法线先加 Bloom
木头、金属、布料没区别粗糙度、金属度、环境反射先增加面数
家具像漂在地上接触阴影、AO、光照方向先换更高清颜色图
画面过曝或灰平色彩空间、曝光、烘焙与实时光叠加先盲目提高灯强度
贴图错位或颠倒UV 通道、flipY、导出坐标先重新建模
写实版比纯色版更糊实际画布像素、FOV、各向异性过滤和色彩对比先重做模型或盲目加锐化
手机卡顿帧时间、DPR、阴影、纹理、Draw Calls只盯三角面数
下载慢但运行也卡几何压缩、纹理压缩、显存与加载策略只把 PNG 再压一遍
进度满格但还不能进入图片解码、GPU 上传、着色器编译和首帧用固定定时器假装加载完成
手机加载页卡死同步长任务、Base64 临时内存、纹理生成与首屏资源量只给加载动画换样式

最后记住一句话:模型负责形状,材质负责表面,光照负责空间,优化负责让这一切稳定运行。