three-base 2.0.196 → 2.0.198

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,13 +49,27 @@ import { stripAssetHost, absAssetUrl } from "./assetUrl";
49
49
  // 原先各住在本站两个渲染器里一份 ⇒ 下游直接用包里的 `MktMesh` 时,存档里的 `images`/`models` 两键没人读。
50
50
  // 入库之后它们挂在 `MktMesh` **自己的子树**下,与墙同一棵变换 ⇒ 预览那三根 transform 滑杆搬得动整份展品。
51
51
  import { ImagePlane } from "./imagePlane";
52
- import { ModelSlot } from "./modelSlot";
52
+ import { ModelSlot, glbFileName } from "./modelSlot";
53
53
  import { ImagePlaneTileLoader } from "./imageTileLoader";
54
- /** 载入 JSON 时**优先**加载几个全景的素材(2026-10-29 需求「优先加载前 2 个全景图的素材,然后再加载其他的」)。
55
- * 口径 = 存档 `textures` 数组的前 n 项;其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。 */
56
- const PANO_IMPORT_PRIORITY = 2;
54
+ /** 载入 JSON 时**优先**加载几个全景的素材的**默认档**(数值只写这一处)。
55
+ * 口径 = 存档 `textures` 数组的前 n 项(n = -1 视为整份全加载);其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。
56
+ * 2026-10-29 需求「优先加载前 2 个全景图的素材,然后再加载其他的」立的是 2;
57
+ * 2026-11-05 需求逐字「现在要默认把全景全部都要默认加载, 现在需要通过配置来设置, 提前加载多少个全景 -1 就是全加载 0就是不加载 1就是加载个 2就提前加载2个 一次类推, 默认加载前4个」⇒ 换成 4,并让这一档可被实例覆写。 */
58
+ const PANO_IMPORT_PRIORITY_DEFAULT = 4;
57
59
  /** 隐藏期预热那一次绘制用的镜头(模块级共用:它只是个「有合法的投影矩阵可读」的载体,永不动 ⇒ ⛔ 不必每实例造一份)。 */
58
60
  const GPU_WARM_CAMERA = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
