three-base 2.0.195 → 2.0.197

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.
@@ -29,8 +29,13 @@ import {
29
29
  Camera,
30
30
  BackSide,
31
31
  Float32BufferAttribute,
32
+ InstancedMesh,
33
+ Light,
32
34
  Material,
33
35
  Matrix4,
36
+ Object3D,
37
+ OrthographicCamera,
38
+ Scene,
34
39
  Side,
35
40
  Mesh,
36
41
  Shape,
@@ -40,6 +45,7 @@ import {
40
45
  Texture,
41
46
  Vector3,
42
47
  Vector4,
48
+ WebGLRenderTarget,
43
49
  } from "three";
44
50
  import type { WebGLRenderer } from "three";
45
51
  import { createPlaceholderTexture, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT, type PanoTexMode } from "./mktShader";
@@ -76,6 +82,26 @@ import { ImagePlaneTileLoader } from "./imageTileLoader";
76
82
  * 口径 = 存档 `textures` 数组的前 n 项;其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。 */
77
83
  const PANO_IMPORT_PRIORITY = 2;
78
84
 
85
+ /** 隐藏期预热那一次绘制用的镜头(模块级共用:它只是个「有合法的投影矩阵可读」的载体,永不动 ⇒ ⛔ 不必每实例造一份)。 */
86
+ const GPU_WARM_CAMERA = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
87
+
88
+ /**
89
+ * 预热小屋子里那几盏克隆灯的**阴影图边长**(宿主一般是 4096²)。
90
+ * 压小不是降质量:这张图只在预热那间小屋子里用、像素当场扔掉,宿主自己那张阴影图归宿主每帧照画。
91
+ * 判据 = three 的 program 缓存键里只有 `shadowMapType` 与「几盏灯投影」这两档,**没有** `mapSize`
92
+ * (`three.module.js:20913` 一段 `numDirLightShadows / shadowMapType`,`mapSize` 只是 uniform)
93
+ * ⇒ 256² 编出来的 depth program 与 4096² 那份是同一个。
94
+ * 不压小的代价:预热每跑一件就等于替宿主重画一张 4096² 深度图(`shadow.autoUpdate` 默认开)
95
+ * ⇒ 隐藏期那圈自由视角跟着掉帧,那正是「卡」本身,⛔ 不能拿它换。
96
+ */
97
+ const GPU_WARM_SHADOW_MAP_SIZE = 256;
98
+
99
+ /** 只取预热要动的那两颗字段(`LightShadow` 的类型名不在这页的导入清单里,结构类型够用且不必扩面)。 */
100
+ type WarmShadow = { mapSize?: { set(w: number, h: number): unknown }; dispose?(): void };
101
+
102
+ /** 「同一件」判据里要看的那张主图 id:ShaderMaterial 那一族没有 `map` 这一档 ⇒ 恒 0;图片平面绑上图才换一档。 */
103
+ const warmMapId = (m: Material): number => (m as Material & { map?: Texture | null }).map?.id ?? 0;
104
+
79
105
  export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
80
106
  /** 纹理列表(每个纹理的 url + 世界矩阵) */
81
107
  textures: MktTextureInfo[] = [];
@@ -249,6 +275,23 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
249
275
  */
250
276
  private _revealPending: Array<ImagePlane | ModelSlot> = [];
251
277
 
278
+ /**
279
+ * 隐藏期 GPU 预热的在册清单(元素 = 一个「会进渲染列表的物件」,即它的材质组合 × 几何)。
280
+ * null = 没在预热(未启动 / 已跑完 / `stopGpuPrewarm` / `dispose` 四种都是它)。
281
+ */
282
+ private _warmItems: Array<{ src: Object3D; m: Material | Material[]; g: BufferGeometry; p?: Object3D }> | null = null;
283
+ /** 已收过的去重键(`几何 × 材质数组 × 各材质的 map`)⇒ 跨轮次只补收新出现的组合,不重复编同一条 GL 路径。
284
+ * 自然收尾时**故意留着**(晚到的内容补排时靠它跳过旧档);只有 `stopGpuPrewarm`/`dispose` 把它归 null = 摘牌。 */
285
+ private _warmSeen: Set<string> | null = null;
286
+ /** 预热用的小场景:只挂当前这一件的替身 + 宿主灯光的克隆,`fog`/`environment` 照抄(都进 program 缓存键) */
287
+ private _warmScene: Scene | null = null;
288
+ /** 此刻挂在预热小场景里的那一颗替身(一帧只画一件 ⇒ 换件时先把上一颗摘下来) */
289
+ private _warmActive: Object3D | null = null;
290
+ /** 预热小场景里的那几盏灯 = 宿主灯的克隆体(各持一份 `shadow` ⇒ 收尾时释放的是它们自己的阴影图,碰不到宿主的) */
291
+ private _warmLights: Light[] | null = null;
292
+ /** 预热落点:1×1 渲染目标(只有像素不要,要的是「这条 GL 路径确实走完了」),随预热结束/dispose 释放 */
293
+ private _warmRt: WebGLRenderTarget | null = null;
294
+
252
295
  constructor(opts: { wallHeight?: number; wallClosed?: boolean } = {}) {
253
296
  // 发布/预览态墙:**固定**用「全景序号直取」着色器(皇上旨 2026-09-29:mktmesh 就只有按照 attribute
254
297
  // 配置来显示全景这一种固定的 shader——每帧由 feedFaceMapping 按 panoFaceMapping 把被引用的全景
@@ -1224,6 +1267,8 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1224
1267
  // 槽已退场(换项目 / 删除):待挂那棵由 `ModelSlot.dispose` → `_detachModel` 当场释放,这里不挂
1225
1268
  if (!this.models.includes(ms)) return;
1226
1269
  if (ms.mountStaged() && onEachLoad) onEachLoad();
1270
+ // 挂进来的那一族此刻才在子树里 ⇒ 补排预热(清单已排空时,这一颗是重新开工的唯一触发点)
1271
+ this._topUpGpuPrewarm();
1227
1272
  }
1228
1273
 
1229
1274
  /**
@@ -1281,6 +1326,9 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1281
1326
  }
1282
1327
  }
1283
1328
  ip.setTileTextures(texs, insets);
1329
+ // 瓦片是一张一张就绪的 ⇒ 每绑上一张,这一颗的「待画纹理」就换一档(去重键带绑定戳记)⇒ 补排一次,
1330
+ // 让上传发生在隐藏期而不是进厅那一帧。
1331
+ this._topUpGpuPrewarm();
1284
1332
  if (pending) {
1285
1333
  this._queueImagePlaneBind(ip);
1286
1334
  return;
@@ -1307,6 +1355,233 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1307
1355
  }
1308
1356
  }
1309
1357
 
1358
+ // ─────────────────── 隐藏期 GPU 预热(2026-11-01 需求:进厅那一下不要再现编着色器) ───────────────────
1359
+
1360
+ /**
1361
+ * 开始隐藏期预热:把「进厅第一帧要现编的那些 program + 要第一次上传的那些几何/纹理」提前跑掉,一帧只放**一件**。
1362
+ *
1363
+ * 为什么必须在隐藏期做:`visible = false` 时 three 的遍历第一句就 `if (object.visible === false) return`
1364
+ * (与上面 `tickProgressiveReveal` 注释同一颗雷)⇒ 墙根本进不了渲染列表,那份大 shader 从来没进过
1365
+ * three 的 program 缓存。进厅那一帧才第一次画它 = 编译 + 链接 + 顶点缓冲上传全压在那一帧,
1366
+ * 这就是「像有很多资源要载入 GPU」的另一半(前一半「16 张 6144² 渲染目标」已在拼合器里治掉)。
1367
+ *
1368
+ * ⛔ 不用 `renderer.compile()` / `compileAsync()`:那两个吃**整个场景**、一次把里面全部 program 编完,
1369
+ * 没地方按帧配额(`compileAsync` 的完成回调还会在同一帧落一坨主线程开销)。这里的做法是
1370
+ * 「拿同一个材质对象 + 同一个几何对象真画一次,画进 1×1 的渲染目标」⇒ 走的就是进厅那一条 GL 路径,
1371
+ * 编出来的正是同一份 program(three 的缓存按 `材质 × 几何属性形态 × 场景光照/雾` 计键),
1372
+ * 顶点缓冲也真上了 GPU;那 1×1 的像素直接扔掉,不占显存、不污染画面。
1373
+ *
1374
+ * ⚠️ 2026-11-01 第二版:清单从「只收不带灯」放宽到**全收**,替身从「自造一颗裸 Mesh」换成「原对象自己克隆一颗」。
1375
+ * 量出来的账(本地 dev,进厅三连帧 100/250/234 ms):进厅那几帧里 89 份几何第一次上传(`bufferData` ×72)、
1376
+ * 504 次 `drawElements`、6 份 program 现编 —— 第一版一道都没挡住:展厅里的展品(`ModelSlot` 那些 GLB 件,
1377
+ * Lambert/Standard 带灯材质)既没上传也没编,因为它们既不是 ShaderMaterial 也不是 MeshBasicMaterial。
1378
+ * 带灯材质的 program 缓存键里有「方向光/点光/聚光数量 + 阴影档 + 环境图」
1379
+ * (`three.module.js:20901-20913`:`numDirLights`…`numDirLightShadows`、`shadowMapType`,另有 `envMap`/`fog` 两个位)
1380
+ * ⇒ 预热那间小屋子把宿主的灯**克隆一份**摆进去、`fog`/`environment` 照抄,否则编出来是另一份 define 组 = 白编。
1381
+ * 替身必须是**克隆**不是裸 Mesh:`instancing`/`skinning`/`morphTargets` 也各占缓存键一个位
1382
+ * (`getProgramCacheKeyBooleans` 里那段 `_programLayers`)⇒ 拿裸 Mesh 画实例化材质编的是非实例化那一份。
1383
+ * 克隆的灯只活在预热期,收尾时连各自的阴影图一起释放(`light.clone()` 给的是 `shadow.clone()` 的新份,
1384
+ * 实测不与宿主共享 ⇒ 不会误释放宿主那张);阴影边长压到 `GPU_WARM_SHADOW_MAP_SIZE`,理由写在那颗常量上。
1385
+ *
1386
+ * ⚠️ 一次性清单不等于收工:隐藏期里展厅的内容**还没长齐**(GLB 逐帧各挂一颗、平面的 4 格瓦片逐张就绪逐张绑),
1387
+ * 所以补收有两只手 —— ① 排空那一拍再扫一遍;② 内容落位那两个落点各调一次 `_topUpGpuPrewarm()`
1388
+ * (GLB 挂进子树、平面绑上瓦片)。去重键 = `几何 id × 材质 id 串 × 各材质的 map id`(平面再带绑定戳记)
1389
+ * ⇒ 补收只针对「没见过的那一档」,同一件不会被画第二遍。
1390
+ *
1391
+ * @param renderer 宿主那一份渲染器(隐藏期 `onBeforeRender` 不触发,库里拿不到 ⇒ 必须由宿主交进来;
1392
+ * 站侧的落点就是 `FloorMktBase.preloadFirstPano(renderer)`,它本来就是隐藏期的入口)
1393
+ */
1394
+ startGpuPrewarm(renderer: WebGLRenderer): void {
1395
+ if (!renderer) return;
1396
+ // 顺手记下渲染器:全景瓦片那道「每 tick 一张 initTexture」的闸在隐藏期也就能落下去了
1397
+ //(原先 `_glRenderer` 只在 `onBeforeRender` 里捕获 ⇒ 隐藏期排上的队全走「three 自己绑」的降级路)。
1398
+ this._glRenderer = renderer;
1399
+ if (this._warmItems) return; // 已在预热 ⇒ ⛔ 不重建清单(重复点会打断还没跑的那几件)
1400
+ this._warmItems = [];
1401
+ this._warmSeen = new Set<string>();
1402
+ this._collectWarmItems();
1403
+ this._scheduleGpuWarmStep(renderer);
1404
+ }
1405
+
1406
+ /** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废并**摘牌** —— 亮出来那一帧本来就要编要传,不必再抢额度;摘牌后晚到的内容也不再来补排。 */
1407
+ stopGpuPrewarm(): void {
1408
+ this._warmSeen = null; // 这颗就是「摘牌」本身:`_topUpGpuPrewarm` 只认它
1409
+ this._finishGpuPrewarm();
1410
+ }
1411
+
1412
+ /**
1413
+ * 内容后落位时补排预热(GLB 挂进子树那一拍、平面绑上瓦片纹理那一拍各调一次)。
1414
+ *
1415
+ * 为什么必须有这一颗:预热是隐藏期跑的,而隐藏期里展厅的内容**还没长齐** —— GLB 十几只各走一条异步下载链、
1416
+ * 逐帧只挂一颗,平面那 4 格图也是逐张就绪逐张绑。一次性清单在几拍之内就排空了 ⇒ 晚到的那一族一件都没被画过,
1417
+ * 进厅那一帧照样现编现传(这就是第一版「预热跑了、人还是卡了一下」的根)。
1418
+ *
1419
+ * 只在隐藏期响应:`_warmSeen` 归 null = 宿主已叫停(进厅)⇒ 晚到的东西交给「逐帧点亮」那一拍自己编,⛔ 别再抢额度。
1420
+ */
1421
+ private _topUpGpuPrewarm(): void {
1422
+ if (!this._warmSeen || this.visible) return;
1423
+ if (!this._warmItems) {
1424
+ this._warmItems = []; // 上一轮已经收尾 ⇒ 重新开工(去重表还在 ⇒ 只补新出现的那几档,旧的不会重画)
1425
+ this._scheduleGpuWarmStep(this._glRenderer!); // `_warmSeen` 非空 ⇒ `startGpuPrewarm` 早已把渲染器交进来
1426
+ }
1427
+ this._collectWarmItems();
1428
+ }
1429
+
1430
+ /**
1431
+ * 扫一遍本对象的子树,把「还没收过的那一档」压进清单。
1432
+ * @returns 这次新收了几个(0 = 确实没有新东西 ⇒ 调用方据此收尾,⛔ 不是每帧空转)
1433
+ */
1434
+ private _collectWarmItems(): number {
1435
+ const items = this._warmItems;
1436
+ const seen = this._warmSeen;
1437
+ if (!items || !seen) return 0;
1438
+ let added = 0;
1439
+ // ⚠️ 遍历**不看 `visible`**(与 `_ensureWarmScene` 里那趟剪枝的灯正好相反):隐藏期里展品的槽、图片的层都是
1440
+ // 先压着不亮的(`setModels` 那一路进来就是 `visible = false`),要挡它们就等于把这次要治的那一族又漏回去。
1441
+ this.traverse((o) => {
1442
+ const mesh = o as Mesh;
1443
+ if (!mesh.isMesh) return;
1444
+ const g = mesh.geometry as BufferGeometry;
1445
+ if (!g) return;
1446
+ const mats = (Array.isArray(mesh.material) ? mesh.material : [mesh.material]) as Material[];
1447
+ if (!mats.length || mats.some((m) => !m || !m.visible)) return; // 材质自己就不画 ⇒ 编了也用不上
1448
+ // 「同一件」= 同一份几何 × 同一串材质 × 同一串图。后两截都要进键,因为 program 缓存键里 `USE_MAP` 那一档
1449
+ // 看的是材质真绑没绑;而图片平面那一族的 4 格瓦片走的是 `_uTiles` uniform 数组(`material.map` 恒 null),
1450
+ // 绑上第 1 张那一刻才是「这条 GL 路径第一次真画」⇒ 拿它自带的绑定戳记当尺,图一换就补收一次。
1451
+ const key = `${g.id}|${mats.map((m) => m.id).join("+")}|${mats.map(warmMapId).join("+")}` +
1452
+ (o instanceof ImagePlane ? `|${(o as ImagePlane).tilesBindingStamp()}` : "");
1453
+ if (seen.has(key)) return;
1454
+ seen.add(key);
1455
+ items.push({ src: o, m: mesh.material as Material | Material[], g });
1456
+ added++;
1457
+ });
1458
+ return added;
1459
+ }
1460
+
1461
+ /** 排下一件:先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 真活落在下一个 tick),与全景上传、平面绑定同一手法。 */
1462
+ private _scheduleGpuWarmStep(renderer: WebGLRenderer): void {
1463
+ threeUtils.addWork(() => {});
1464
+ threeUtils.addWork(() => this._runGpuWarmStep(renderer));
1465
+ }
1466
+
1467
+ /** 队列回调的全部内容:预热**一件**(一次 1×1 绘制)。清单空了/已被摘牌就收尾。 */
1468
+ private _runGpuWarmStep(renderer: WebGLRenderer): void {
1469
+ const items = this._warmItems;
1470
+ if (!items) return; // `stopGpuPrewarm`/`dispose` 已摘牌 ⇒ 本项作废
1471
+ if (this.visible) { // 已经亮起来了(进厅)⇒ 不再和真实那帧抢额度
1472
+ this._finishGpuPrewarm();
1473
+ return;
1474
+ }
1475
+ if (!items.length && this._collectWarmItems() === 0) { // 排空了:先补扫(GLB、后绑的图都是晚到的),真没有才收尾
1476
+ this._finishGpuPrewarm();
1477
+ return;
1478
+ }
1479
+ const it = items.shift()!;
1480
+ const scene = this._ensureWarmScene();
1481
+ let proxy = it.p;
1482
+ if (!proxy) {
1483
+ // 替身 = **原对象自己克隆一颗**(⛔ 自造裸 `Mesh`):`instancing`/`skinning`/`morphTargets` 都进缓存键,
1484
+ // 裸 Mesh 画实例化材质编的是非实例化那一份 = 白编。克隆体与宿主共享同一份 `geometry`/`material` 对象
1485
+ // ⇒ 编的是同一个 program、传的是同一个顶点缓冲。子树不要(`recursive = false`):一件只画一颗。
1486
+ proxy = it.p = it.src.clone(false);
1487
+ proxy.visible = true; // 宿主那颗此刻多半还是 `visible = false` ⇒ 替身得进得了 `projectObject`
1488
+ proxy.frustumCulled = false; // 墙几何是世界坐标、体量又比这 2×2 的取景框大得多 ⇒ 别按包围球剔除
1489
+ }
1490
+ if ((it.src as InstancedMesh).isInstancedMesh) {
1491
+ const src = it.src as InstancedMesh;
1492
+ const inst = proxy as InstancedMesh;
1493
+ // `clone()` 里的 `instanceMatrix.copy(...)` 是**快照**(另起一份 Float32Array)⇒ 改回指宿主那一份:
1494
+ // 上传的就是进厅要用的那个 buffer,晚到的落位改动也一起带上
1495
+ inst.instanceMatrix = src.instanceMatrix;
1496
+ inst.count = src.count;
1497
+ }
1498
+ if (this._warmActive && this._warmActive !== proxy) scene.remove(this._warmActive); // 一帧只画这一件
1499
+ if (proxy.parent !== scene) scene.add(proxy);
1500
+ this._warmActive = proxy;
1501
+ const prevTarget = renderer.getRenderTarget();
1502
+ renderer.setRenderTarget(this._warmRt!);
1503
+ renderer.render(scene, GPU_WARM_CAMERA);
1504
+ renderer.setRenderTarget(prevTarget);
1505
+ this._scheduleGpuWarmStep(renderer);
1506
+ }
1507
+
1508
+ /**
1509
+ * 造(并记住)预热那间小屋子:只建一次。
1510
+ * `fog` / `environment` 照抄、宿主的灯**逐盏克隆**摆进来 —— 这三样都进 program 缓存键,
1511
+ * 少一样就是编出一份进厅用不上的 program。
1512
+ */
1513
+ private _ensureWarmScene(): Scene {
1514
+ if (this._warmScene) return this._warmScene;
1515
+ const scene = new Scene();
1516
+ const lights: Light[] = [];
1517
+ const host = this._findHostScene();
1518
+ if (host) {
1519
+ scene.fog = host.fog; // `USE_FOG` 进键
1520
+ scene.environment = host.environment; // `envMap` 进键(Standard 那族吃场景环境图)
1521
+ const walk = (o: Object3D) => {
1522
+ if (!o.visible) return; // 与 three 的 `projectObject` 同一道闸:此刻不计数的灯,进厅那份 define 组里也没有
1523
+ const light = o as Light;
1524
+ if (light.isLight) {
1525
+ const clone = light.clone(true) as Light;
1526
+ // `light.clone()` 里 `shadow = source.shadow.clone()` ⇒ 克隆体自带一份**新的** shadow
1527
+ // (边长、bias、投影相机范围都照抄,实测不与宿主共享),所以这里只管把它压小;
1528
+ // 压小的理由与「为什么压了还编得出同一份 program」都写在 `GPU_WARM_SHADOW_MAP_SIZE` 上。
1529
+ const shadow = (clone as unknown as { shadow?: WarmShadow }).shadow;
1530
+ if (shadow?.mapSize) shadow.mapSize.set(GPU_WARM_SHADOW_MAP_SIZE, GPU_WARM_SHADOW_MAP_SIZE);
1531
+ // target 同样是新克隆的一份(位置一起带过来)⇒ 挂进这间屋子,`updateMatrixWorld` 才走得到它、
1532
+ // 光照方向才与宿主一致。⛔ 不许把宿主那颗挂过来:`Object3D.add` 会先从原父节点 `remove`,
1533
+ // 楼外那圈自由视角的阴影当场就被摘歪了。
1534
+ const target = (clone as unknown as { target?: Object3D }).target;
1535
+ if (target) scene.add(target);
1536
+ scene.add(clone);
1537
+ lights.push(clone);
1538
+ }
1539
+ for (const child of o.children) walk(child);
1540
+ };
1541
+ walk(host);
1542
+ }
1543
+ this._warmLights = lights;
1544
+ this._warmScene = scene;
1545
+ if (!this._warmRt) this._warmRt = new WebGLRenderTarget(1, 1);
1546
+ return scene;
1547
+ }
1548
+
1549
+ /** 沿 `parent` 往上找到的第一个 Scene = 宿主场景(本站是 `largeScreenRender` 那一棵)。 */
1550
+ private _findHostScene(): Scene | null {
1551
+ for (let p: Object3D | null = this.parent; p; p = p.parent) {
1552
+ if ((p as Scene).isScene) return p as Scene;
1553
+ }
1554
+ return null;
1555
+ }
1556
+
1557
+ /**
1558
+ * 收尾(跑完 / 进厅 / 宿主叫停 / `dispose` 四条路都汇到这里):清单作废 + 临时资源拆掉。
1559
+ *
1560
+ * ⚠️ **不动 `_warmSeen`**:自然跑完那一路后面还会有内容落位(GLB、后绑的瓦片),补排时全靠这张表跳过已经画过的旧档
1561
+ * —— 清掉它等于把同一批东西再画一遍。真正的「摘牌」只有 `stopGpuPrewarm`/`dispose`,它们先把这颗归 null。
1562
+ */
1563
+ private _finishGpuPrewarm(): void {
1564
+ this._warmItems = null;
1565
+ this._releaseWarmHarness();
1566
+ }
1567
+
1568
+ /**
1569
+ * 拆掉预热那套临时资源(1×1 渲染目标 + 克隆的灯 + 小场景)。
1570
+ * 替身只是从小屋子里摘出来 —— 它的 `geometry`/`material` 都是宿主那一份对象 ⇒ ⛔ 在此 dispose,会把展厅要用的资源释放掉。
1571
+ */
1572
+ private _releaseWarmHarness(): void {
1573
+ if (this._warmRt) this._warmRt.dispose();
1574
+ this._warmRt = null;
1575
+ for (const light of this._warmLights || []) {
1576
+ const shadow = (light as unknown as { shadow?: WarmShadow }).shadow;
1577
+ if (shadow?.dispose) shadow.dispose(); // 放掉的是克隆体自己那张阴影图(`clone()` 给的是新 shadow)
1578
+ }
1579
+ this._warmLights = null;
1580
+ if (this._warmScene) this._warmScene.clear(); // 只摘孩子(替身、克隆灯、灯的 target)
1581
+ this._warmScene = null;
1582
+ this._warmActive = null;
1583
+ }
1584
+
1310
1585
  /** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
1311
1586
  * @param projName 项目名(可选,优先于 `setProjectName` 注入的那个)——图片地址落成 `{项目名}/xxx.jpg` */
1312
1587
  exportData(projName?: string): MktMeshData {
@@ -1480,6 +1755,9 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1480
1755
  this._imgBindDone.clear();
1481
1756
  this._modelMountQueued.clear();
1482
1757
  this._revealPending = [];
1758
+ // 隐藏期预热:清单、去重表与小屋子那套临时资源随本对象一起退场(那张 1×1 渲染目标是本类**唯一**自建的 RT)。
1759
+ // 走 `stopGpuPrewarm` 而不是 `_finishGpuPrewarm`:本对象都要没了,去重表再留着就是泄漏。
1760
+ this.stopGpuPrewarm();
1483
1761
  this._stitcher.dispose();
1484
1762
  this._glRenderer = null;
1485
1763
  if (this._placeholder) this._placeholder.dispose();
@@ -195,6 +195,9 @@ export class ModelSlot extends Group {
195
195
  return false;
196
196
  }
197
197
  if (!obj) {
198
+ // 画面停在占位方块之外,还要让页面知道「这一颗没出来」:报进加载状态那唯一出口,带上槽名。
199
+ // 同一地址重复失败由 `threeLoader.reportLoadError` 去重(下载本身失败已由 manager 计过一次 ⇒ 这里只补「是哪颗槽」)。
200
+ threeLoader.reportLoadError(url, "model", this.name);
198
201
  this._loaded = true;
199
202
  this._layout();
200
203
  return false;
@@ -233,6 +233,9 @@ export class ModelSlot extends Group {
233
233
  return false;
234
234
  }
235
235
  if (!obj) {
236
+ // 画面停在占位方块之外,还要让页面知道「这一颗没出来」:报进加载状态那唯一出口,带上槽名。
237
+ // 同一地址重复失败由 `threeLoader.reportLoadError` 去重(下载本身失败已由 manager 计过一次 ⇒ 这里只补「是哪颗槽」)。
238
+ threeLoader.reportLoadError(url, "model", this.name);
236
239
  this._loaded = true;
237
240
  this._layout();
238
241
  return false;
@@ -1,4 +1,5 @@
1
1
  import { DefaultLoadingManager, Texture } from "three";
2
+ import threeLoader from "../threeLoader";
2
3
  /**
3
4
  * ⭐ 甲(皇上旨 2026-10-05「你试试甲」):一张全景图(整图 / 单片瓦片,现行 32 片/全景)的**异步解码**建纹理。
4
5
  *
@@ -25,8 +26,11 @@ export function loadPanoTexture(url) {
25
26
  DefaultLoadingManager.itemStart(url);
26
27
  const task = fetch(url)
27
28
  .then((res) => {
28
- if (!res.ok)
29
+ // 状态码先记进 `threeLoader`(页面据此分「服务器明说没有」与「断网」:前者才敢删那条数据)
30
+ if (!res.ok) {
31
+ threeLoader.markUrlStatus(url, res.status);
29
32
  throw new Error(`HTTP ${res.status}`);
33
+ }
30
34
  return res.blob();
31
35
  })
32
36
  .then((blob) => createImageBitmap(blob, {
@@ -1,4 +1,5 @@
1
1
  import { DefaultLoadingManager, Texture } from "three";
2
+ import threeLoader from "../threeLoader";
2
3
 
3
4
  /**
4
5
  * ⭐ 甲(皇上旨 2026-10-05「你试试甲」):一张全景图(整图 / 单片瓦片,现行 32 片/全景)的**异步解码**建纹理。
@@ -27,7 +28,11 @@ export function loadPanoTexture(url: string): Promise<Texture> {
27
28
  DefaultLoadingManager.itemStart(url);
28
29
  const task = fetch(url)
29
30
  .then((res) => {
30
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
31
+ // 状态码先记进 `threeLoader`(页面据此分「服务器明说没有」与「断网」:前者才敢删那条数据)
32
+ if (!res.ok) {
33
+ threeLoader.markUrlStatus(url, res.status);
34
+ throw new Error(`HTTP ${res.status}`);
35
+ }
31
36
  return res.blob();
32
37
  })
33
38
  .then((blob) =>
@@ -25,17 +25,20 @@ export interface PanoStitcher {
25
25
  prioritize(urls: string[], layout?: TileLayout): void;
26
26
  /**
27
27
  * 取某一拼合槽的纹理。**画过 ≥1 格就返回**(同日旨「需要先喂给 shader」)。
28
- * ⭐ 2026-10-09 皇上旨「切换全景时上一个全景的内容要渲染成深灰」:**传了 `renderer` 且该槽一格都还没画时,
29
- * 先把 RT 建出来(建完即整张涂 `MISSING_TILE_GRAY` 缺失灰)并返回它** ⇒ 调用方切批次的那一帧
28
+ * ⭐ 2026-10-09 需求「切换全景时上一个全景的内容要渲染成深灰」:**传了 `renderer` 且该槽一格都还没画时
29
+ * 返回共享的 1×1 缺失灰纹理**(屏幕上与「刚整张涂过灰的那张 RT」逐字节同色)⇒ 调用方切批次的那一帧
30
30
  * 拿到的就是**深灰底**而非 null(null 会让它沿用上一批 ⇒ 墙上闪上一个全景)。
31
- * ⛔ 没传 `renderer`(诊断/测试路径)仍旧返回 null —— 不能在无 GL 上下文时凭空建 RT。
31
+ * ⚠️ 2026-11-01:这一支原先是**当场建一张 6144×6144 的 RT** 再交出去 ⇒ 名册 8 点 × 2 槽 = 同一帧 16 张
32
+ * (≈3.2GB 显存 + 约 6 亿像素预涂),是「进厅那一下卡」的本体;现在 RT 退回 `_drawPass` 画第一格时懒建。
33
+ * ⛔ 没传 `renderer`(诊断/测试路径)仍旧返回 null。
32
34
  */
33
35
  peek(batch: string, slot: number, renderer?: WebGLRenderer | null): Texture | null;
34
36
  /** 每帧驱动(消费方每帧只调一次)。`renderer` = 调用方当前的 WebGLRenderer,还没捕获到就传 null
35
37
  * ⇒ 这一帧什么都不画(⛔ 不打假读数,也⛔ 不退回任何 CPU 拼合路径)。 */
36
38
  pump(renderer: WebGLRenderer | null): void;
37
- /** 这张槽纹理是否**已经在 GPU 上**(RT 版恒 true):消费方据此跳过「每 tick 一张」预上传闸。
38
- * ⚠️ 返回 true 的纹理绝不能喂给 `renderer.initTexture` —— 它不是普通纹理,是 three 自己管的渲染目标附件。 */
39
+ /** 这张槽纹理是否**不需要走「每 tick 一张上传」那道闸**(RT 附件与共享的 1×1 缺失灰都算):
40
+ * 消费方据此直接绑定,不再排预上传队列。
41
+ * ⚠️ 返回 true 的纹理绝不能喂给 `renderer.initTexture` —— 前者是 three 自己管的渲染目标附件。 */
39
42
  resident(tex: Texture): boolean;
40
43
  /** 该批次是否已在册(诊断/测试用) */
41
44
  has(batch: string): boolean;
@@ -83,6 +86,8 @@ export declare class PanoTileStitcher implements PanoStitcher {
83
86
  private _blank;
84
87
  /** 本拼合器发出去的槽纹理 = RT 附件(`resident()` 认它);⛔ 不许喂给 `renderer.initTexture` */
85
88
  private _rtTextures;
89
+ /** 「还没画过」那一路交出去的共享 1×1 素色纹理(见 `createGrayStandIn`):一座拼合器一份,⛔ 不随组建/拆 */
90
+ private _grayStandIn;
86
91
  constructor();
87
92
  /** 确保批次已建组、加载已发起。幂等:同批已在册 ⇒ 直接返回(一片瓦片终身只下载、只解码一次)。
88
93
  * ⭐ 09-29「不足的涂灰」+ 2026-10-27「8×4 = 32 片」:1~32 条皆可入组,内部补空串占位到 32;
@@ -101,10 +106,13 @@ export declare class PanoTileStitcher implements PanoStitcher {
101
106
  /** 这一组的每一格要么已经画进 RT,要么根本没有地址 / 地址已判死 ⇒ 该下的都下完了(队列出清判据)。 */
102
107
  private _isSettled;
103
108
  /** 取某一拼合槽的纹理:**画过 ≥1 格就交出去**(同日旨「需要先喂给 shader」⇒ 不再等 `cellsPerSlot` 格画满)。
104
- * RT 建好时整张涂过缺失灰 ⇒ 交出去的那张里**没有未定义像素**,观感 = 该象限逐格显影。 */
109
+ * RT 建好时整张涂过缺失灰 ⇒ 交出去的那张里**没有未定义像素**,观感 = 该象限逐格显影。
110
+ * 还没画过任何格时交的是**共享的 1×1 素色纹理**(屏幕上与「刚涂满缺失灰的那张 RT」同色,见 `createGrayStandIn`),
111
+ * ⛔ 不再为「垫一片灰」当场建一张 6144×6144 的渲染目标。 */
105
112
  peek(batch: string, slot: number, renderer?: WebGLRenderer | null): Texture | null;
106
113
  /** ⭐ 这条路交出去的槽纹理本来就是 GL 渲染目标的附件 —— 像素已经在显存里,没有「上传」这一步。
107
- * 消费方(`MktMesh._peekTileAt` / 编辑器 `_peekTileAt`)据此**跳过**「每 tick 一张 `initTexture`」那道闸。 */
114
+ * 消费方(`MktMesh._peekTileAt` / 编辑器 `_peekTileAt`)据此**跳过**「每 tick 一张 `initTexture`」那道闸。
115
+ * 「还没画过」那一路交的共享 1×1 素色同样认(它本来就只是给 sampler 垫一个合法纹理,⛔ 别把它排进上传队列)。 */
108
116
  resident(tex: Texture): boolean;
109
117
  has(batch: string): boolean;
110
118
  cellsDrawn(batch: string): number;
@@ -140,6 +140,28 @@ function grayToSrgbVec3(hex) {
140
140
  const byte = (i) => parseInt(s.slice(i, i + 2), 16) / 255;
141
141
  return new Vector3(byte(0), byte(2), byte(4));
142
142
  }
143
+ /** 「这张槽还没有任何内容」时交出去的**共享**素色纹理(2026-11-01 需求「进厅不要卡一下」)。
144
+ *
145
+ * 为什么不是「先建一张全尺寸 RT、整张涂灰、再交出去」:那条路在消费方点到某个还没画过的槽时就跑一次,
146
+ * 而一次点屏会把名册那 8 个景点 × 每点 2 槽 = 16 个槽全点一遍 ⇒ 同一帧里建出 16 张 6144×6144 的渲染目标
147
+ * (单张 151MB 显存,含 mipmap ≈201MB;外加 16 次整张预涂 = 约 6 亿像素填充 + 16 条 mipmap 链)。
148
+ * 而这一刻屏幕上要的**只是一片素色灰**,逐像素根本看不出尺寸 ⇒ 交这张 1×1 的:
149
+ * 字节 = `MISSING_TILE_GRAY`(与「新建 RT 整张预涂灰」落进显存的字节逐字相同),标 `SRGBColorSpace`
150
+ * ⇒ 采样出来的线性值与采那张灰 RT 完全一致 ⇒ 屏幕上同一色,显存与那一帧的填充归零。
151
+ * 真正的 RT 退回**懒建**:`_drawPass` 画进该槽第一格瓦片时才建(建好仍立刻整张涂灰,未定义像素那条规矩一字未动)。 */
152
+ function createGrayStandIn() {
153
+ const s = MISSING_TILE_GRAY.replace("#", "");
154
+ const byte = (i) => parseInt(s.slice(i, i + 2), 16);
155
+ const tex = new DataTexture(new Uint8Array([byte(0), byte(2), byte(4), 255]), 1, 1, RGBAFormat, UnsignedByteType);
156
+ tex.colorSpace = SRGBColorSpace;
157
+ tex.wrapS = ClampToEdgeWrapping;
158
+ tex.wrapT = ClampToEdgeWrapping;
159
+ tex.minFilter = LinearFilter;
160
+ tex.magFilter = LinearFilter;
161
+ tex.generateMipmaps = false;
162
+ tex.needsUpdate = true;
163
+ return tex;
164
+ }
143
165
  /** 全屏 quad 的片元:只干「把一片瓦片的**中间那块**原样搬进 RT 的一格」。
144
166
  * ⛔ 不加色彩转换、不加 tone mapping(见文件头画质第 2 条)。 */
145
167
  const STITCH_VERT = /* glsl */ `
@@ -213,6 +235,8 @@ export class PanoTileStitcher {
213
235
  _blank = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1);
214
236
  /** 本拼合器发出去的槽纹理 = RT 附件(`resident()` 认它);⛔ 不许喂给 `renderer.initTexture` */
215
237
  _rtTextures = new WeakSet();
238
+ /** 「还没画过」那一路交出去的共享 1×1 素色纹理(见 `createGrayStandIn`):一座拼合器一份,⛔ 不随组建/拆 */
239
+ _grayStandIn = createGrayStandIn();
216
240
  constructor() {
217
241
  this._quad.frustumCulled = false; // 正交相机 + 手工摆位,⛔ 别按包围球把格子剔没了
218
242
  this._scene.add(this._quad);
@@ -287,32 +311,36 @@ export class PanoTileStitcher {
287
311
  return true;
288
312
  }
289
313
  /** 取某一拼合槽的纹理:**画过 ≥1 格就交出去**(同日旨「需要先喂给 shader」⇒ 不再等 `cellsPerSlot` 格画满)。
290
- * RT 建好时整张涂过缺失灰 ⇒ 交出去的那张里**没有未定义像素**,观感 = 该象限逐格显影。 */
314
+ * RT 建好时整张涂过缺失灰 ⇒ 交出去的那张里**没有未定义像素**,观感 = 该象限逐格显影。
315
+ * 还没画过任何格时交的是**共享的 1×1 素色纹理**(屏幕上与「刚涂满缺失灰的那张 RT」同色,见 `createGrayStandIn`),
316
+ * ⛔ 不再为「垫一片灰」当场建一张 6144×6144 的渲染目标。 */
291
317
  peek(batch, slot, renderer) {
292
318
  const g = this._groups.get(batch);
293
319
  if (!g)
294
320
  return null;
295
321
  if (!g.drawnCount[slot]) {
296
- // ⭐ 皇上 2026-10-09 旨「切换全景时上一个全景的内容要渲染成深灰」:
322
+ // 需求(2026-10-09)「切换全景时上一个全景的内容要渲染成深灰」:
297
323
  // 换批次的瞬间新全景还没画进任何格,若此处照旧返回 null,消费方 `panoSlotBind` 会
298
- // **沿用上一批的 RT 纹理** ⇒ 墙上继续显示上一个全景(皇上报的现象)。
299
- // 这里改为:只要调用方给了 renderer,就**先把这张 RT 建出来**(`_ensureRt` 建完立刻
300
- // 整张涂 `MISSING_TILE_GRAY` 缺失灰)并交出去 ⇒ 切换瞬间该象限就是**深灰底**,
301
- // 随后随`pump` 逐格显影。观感 = 深灰 → 逐格填图,不再闪上一个全景的内容。
302
- // ⛔ 没给 renderer(诊断/测试路径)仍旧返回 null —— 不能在无 GL 上下文时凭空建 RT。
324
+ // **沿用上一批的 RT 纹理** ⇒ 墙上继续显示上一个全景(报上来的现象)。
325
+ // 所以这一支仍要**交出点东西**:现在交的是共享那张 1×1 素色(与缺失灰逐字节同值),
326
+ // 随后随 `pump` 逐格显影。观感 = 深灰 → 逐格填图,一字未变;变的是「不再为一片灰建整张 RT」。
327
+ // ⚠️ 2026-11-01:这一支原先是 `this._ensureRt(...)` ⇒ 一次点屏(名册 8 点 × 2 槽)就在同一帧
328
+ // 建出 16 张 6144² 渲染目标(≈3.2GB 显存 + 6 亿像素预涂),正是「进厅那一下卡」的本体。
329
+ // ⛔ 没给 renderer(诊断/测试路径)仍旧返回 null,与消费方约定的「问不到就是没就绪」一致。
303
330
  if (!renderer)
304
331
  return null;
305
332
  if (!this._renderer)
306
333
  return null;
307
- return this._ensureRt(this._renderer, g, slot).texture;
334
+ return this._grayStandIn;
308
335
  }
309
336
  const rt = g.rts[slot];
310
337
  return rt ? rt.texture : null;
311
338
  }
312
339
  /** ⭐ 这条路交出去的槽纹理本来就是 GL 渲染目标的附件 —— 像素已经在显存里,没有「上传」这一步。
313
- * 消费方(`MktMesh._peekTileAt` / 编辑器 `_peekTileAt`)据此**跳过**「每 tick 一张 `initTexture`」那道闸。 */
340
+ * 消费方(`MktMesh._peekTileAt` / 编辑器 `_peekTileAt`)据此**跳过**「每 tick 一张 `initTexture`」那道闸。
341
+ * 「还没画过」那一路交的共享 1×1 素色同样认(它本来就只是给 sampler 垫一个合法纹理,⛔ 别把它排进上传队列)。 */
314
342
  resident(tex) {
315
- return this._rtTextures.has(tex);
343
+ return this._rtTextures.has(tex) || tex === this._grayStandIn;
316
344
  }
317
345
  has(batch) {
318
346
  return this._groups.has(batch);
@@ -362,6 +390,7 @@ export class PanoTileStitcher {
362
390
  this._quad.geometry.dispose();
363
391
  this._mat.dispose();
364
392
  this._blank.dispose();
393
+ this._grayStandIn.dispose();
365
394
  this._scene.remove(this._quad);
366
395
  }
367
396
  // ───────────────────────── 内部:每帧三道工序 ─────────────────────────