61
+ /**
62
+ * 预热小屋子里那几盏克隆灯的**阴影图边长**(宿主一般是 4096²)。
63
+ * 压小不是降质量:这张图只在预热那间小屋子里用、像素当场扔掉,宿主自己那张阴影图归宿主每帧照画。
64
+ * 判据 = three 的 program 缓存键里只有 `shadowMapType` 与「几盏灯投影」这两档,**没有** `mapSize`
65
+ * (`three.module.js:20913` 一段 `numDirLightShadows / shadowMapType`,`mapSize` 只是 uniform)
66
+ * ⇒ 256² 编出来的 depth program 与 4096² 那份是同一个。
67
+ * 不压小的代价:预热每跑一件就等于替宿主重画一张 4096² 深度图(`shadow.autoUpdate` 默认开)
68
+ * ⇒ 隐藏期那圈自由视角跟着掉帧,那正是「卡」本身,⛔ 不能拿它换。
69
+ */
70
+ const GPU_WARM_SHADOW_MAP_SIZE = 256;
71
+ /** 「同一件」判据里要看的那张主图 id:ShaderMaterial 那一族没有 `map` 这一档 ⇒ 恒 0;图片平面绑上图才换一档。 */
72
+ const warmMapId = (m) => m.map?.id ?? 0;
59
73
  export default class MktMesh extends Mesh {
60
74
  /** 纹理列表(每个纹理的 url + 世界矩阵) */
61
75
  textures = [];
@@ -181,6 +195,14 @@ export default class MktMesh extends Mesh {
181
195
  get panoTexMode() {
182
196
  return this._panoTexMode;
183
197
  }
198
+ /** 优先加载的全景个数(存档 `textures` 的前 n 项):**-1 = 全部、0 = 一个都不提前、n = 前 n 个**(2026-11-05 需求)。
199
+ * 默认取 `PANO_IMPORT_PRIORITY_DEFAULT`(=4);宿主想换档在渲染循环跑起来之前 `setPanoPriorityCount(-1)` 一行即可
200
+ * (与 `_ossSize`、`_panoTexMode` 同一套注入路径 ⇒ 编辑器与预览各自的配置各自推下来)。 */
201
+ _panoPriorityCount = PANO_IMPORT_PRIORITY_DEFAULT;
202
+ /** 当前优先加载个数(只读,回显用)。 */
203
+ get panoPriorityCount() {
204
+ return this._panoPriorityCount;
205
+ }
184
206
  // 复用临时对象(避免每帧 new,降低 GC 压力)
185
207
  _tmpV3 = new Vector3();
186
208
  /**
@@ -224,14 +246,19 @@ export default class MktMesh extends Mesh {
224
246
  */
225
247
  _revealPending = [];
226
248
  /**
227
- * 隐藏期 GPU 预热的在册清单(元素 = 一个「材质 × 几何」组合,也就是一个会进渲染列表的物件)。
249
+ * 隐藏期 GPU 预热的在册清单(元素 = 一个「会进渲染列表的物件」,即它的材质组合 × 几何)。
228
250
  * null = 没在预热(未启动 / 已跑完 / `stopGpuPrewarm` / `dispose` 四种都是它)。
229
251
  */
230
252
  _warmItems = null;
231
- /** 预热用的小场景:只装 `_warmProxy` 一颗,宿主场景的 `fog` 抄过来(fog 进 program 缓存键) */
253
+ /** 已收过的去重键(`几何 × 材质数组 × 各材质的 map`)⇒ 跨轮次只补收新出现的组合,不重复编同一条 GL 路径。
254
+ * 自然收尾时**故意留着**(晚到的内容补排时靠它跳过旧档);只有 `stopGpuPrewarm`/`dispose` 把它归 null = 摘牌。 */
255
+ _warmSeen = null;
256
+ /** 预热用的小场景:只挂当前这一件的替身 + 宿主灯光的克隆,`fog`/`environment` 照抄(都进 program 缓存键) */
232
257
  _warmScene = null;
233
- /** 预热用的替身物件:每次把在册那一件的 `material`/`geometry` 原对象赋给它再画一次 */
234
- _warmProxy = null;
258
+ /** 此刻挂在预热小场景里的那一颗替身(一帧只画一件 ⇒ 换件时先把上一颗摘下来) */
259
+ _warmActive = null;
260
+ /** 预热小场景里的那几盏灯 = 宿主灯的克隆体(各持一份 `shadow` ⇒ 收尾时释放的是它们自己的阴影图,碰不到宿主的) */
261
+ _warmLights = null;
235
262
  /** 预热落点:1×1 渲染目标(只有像素不要,要的是「这条 GL 路径确实走完了」),随预热结束/dispose 释放 */
236
263
  _warmRt = null;
237
264
  constructor(opts = {}) {
@@ -267,6 +294,7 @@ export default class MktMesh extends Mesh {
267
294
  // `updateTexture`(它里面那记 `pump` 是「第一次拿到 renderer ⇒ 在册组整份作废」的现场)之后、
268
295
  // `feedFaceMapping`(名册逐槽入册)之前 —— 顺序倒过来就是「名册在前、我们垫底」= 静默失效。
269
296
  // 编辑态那份在 `MktDevMesh` 的宿主循环里照同一相对位置点(⛔ 别在这儿加第三处)。
297
+ // 2026-11-05 需求:这一批点几颗改由 `panoPriorityCount` 定(默认 4,-1 = 存档全部)⇒ 本句不动。
270
298
  this.prefetchHeadPanoramas();
271
299
  this.feedFaceMapping();
272
300
  this.tickProgressiveReveal();
@@ -1052,6 +1080,18 @@ export default class MktMesh extends Mesh {
1052
1080
  return;
1053
1081
  this._panoTexMode = next;
1054
1082
  }
1083
+ /** 设置优先加载的全景个数:-1 = 全部 / 0 = 不提前 / n = 存档前 n 个(2026-11-05 需求)。
1084
+ * 收口:负数(含 -2、NaN 一类)一律归到 -1「全加载」,正数向下取整;非有限值回落默认档。
1085
+ * 改档⛔ 需要重新 `importData` —— 名册每帧重排,下一帧 `prefetchHeadPanoramas` 直接用新数点名
1086
+ * (`prioritize` 幂等 ⇒ 已在队列里的那批不重复投喂,也不会把已出队的旧批再塞回去)。 */
1087
+ setPanoPriorityCount(count) {
1088
+ if (!Number.isFinite(count))
1089
+ this._panoPriorityCount = PANO_IMPORT_PRIORITY_DEFAULT;
1090
+ else {
1091
+ const n = Math.floor(count);
1092
+ this._panoPriorityCount = n < 0 ? -1 : n;
1093
+ }
1094
+ }
1055
1095
  /**
1056
1096
  * 记下「当前激活(名册 0 号槽 = 中心)是第几个全景」。
1057
1097
  * ⭐ 皇上旨 2026-10-03 旨②:预览立方体已与墙**共用同一份材质**,原来那句「把中心那几张抄给立方体单全景材质」
@@ -1172,6 +1212,9 @@ export default class MktMesh extends Mesh {
1172
1212
  ms.dispose();
1173
1213
  }
1174
1214
  this.models = [];
1215
+ // 用户 2026-11-04「日志少打印点吧」⇒ 这一路(进厅 / 切项目 / 预览的整层重建)几十颗⛔ 不再一颗一行:
1216
+ // 名单先攒着,循环结束打**一条**(文件名口径与 `setModelUrl` 那一记共用 `glbFileName`)⇒ 一颗不落、一行到位。
1217
+ const glbNames = [];
1175
1218
  for (const d of datas) {
1176
1219
  const ms = new ModelSlot(d.name);
1177
1220
  ms.applyData(d);
@@ -1183,9 +1226,12 @@ export default class MktMesh extends Mesh {
1183
1226
  //(⚠️ 与旧的「GLB 在路上时这一槽露出占位方块」不同:整层重建这一路一颗都不露,直到轮到自己亮)
1184
1227
  ms.visible = false;
1185
1228
  this._revealPending.push(ms);
1229
+ glbNames.push(glbFileName(d.url));
1186
1230
  void ms.loadModel(d.url).then(() => this._queueModelMount(ms, onEachLoad));
1187
1231
  }
1188
1232
  }
1233
+ if (glbNames.length > 0)
1234
+ console.log(`[MktMesh] 整层重建加载 ${glbNames.length} 个 glb:${glbNames.join("、")}`);
1189
1235
  }
1190
1236
  /** 把「备好待挂」的那一槽交给全局队列;⛔ 去重闸门自备(`addWork` 按闭包引用判重,新闭包它判不出来) */
1191
1237
  _queueModelMount(ms, onEachLoad) {
@@ -1203,6 +1249,8 @@ export default class MktMesh extends Mesh {
1203
1249
  return;
1204
1250
  if (ms.mountStaged() && onEachLoad)
1205
1251
  onEachLoad();
1252
+ // 挂进来的那一族此刻才在子树里 ⇒ 补排预热(清单已排空时,这一颗是重新开工的唯一触发点)
1253
+ this._topUpGpuPrewarm();
1206
1254
  }
1207
1255
  /**
1208
1256
  * 把各颗平面那 4 张瓦片的纹理绑进材质(10-22 晚定的口径:不走拼合器 RT)。
@@ -1264,6 +1312,9 @@ export default class MktMesh extends Mesh {
1264
1312
  }
1265
1313
  }
1266
1314
  ip.setTileTextures(texs, insets);
1315
+ // 瓦片是一张一张就绪的 ⇒ 每绑上一张,这一颗的「待画纹理」就换一档(去重键带绑定戳记)⇒ 补排一次,
1316
+ // 让上传发生在隐藏期而不是进厅那一帧。
1317
+ this._topUpGpuPrewarm();
1267
1318
  if (pending) {
1268
1319
  this._queueImagePlaneBind(ip);
1269
1320
  return;
@@ -1291,7 +1342,7 @@ export default class MktMesh extends Mesh {
1291
1342
  }
1292
1343
  // ─────────────────── 隐藏期 GPU 预热(2026-11-01 需求:进厅那一下不要再现编着色器) ───────────────────
1293
1344
  /**
1294
- * 开始隐藏期预热:把「进厅第一帧要现编的那份墙着色器」提前编掉,一帧只放**一件**。
1345
+ * 开始隐藏期预热:把「进厅第一帧要现编的那些 program + 要第一次上传的那些几何/纹理」提前跑掉,一帧只放**一件**。
1295
1346
  *
1296
1347
  * 为什么必须在隐藏期做:`visible = false` 时 three 的遍历第一句就 `if (object.visible === false) return`
1297
1348
  * (与上面 `tickProgressiveReveal` 注释同一颗雷)⇒ 墙根本进不了渲染列表,那份大 shader 从来没进过
@@ -1304,10 +1355,22 @@ export default class MktMesh extends Mesh {
1304
1355
  * 编出来的正是同一份 program(three 的缓存按 `材质 × 几何属性形态 × 场景光照/雾` 计键),
1305
1356
  * 顶点缓冲也真上了 GPU;那 1×1 的像素直接扔掉,不占显存、不污染画面。
1306
1357
  *
1307
- * ⚠️ 只收**不带灯**的材质(墙的 mapping ShaderMaterial、图片平面的 MeshBasicMaterial):预热那间小屋子里没有灯,
1308
- * 带灯材质(GLB 那些 Lambert/Standard)在这儿编出来的 define 组(方向光数、阴影开关…)与真实场景那份
1309
- * 不是同一个 program,白编一遍还挤缓存。那一族本来就由 `tickProgressiveReveal` 在进厅后一帧放一颗,
1310
- * 亮出来的那一帧就是它们的预热帧 ⇒ 这次一字未动。
1358
+ * ⚠️ 2026-11-01 第二版:清单从「只收不带灯」放宽到**全收**,替身从「自造一颗裸 Mesh」换成「原对象自己克隆一颗」。
1359
+ * 量出来的账(本地 dev,进厅三连帧 100/250/234 ms):进厅那几帧里 89 份几何第一次上传(`bufferData` ×72)、
1360
+ * 504 次 `drawElements`、6 份 program 现编 —— 第一版一道都没挡住:展厅里的展品(`ModelSlot` 那些 GLB 件,
1361
+ * Lambert/Standard 带灯材质)既没上传也没编,因为它们既不是 ShaderMaterial 也不是 MeshBasicMaterial。
1362
+ * 带灯材质的 program 缓存键里有「方向光/点光/聚光数量 + 阴影档 + 环境图」
1363
+ * (`three.module.js:20901-20913`:`numDirLights`…`numDirLightShadows`、`shadowMapType`,另有 `envMap`/`fog` 两个位)
1364
+ * ⇒ 预热那间小屋子把宿主的灯**克隆一份**摆进去、`fog`/`environment` 照抄,否则编出来是另一份 define 组 = 白编。
1365
+ * 替身必须是**克隆**不是裸 Mesh:`instancing`/`skinning`/`morphTargets` 也各占缓存键一个位
1366
+ * (`getProgramCacheKeyBooleans` 里那段 `_programLayers`)⇒ 拿裸 Mesh 画实例化材质编的是非实例化那一份。
1367
+ * 克隆的灯只活在预热期,收尾时连各自的阴影图一起释放(`light.clone()` 给的是 `shadow.clone()` 的新份,
1368
+ * 实测不与宿主共享 ⇒ 不会误释放宿主那张);阴影边长压到 `GPU_WARM_SHADOW_MAP_SIZE`,理由写在那颗常量上。
1369
+ *
1370
+ * ⚠️ 一次性清单不等于收工:隐藏期里展厅的内容**还没长齐**(GLB 逐帧各挂一颗、平面的 4 格瓦片逐张就绪逐张绑),
1371
+ * 所以补收有两只手 —— ① 排空那一拍再扫一遍;② 内容落位那两个落点各调一次 `_topUpGpuPrewarm()`
1372
+ * (GLB 挂进子树、平面绑上瓦片)。去重键 = `几何 id × 材质 id 串 × 各材质的 map id`(平面再带绑定戳记)
1373
+ * ⇒ 补收只针对「没见过的那一档」,同一件不会被画第二遍。
1311
1374
  *
1312
1375
  * @param renderer 宿主那一份渲染器(隐藏期 `onBeforeRender` 不触发,库里拿不到 ⇒ 必须由宿主交进来;
1313
1376
  * 站侧的落点就是 `FloorMktBase.preloadFirstPano(renderer)`,它本来就是隐藏期的入口)
@@ -1320,38 +1383,68 @@ export default class MktMesh extends Mesh {
1320
1383
  this._glRenderer = renderer;
1321
1384
  if (this._warmItems)
1322
1385
  return; // 已在预热 ⇒ ⛔ 不重建清单(重复点会打断还没跑的那几件)
1323
- const items = [];
1324
- const seen = new Set();
1386
+ this._warmItems = [];
1387
+ this._warmSeen = new Set();
1388
+ this._collectWarmItems();
1389
+ this._scheduleGpuWarmStep(renderer);
1390
+ }
1391
+ /** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废并**摘牌** —— 亮出来那一帧本来就要编要传,不必再抢额度;摘牌后晚到的内容也不再来补排。 */
1392
+ stopGpuPrewarm() {
1393
+ this._warmSeen = null; // 这颗就是「摘牌」本身:`_topUpGpuPrewarm` 只认它
1394
+ this._finishGpuPrewarm();
1395
+ }
1396
+ /**
1397
+ * 内容后落位时补排预热(GLB 挂进子树那一拍、平面绑上瓦片纹理那一拍各调一次)。
1398
+ *
1399
+ * 为什么必须有这一颗:预热是隐藏期跑的,而隐藏期里展厅的内容**还没长齐** —— GLB 十几只各走一条异步下载链、
1400
+ * 逐帧只挂一颗,平面那 4 格图也是逐张就绪逐张绑。一次性清单在几拍之内就排空了 ⇒ 晚到的那一族一件都没被画过,
1401
+ * 进厅那一帧照样现编现传(这就是第一版「预热跑了、人还是卡了一下」的根)。
1402
+ *
1403
+ * 只在隐藏期响应:`_warmSeen` 归 null = 宿主已叫停(进厅)⇒ 晚到的东西交给「逐帧点亮」那一拍自己编,⛔ 别再抢额度。
1404
+ */
1405
+ _topUpGpuPrewarm() {
1406
+ if (!this._warmSeen || this.visible)
1407
+ return;
1408
+ if (!this._warmItems) {
1409
+ this._warmItems = []; // 上一轮已经收尾 ⇒ 重新开工(去重表还在 ⇒ 只补新出现的那几档,旧的不会重画)
1410
+ this._scheduleGpuWarmStep(this._glRenderer); // `_warmSeen` 非空 ⇒ `startGpuPrewarm` 早已把渲染器交进来
1411
+ }
1412
+ this._collectWarmItems();
1413
+ }
1414
+ /**
1415
+ * 扫一遍本对象的子树,把「还没收过的那一档」压进清单。
1416
+ * @returns 这次新收了几个(0 = 确实没有新东西 ⇒ 调用方据此收尾,⛔ 不是每帧空转)
1417
+ */
1418
+ _collectWarmItems() {
1419
+ const items = this._warmItems;
1420
+ const seen = this._warmSeen;
1421
+ if (!items || !seen)
1422
+ return 0;
1423
+ let added = 0;
1424
+ // ⚠️ 遍历**不看 `visible`**(与 `_ensureWarmScene` 里那趟剪枝的灯正好相反):隐藏期里展品的槽、图片的层都是
1425
+ // 先压着不亮的(`setModels` 那一路进来就是 `visible = false`),要挡它们就等于把这次要治的那一族又漏回去。
1325
1426
  this.traverse((o) => {
1326
1427
  const mesh = o;
1327
1428
  if (!mesh.isMesh)
1328
1429
  return;
1329
- const m = mesh.material;
1330
1430
  const g = mesh.geometry;
1331
- if (!m || !g)
1332
- return;
1333
- // 白名单,不是黑名单:r167 里「带不带灯」不再写在 `material.lights` 上(除 ShaderMaterial 之外全是 undefined),
1334
- // 只有这两个是本站这条链上确定不吃场景光照的 ⇒ 别的材质类型一律不收。
1335
- if (!m.isShaderMaterial && !m.isMeshBasicMaterial)
1336
- return;
1337
- // 平面的图还没绑上(`map` 这一档还空着)⇒ 跳过:那样编出来的是 `USE_MAP` 关闭的那一份,
1338
- // 与绑上图后真正要用的不是同一个 program,白编(绑图那一拍会重新排进 `_imgBindQueued`,照样亮得出)。
1339
- const withMap = m;
1340
- if ("map" in withMap && !withMap.map)
1431
+ if (!g)
1341
1432
  return;
1342
- const key = `${m.id}:${g.id}`;
1433
+ const mats = (Array.isArray(mesh.material) ? mesh.material : [mesh.material]);
1434
+ if (!mats.length || mats.some((m) => !m || !m.visible))
1435
+ return; // 材质自己就不画 ⇒ 编了也用不上
1436
+ // 「同一件」= 同一份几何 × 同一串材质 × 同一串图。后两截都要进键,因为 program 缓存键里 `USE_MAP` 那一档
1437
+ // 看的是材质真绑没绑;而图片平面那一族的 4 格瓦片走的是 `_uTiles` uniform 数组(`material.map` 恒 null),
1438
+ // 绑上第 1 张那一刻才是「这条 GL 路径第一次真画」⇒ 拿它自带的绑定戳记当尺,图一换就补收一次。
1439
+ const key = `${g.id}|${mats.map((m) => m.id).join("+")}|${mats.map(warmMapId).join("+")}` +
1440
+ (o instanceof ImagePlane ? `|${o.tilesBindingStamp()}` : "");
1343
1441
  if (seen.has(key))
1344
- return; // 同一份材质 + 同一份几何 = 同一个 program,画一次就够
1442
+ return;
1345
1443
  seen.add(key);
1346
- items.push({ m, g });
1444
+ items.push({ src: o, m: mesh.material, g });
1445
+ added++;
1347
1446
  });
1348
- this._warmItems = items;
1349
- this._scheduleGpuWarmStep(renderer);
1350
- }
1351
- /** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废 —— 亮出来那一帧本来就要编要传,不必再抢额度。 */
1352
- stopGpuPrewarm() {
1353
- this._warmItems = null;
1354
- this._releaseWarmHarness();
1447
+ return added;
1355
1448
  }
1356
1449
  /** 排下一件:先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 真活落在下一个 tick),与全景上传、平面绑定同一手法。 */
1357
1450
  _scheduleGpuWarmStep(renderer) {
@@ -1364,55 +1457,125 @@ export default class MktMesh extends Mesh {
1364
1457
  if (!items)
1365
1458
  return; // `stopGpuPrewarm`/`dispose` 已摘牌 ⇒ 本项作废
1366
1459
  if (this.visible) { // 已经亮起来了(进厅)⇒ 不再和真实那帧抢额度
1367
- this._warmItems = null;
1368
- this._releaseWarmHarness();
1460
+ this._finishGpuPrewarm();
1369
1461
  return;
1370
1462
  }
1371
- if (!items.length) {
1372
- this._warmItems = null;
1373
- this._releaseWarmHarness();
1463
+ if (!items.length && this._collectWarmItems() === 0) { // 排空了:先补扫(GLB、后绑的图都是晚到的),真没有才收尾
1464
+ this._finishGpuPrewarm();
1374
1465
  return;
1375
1466
  }
1376
1467
  const it = items.shift();
1377
- let proxy = this._warmProxy;
1378
- let scene = this._warmScene;
1379
- if (!proxy || !scene) {
1380
- scene = new Scene();
1381
- // 雾抄宿主那份:`USE_FOG` 进 MeshBasicMaterial 的 program 缓存键,不抄就是编了一份用不上的。
1382
- // 宿主场景 = 沿 `parent` 往上找到的第一个 Scene(本站是 `largeScreenRender` 那一棵)。
1383
- let host = null;
1384
- for (let p = this.parent; p; p = p.parent) {
1385
- if (p.isScene) {
1386
- host = p;
1387
- break;
1388
- }
1389
- }
1390
- scene.fog = host ? host.fog : null;
1391
- proxy = new Mesh();
1468
+ const scene = this._ensureWarmScene();
1469
+ let proxy = it.p;
1470
+ if (!proxy) {
1471
+ // 替身 = **原对象自己克隆一颗**(⛔ 自造裸 `Mesh`):`instancing`/`skinning`/`morphTargets` 都进缓存键,
1472
+ // 裸 Mesh 画实例化材质编的是非实例化那一份 = 白编。克隆体与宿主共享同一份 `geometry`/`material` 对象
1473
+ // ⇒ 编的是同一个 program、传的是同一个顶点缓冲。子树不要(`recursive = false`):一件只画一颗。
1474
+ proxy = it.p = it.src.clone(false);
1475
+ proxy.visible = true; // 宿主那颗此刻多半还是 `visible = false` ⇒ 替身得进得了 `projectObject`
1392
1476
  proxy.frustumCulled = false; // 墙几何是世界坐标、体量又比这 2×2 的取景框大得多 ⇒ 别按包围球剔除
1393
- scene.add(proxy);
1394
- this._warmScene = scene;
1395
- this._warmProxy = proxy;
1396
1477
  }
1397
- if (!this._warmRt)
1398
- this._warmRt = new WebGLRenderTarget(1, 1);
1399
- proxy.geometry = it.g;
1400
- proxy.material = it.m;
1478
+ if (it.src.isInstancedMesh) {
1479
+ const src = it.src;
1480
+ const inst = proxy;
1481
+ // `clone()` 里的 `instanceMatrix.copy(...)` 是**快照**(另起一份 Float32Array)⇒ 改回指宿主那一份:
1482
+ // 上传的就是进厅要用的那个 buffer,晚到的落位改动也一起带上
1483
+ inst.instanceMatrix = src.instanceMatrix;
1484
+ inst.count = src.count;
1485
+ }
1486
+ if (this._warmActive && this._warmActive !== proxy)
1487
+ scene.remove(this._warmActive); // 一帧只画这一件
1488
+ if (proxy.parent !== scene)
1489
+ scene.add(proxy);
1490
+ this._warmActive = proxy;
1401
1491
  const prevTarget = renderer.getRenderTarget();
1402
1492
  renderer.setRenderTarget(this._warmRt);
1403
1493
  renderer.render(scene, GPU_WARM_CAMERA);
1404
1494
  renderer.setRenderTarget(prevTarget);
1405
1495
  this._scheduleGpuWarmStep(renderer);
1406
1496
  }
1407
- /** 拆掉预热那套临时资源(1×1 渲染目标 + 小场景 + 替身);清单本身由调用方置 null。 */
1497
+ /**
1498
+ * 造(并记住)预热那间小屋子:只建一次。
1499
+ * `fog` / `environment` 照抄、宿主的灯**逐盏克隆**摆进来 —— 这三样都进 program 缓存键,
1500
+ * 少一样就是编出一份进厅用不上的 program。
1501
+ */
1502
+ _ensureWarmScene() {
1503
+ if (this._warmScene)
1504
+ return this._warmScene;
1505
+ const scene = new Scene();
1506
+ const lights = [];
1507
+ const host = this._findHostScene();
1508
+ if (host) {
1509
+ scene.fog = host.fog; // `USE_FOG` 进键
1510
+ scene.environment = host.environment; // `envMap` 进键(Standard 那族吃场景环境图)
1511
+ const walk = (o) => {
1512
+ if (!o.visible)
1513
+ return; // 与 three 的 `projectObject` 同一道闸:此刻不计数的灯,进厅那份 define 组里也没有
1514
+ const light = o;
1515
+ if (light.isLight) {
1516
+ const clone = light.clone(true);
1517
+ // `light.clone()` 里 `shadow = source.shadow.clone()` ⇒ 克隆体自带一份**新的** shadow
1518
+ // (边长、bias、投影相机范围都照抄,实测不与宿主共享),所以这里只管把它压小;
1519
+ // 压小的理由与「为什么压了还编得出同一份 program」都写在 `GPU_WARM_SHADOW_MAP_SIZE` 上。
1520
+ const shadow = clone.shadow;
1521
+ if (shadow?.mapSize)
1522
+ shadow.mapSize.set(GPU_WARM_SHADOW_MAP_SIZE, GPU_WARM_SHADOW_MAP_SIZE);
1523
+ // target 同样是新克隆的一份(位置一起带过来)⇒ 挂进这间屋子,`updateMatrixWorld` 才走得到它、
1524
+ // 光照方向才与宿主一致。⛔ 不许把宿主那颗挂过来:`Object3D.add` 会先从原父节点 `remove`,
1525
+ // 楼外那圈自由视角的阴影当场就被摘歪了。
1526
+ const target = clone.target;
1527
+ if (target)
1528
+ scene.add(target);
1529
+ scene.add(clone);
1530
+ lights.push(clone);
1531
+ }
1532
+ for (const child of o.children)
1533
+ walk(child);
1534
+ };
1535
+ walk(host);
1536
+ }
1537
+ this._warmLights = lights;
1538
+ this._warmScene = scene;
1539
+ if (!this._warmRt)
1540
+ this._warmRt = new WebGLRenderTarget(1, 1);
1541
+ return scene;
1542
+ }
1543
+ /** 沿 `parent` 往上找到的第一个 Scene = 宿主场景(本站是 `largeScreenRender` 那一棵)。 */
1544
+ _findHostScene() {
1545
+ for (let p = this.parent; p; p = p.parent) {
1546
+ if (p.isScene)
1547
+ return p;
1548
+ }
1549
+ return null;
1550
+ }
1551
+ /**
1552
+ * 收尾(跑完 / 进厅 / 宿主叫停 / `dispose` 四条路都汇到这里):清单作废 + 临时资源拆掉。
1553
+ *
1554
+ * ⚠️ **不动 `_warmSeen`**:自然跑完那一路后面还会有内容落位(GLB、后绑的瓦片),补排时全靠这张表跳过已经画过的旧档
1555
+ * —— 清掉它等于把同一批东西再画一遍。真正的「摘牌」只有 `stopGpuPrewarm`/`dispose`,它们先把这颗归 null。
1556
+ */
1557
+ _finishGpuPrewarm() {
1558
+ this._warmItems = null;
1559
+ this._releaseWarmHarness();
1560
+ }
1561
+ /**
1562
+ * 拆掉预热那套临时资源(1×1 渲染目标 + 克隆的灯 + 小场景)。
1563
+ * 替身只是从小屋子里摘出来 —— 它的 `geometry`/`material` 都是宿主那一份对象 ⇒ ⛔ 在此 dispose,会把展厅要用的资源释放掉。
1564
+ */
1408
1565
  _releaseWarmHarness() {
1409
1566
  if (this._warmRt)
1410
1567
  this._warmRt.dispose();
1411
1568
  this._warmRt = null;
1412
- if (this._warmScene && this._warmProxy)
1413
- this._warmScene.remove(this._warmProxy);
1569
+ for (const light of this._warmLights || []) {
1570
+ const shadow = light.shadow;
1571
+ if (shadow?.dispose)
1572
+ shadow.dispose(); // 放掉的是克隆体自己那张阴影图(`clone()` 给的是新 shadow)
1573
+ }
1574
+ this._warmLights = null;
1575
+ if (this._warmScene)
1576
+ this._warmScene.clear(); // 只摘孩子(替身、克隆灯、灯的 target)
1414
1577
  this._warmScene = null;
1415
- this._warmProxy = null;
1578
+ this._warmActive = null;
1416
1579
  }
1417
1580
  /** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
1418
1581
  * @param projName 项目名(可选,优先于 `setProjectName` 注入的那个)——图片地址落成 `{项目名}/xxx.jpg` */
@@ -1507,11 +1670,12 @@ export default class MktMesh extends Mesh {
1507
1670
  this.setModels((data.models ?? []).map((d) => ({ ...d, url: d.url ? absAssetUrl(d.url, pn) : "" })), onModelLoad);
1508
1671
  }
1509
1672
  /**
1510
- * 把**存档里前 `PANO_IMPORT_PRIORITY` 个全景**按存档顺序登记进拼合器的**加载任务队列**
1673
+ * 把**存档里前 `panoPriorityCount` 个全景**(-1 = 整份全加载)按存档顺序登记进拼合器的**加载任务队列**
1511
1674
  *(2026-10-29 需求「优先加载前 2 个全景素材」+ 2026-10-30 需求逐字:「给 mktmesh 内部加一个加载任务队列,
1512
1675
  * 把模型和平面图排在最前面,然后按照顺序,依次把前 2 个全景的顺序把瓦片加载任务添加到任务队列里面,
1513
1676
  * 第一个加载完了,就把这个加载任务从任务队列里面去掉,然后加载第二个;
1514
- * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」)。
1677
+ * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」
1678
+ * + 2026-11-05 需求「提前加载多少个全景改由配置决定,-1 全加载 / 0 不加载 / n 前 n 个,默认 4」)。
1515
1679
  * 由两处渲染循环每帧点一次:基类 `onBeforeRender`(预览弹窗 / 发布页)与编辑器的 `updateFloorTexture`
1516
1680
  *(`MktDevMesh` 覆写了 `onBeforeRender`,只点 `syncProjInvMatrix` + `tickProgressiveReveal`,库内那句在编辑态不跑)。
1517
1681
  *
@@ -1520,27 +1684,31 @@ export default class MktMesh extends Mesh {
1520
1684
  * (GLB 逐只 `loadModel` 不 throttle;平面那 4 片一次性 `_imgTileLoader.preload`),
1521
1685
  * 而全景瓦片最早也要等下一帧的 `pump` 才发第一片 ⇒ 发起次序天然是「模型 → 平面图 → 全景」,
1522
1686
  * ⛔ 这里不再把它们塞进队列重复管一遍(塞了就是第二把尺)。
1523
- * ② 本方法把存档 0 号、1 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
1687
+ * ② 本方法把存档 0 号起这 n 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
1524
1688
  * 0 号 32 格全落定(画进 RT,或该格本就缺地址/判死)才出队、1 号才开始发第一片。
1525
1689
  * 比 10-29 那版(只是把两批挪到在册顺序前面、额度仍被两批分摊)更硬:**第 1 颗独享额度,最快出全图**。
1690
+ * n = -1(全加载)时这一条变成「整份存档按存档顺序串行」⇒ 距离名册插不到前面,出清速度取决于总片数。
1526
1691
  * ③ 名册按距离点到的其他全景走 `feedFaceMapping` 里那句 `ensure(t.tiles)`(不带队列)⇒ 只入册、
1527
1692
  * 排在队列后面等,队列空了才按在册顺序补料(现行口径不变)。
1528
1693
  * ⚠️⚠️ 为什么每帧点而⛔ 只在 `importData` 里点一记:预览每开一次都换渲染器,`_switchRenderer` 把在册组
1529
1694
  * 整份作废(队列里的键也在同一刻失效);只在载入那次点名等于白点。本方法点在 `pump` 之后、名册逐槽
1530
- * `ensure` 之前 ⇒ 每帧把这两记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以 0 号恒在 1 号前)。
1695
+ * `ensure` 之前 ⇒ 每帧把这几记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以存档次序恒不变)。
1531
1696
  * ⚠️ 代价如实记:
1532
- * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前两颗的那面墙会多灰一会儿。
1697
+ * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前 n 颗的那面墙会多灰一会儿。
1533
1698
  * · 一颗「永远下不完」的瓦片(下载失败还没累计到判死)会压住队列;判死阈值是解码失败 3 次
1534
- * (`_dead`),退避重试期间队首不落定 ⇒ 第 2 颗跟着延后。真实数据里缺片是「地址为空/404」,
1699
+ * (`_dead`),退避重试期间队首不落定 ⇒ 后面几颗跟着延后。真实数据里缺片是「地址为空/404」,
1535
1700
  * 404 走三次即判死、那一格拼合涂灰 ⇒ 队列照样出清。
1536
- * · 这两颗全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
1537
- * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这两个景点迟早要建。
1701
+ * · 这批全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
1702
+ * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这些景点迟早要建;
1703
+ * ⚠️ n = -1(全加载)时这一条按存档全景总数放大,显存与在途请求都跟着涨。
1538
1704
  * ⚠️ 形态判据读 `this._panoTexMode`(整图形态那一档一张全景只有一张整图,走 `_texCache` 那条路,
1539
1705
  * 走错档就是白下 32 片)⇒ 宿主若用自己那份形态变量(编辑器的 `store.panoTexMode`),
1540
1706
  * 要在调 `importData` 之前先 `setPanoTexMode` 推进来,编辑器那侧已按此接线。
1541
1707
  */
1542
1708
  prefetchHeadPanoramas() {
1543
- const n = Math.min(PANO_IMPORT_PRIORITY, this.textures.length);
1709
+ const total = this.textures.length;
1710
+ // -1 = 整份;0 = 一个都不提前(这一句直接空转,其余全景仍按距离名册排队 = 回到 10-29 之前的行为)
1711
+ const n = this._panoPriorityCount < 0 ? total : Math.min(this._panoPriorityCount, total);
1544
1712
  // 正着点:0 号先入队 ⇒ 队首是它,落定后才轮到 1 号(`prioritize` 对已在队列里的键不挪位置)
1545
1713
  for (let i = 0; i < n; i++) {
1546
1714
  const info = this.textures[i];
@@ -1580,9 +1748,9 @@ export default class MktMesh extends Mesh {
1580
1748
  this._imgBindDone.clear();
1581
1749
  this._modelMountQueued.clear();
1582
1750
  this._revealPending = [];
1583
- // ⭐ 2026-11-01 隐藏期预热:清单与小那套临时资源随本对象一起退场(那张 1×1 渲染目标是本类**唯一**自建的 RT)
1584
- this._warmItems = null;
1585
- this._releaseWarmHarness();
1751
+ // 隐藏期预热:清单、去重表与小屋子那套临时资源随本对象一起退场(那张 1×1 渲染目标是本类**唯一**自建的 RT)。
1752
+ // 走 `stopGpuPrewarm` 而不是 `_finishGpuPrewarm`:本对象都要没了,去重表再留着就是泄漏。
1753
+ this.stopGpuPrewarm();
1586
1754
  this._stitcher.dispose();
1587
1755
  this._glRenderer = null;
1588
1756
  if (this._placeholder)