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.
@@ -177,6 +177,29 @@ function grayToSrgbVec3(hex: string): Vector3 {
177
177
  return new Vector3(byte(0), byte(2), byte(4));
178
178
  }
179
179
 
180
+ /** 「这张槽还没有任何内容」时交出去的**共享**素色纹理(2026-11-01 需求「进厅不要卡一下」)。
181
+ *
182
+ * 为什么不是「先建一张全尺寸 RT、整张涂灰、再交出去」:那条路在消费方点到某个还没画过的槽时就跑一次,
183
+ * 而一次点屏会把名册那 8 个景点 × 每点 2 槽 = 16 个槽全点一遍 ⇒ 同一帧里建出 16 张 6144×6144 的渲染目标
184
+ * (单张 151MB 显存,含 mipmap ≈201MB;外加 16 次整张预涂 = 约 6 亿像素填充 + 16 条 mipmap 链)。
185
+ * 而这一刻屏幕上要的**只是一片素色灰**,逐像素根本看不出尺寸 ⇒ 交这张 1×1 的:
186
+ * 字节 = `MISSING_TILE_GRAY`(与「新建 RT 整张预涂灰」落进显存的字节逐字相同),标 `SRGBColorSpace`
187
+ * ⇒ 采样出来的线性值与采那张灰 RT 完全一致 ⇒ 屏幕上同一色,显存与那一帧的填充归零。
188
+ * 真正的 RT 退回**懒建**:`_drawPass` 画进该槽第一格瓦片时才建(建好仍立刻整张涂灰,未定义像素那条规矩一字未动)。 */
189
+ function createGrayStandIn(): DataTexture {
190
+ const s = MISSING_TILE_GRAY.replace("#", "");
191
+ const byte = (i: number) => parseInt(s.slice(i, i + 2), 16);
192
+ const tex = new DataTexture(new Uint8Array([byte(0), byte(2), byte(4), 255]), 1, 1, RGBAFormat, UnsignedByteType);
193
+ tex.colorSpace = SRGBColorSpace;
194
+ tex.wrapS = ClampToEdgeWrapping;
195
+ tex.wrapT = ClampToEdgeWrapping;
196
+ tex.minFilter = LinearFilter;
197
+ tex.magFilter = LinearFilter;
198
+ tex.generateMipmaps = false;
199
+ tex.needsUpdate = true;
200
+ return tex;
201
+ }
202
+
180
203
  /** 全屏 quad 的片元:只干「把一片瓦片的**中间那块**原样搬进 RT 的一格」。
181
204
  * ⛔ 不加色彩转换、不加 tone mapping(见文件头画质第 2 条)。 */
182
205
  const STITCH_VERT = /* glsl */ `
@@ -258,17 +281,20 @@ export interface PanoStitcher {
258
281
  prioritize(urls: string[], layout?: TileLayout): void;
259
282
  /**
260
283
  * 取某一拼合槽的纹理。**画过 ≥1 格就返回**(同日旨「需要先喂给 shader」)。
261
- * ⭐ 2026-10-09 皇上旨「切换全景时上一个全景的内容要渲染成深灰」:**传了 `renderer` 且该槽一格都还没画时,
262
- * 先把 RT 建出来(建完即整张涂 `MISSING_TILE_GRAY` 缺失灰)并返回它** ⇒ 调用方切批次的那一帧
284
+ * ⭐ 2026-10-09 需求「切换全景时上一个全景的内容要渲染成深灰」:**传了 `renderer` 且该槽一格都还没画时
285
+ * 返回共享的 1×1 缺失灰纹理**(屏幕上与「刚整张涂过灰的那张 RT」逐字节同色)⇒ 调用方切批次的那一帧
263
286
  * 拿到的就是**深灰底**而非 null(null 会让它沿用上一批 ⇒ 墙上闪上一个全景)。
264
- * ⛔ 没传 `renderer`(诊断/测试路径)仍旧返回 null —— 不能在无 GL 上下文时凭空建 RT。
287
+ * ⚠️ 2026-11-01:这一支原先是**当场建一张 6144×6144 的 RT** 再交出去 ⇒ 名册 8 点 × 2 槽 = 同一帧 16 张
288
+ * (≈3.2GB 显存 + 约 6 亿像素预涂),是「进厅那一下卡」的本体;现在 RT 退回 `_drawPass` 画第一格时懒建。
289
+ * ⛔ 没传 `renderer`(诊断/测试路径)仍旧返回 null。
265
290
  */
266
291
  peek(batch: string, slot: number, renderer?: WebGLRenderer | null): Texture | null;
267
292
  /** 每帧驱动(消费方每帧只调一次)。`renderer` = 调用方当前的 WebGLRenderer,还没捕获到就传 null
268
293
  * ⇒ 这一帧什么都不画(⛔ 不打假读数,也⛔ 不退回任何 CPU 拼合路径)。 */
269
294
  pump(renderer: WebGLRenderer | null): void;
270
- /** 这张槽纹理是否**已经在 GPU 上**(RT 版恒 true):消费方据此跳过「每 tick 一张」预上传闸。
271
- * ⚠️ 返回 true 的纹理绝不能喂给 `renderer.initTexture` —— 它不是普通纹理,是 three 自己管的渲染目标附件。 */
295
+ /** 这张槽纹理是否**不需要走「每 tick 一张上传」那道闸**(RT 附件与共享的 1×1 缺失灰都算):
296
+ * 消费方据此直接绑定,不再排预上传队列。
297
+ * ⚠️ 返回 true 的纹理绝不能喂给 `renderer.initTexture` —— 前者是 three 自己管的渲染目标附件。 */
272
298
  resident(tex: Texture): boolean;
273
299
  /** 该批次是否已在册(诊断/测试用) */
274
300
  has(batch: string): boolean;
@@ -331,6 +357,8 @@ private _uploadQueued: Set<TileRec> = new Set();
331
357
  private _blank: DataTexture = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1);
332
358
  /** 本拼合器发出去的槽纹理 = RT 附件(`resident()` 认它);⛔ 不许喂给 `renderer.initTexture` */
333
359
  private _rtTextures: WeakSet<Texture> = new WeakSet();
360
+ /** 「还没画过」那一路交出去的共享 1×1 素色纹理(见 `createGrayStandIn`):一座拼合器一份,⛔ 不随组建/拆 */
361
+ private _grayStandIn: DataTexture = createGrayStandIn();
334
362
 
335
363
  constructor() {
336
364
  this._quad.frustumCulled = false; // 正交相机 + 手工摆位,⛔ 别按包围球把格子剔没了
@@ -403,30 +431,34 @@ private _uploadQueued: Set<TileRec> = new Set();
403
431
  }
404
432
 
405
433
  /** 取某一拼合槽的纹理:**画过 ≥1 格就交出去**(同日旨「需要先喂给 shader」⇒ 不再等 `cellsPerSlot` 格画满)。
406
- * RT 建好时整张涂过缺失灰 ⇒ 交出去的那张里**没有未定义像素**,观感 = 该象限逐格显影。 */
434
+ * RT 建好时整张涂过缺失灰 ⇒ 交出去的那张里**没有未定义像素**,观感 = 该象限逐格显影。
435
+ * 还没画过任何格时交的是**共享的 1×1 素色纹理**(屏幕上与「刚涂满缺失灰的那张 RT」同色,见 `createGrayStandIn`),
436
+ * ⛔ 不再为「垫一片灰」当场建一张 6144×6144 的渲染目标。 */
407
437
  peek(batch: string, slot: number, renderer?: WebGLRenderer | null): Texture | null {
408
438
  const g = this._groups.get(batch);
409
439
  if (!g) return null;
410
440
  if (!g.drawnCount[slot]) {
411
- // ⭐ 皇上 2026-10-09 旨「切换全景时上一个全景的内容要渲染成深灰」:
441
+ // 需求(2026-10-09)「切换全景时上一个全景的内容要渲染成深灰」:
412
442
  // 换批次的瞬间新全景还没画进任何格,若此处照旧返回 null,消费方 `panoSlotBind` 会
413
- // **沿用上一批的 RT 纹理** ⇒ 墙上继续显示上一个全景(皇上报的现象)。
414
- // 这里改为:只要调用方给了 renderer,就**先把这张 RT 建出来**(`_ensureRt` 建完立刻
415
- // 整张涂 `MISSING_TILE_GRAY` 缺失灰)并交出去 ⇒ 切换瞬间该象限就是**深灰底**,
416
- // 随后随`pump` 逐格显影。观感 = 深灰 → 逐格填图,不再闪上一个全景的内容。
417
- // ⛔ 没给 renderer(诊断/测试路径)仍旧返回 null —— 不能在无 GL 上下文时凭空建 RT。
443
+ // **沿用上一批的 RT 纹理** ⇒ 墙上继续显示上一个全景(报上来的现象)。
444
+ // 所以这一支仍要**交出点东西**:现在交的是共享那张 1×1 素色(与缺失灰逐字节同值),
445
+ // 随后随 `pump` 逐格显影。观感 = 深灰 → 逐格填图,一字未变;变的是「不再为一片灰建整张 RT」。
446
+ // ⚠️ 2026-11-01:这一支原先是 `this._ensureRt(...)` ⇒ 一次点屏(名册 8 点 × 2 槽)就在同一帧
447
+ // 建出 16 张 6144² 渲染目标(≈3.2GB 显存 + 6 亿像素预涂),正是「进厅那一下卡」的本体。
448
+ // ⛔ 没给 renderer(诊断/测试路径)仍旧返回 null,与消费方约定的「问不到就是没就绪」一致。
418
449
  if (!renderer) return null;
419
450
  if (!this._renderer) return null;
420
- return this._ensureRt(this._renderer, g, slot).texture;
451
+ return this._grayStandIn;
421
452
  }
422
453
  const rt = g.rts[slot];
423
454
  return rt ? rt.texture : null;
424
455
  }
425
456
 
426
457
  /** ⭐ 这条路交出去的槽纹理本来就是 GL 渲染目标的附件 —— 像素已经在显存里,没有「上传」这一步。
427
- * 消费方(`MktMesh._peekTileAt` / 编辑器 `_peekTileAt`)据此**跳过**「每 tick 一张 `initTexture`」那道闸。 */
458
+ * 消费方(`MktMesh._peekTileAt` / 编辑器 `_peekTileAt`)据此**跳过**「每 tick 一张 `initTexture`」那道闸。
459
+ * 「还没画过」那一路交的共享 1×1 素色同样认(它本来就只是给 sampler 垫一个合法纹理,⛔ 别把它排进上传队列)。 */
428
460
  resident(tex: Texture): boolean {
429
- return this._rtTextures.has(tex);
461
+ return this._rtTextures.has(tex) || tex === this._grayStandIn;
430
462
  }
431
463
 
432
464
  has(batch: string): boolean {
@@ -476,6 +508,7 @@ private _uploadQueued: Set<TileRec> = new Set();
476
508
  this._quad.geometry.dispose();
477
509
  this._mat.dispose();
478
510
  this._blank.dispose();
511
+ this._grayStandIn.dispose();
479
512
  this._scene.remove(this._quad);
480
513
  }
481
514
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-base",
3
- "version": "2.0.195",
3
+ "version": "2.0.197",
4
4
  "description": "A Three.js extension library providing common 3D components, animations, materials, and utilities",
5
5
  "author": "xiaosihan",
6
6
  "publishConfig": {
package/threeLoader.d.ts CHANGED
@@ -9,6 +9,20 @@ import { PLYLoader } from "three/examples/jsm/Addons";
9
9
  import type { ThreeLoaderEventMap } from "./EventMap";
10
10
  declare class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
11
11
  constructor();
12
+ /** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
13
+ private _failedUrls;
14
+ /** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
15
+ loadErrorCount: number;
16
+ /** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
17
+ * 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
18
+ private _urlStatus;
19
+ /** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
20
+ markUrlStatus(url: string, status: number): void;
21
+ /** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
22
+ reportLoadError(url: string, kind: "asset" | "model", label?: string): void;
23
+ /** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
24
+ * three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
25
+ private _noteErrStatus;
12
26
  getFilename(url: string): string;
13
27
  uuid: string;
14
28
  anisotropy: number;
package/threeLoader.js CHANGED
@@ -40,6 +40,12 @@ class ThreeLoader extends EventDispatcher {
40
40
  DefaultLoadingManager.onLoad = () => {
41
41
  this.onload = true;
42
42
  };
43
+ // 资源加载失败:走 manager 计数的每一条(全景整图/32 片切片、图片平面 4 片、GLB 下载与内嵌贴图)
44
+ // 都只从这里出,页面侧仍只订本类这一个出口(与 onProgress 同规矩,⛔ 页面不许自己占 manager 的槽位)。
45
+ // 同一地址去重:全景/平面那两路失败会每帧重发请求 ⇒ 不去重「失败数」会被刷成无穷大。
46
+ DefaultLoadingManager.onError = (url) => {
47
+ this.reportLoadError(url, "asset");
48
+ };
43
49
  // 资源替换
44
50
  DefaultLoadingManager.setURLModifier((url) => {
45
51
  //全路径匹配资源
@@ -58,6 +64,40 @@ class ThreeLoader extends EventDispatcher {
58
64
  return new_url;
59
65
  });
60
66
  }
67
+ /** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
68
+ _failedUrls = new Set();
69
+ /** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
70
+ loadErrorCount = 0;
71
+ /** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
72
+ * 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
73
+ _urlStatus = new Map();
74
+ /** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
75
+ markUrlStatus(url, status) {
76
+ if (url)
77
+ this._urlStatus.set(url, status);
78
+ }
79
+ /** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
80
+ reportLoadError(url, kind, label) {
81
+ const isNew = !this._failedUrls.has(url);
82
+ if (isNew) {
83
+ this._failedUrls.add(url);
84
+ this.loadErrorCount++;
85
+ }
86
+ const status = this._urlStatus.get(url);
87
+ // 403 与 404 并一档:OSS 私有桶里「key 不存在」回的正是 403(AccessDenied 用来不泄露对象是否存在)。
88
+ const missing = status === 404 || status === 403;
89
+ this.dispatchEvent({ type: "loadError", url, kind, label, status, missing, failed: this.loadErrorCount, isNew });
90
+ }
91
+ /** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
92
+ * three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
93
+ _noteErrStatus(url, err) {
94
+ if (!isString(url))
95
+ return;
96
+ const e = err;
97
+ const raw = e?.status ?? e?.response?.status ?? Number(/(?:responded with|HTTP) (\d{3})/.exec(e?.message ?? "")?.[1]);
98
+ if (Number.isFinite(raw))
99
+ this.markUrlStatus(url, raw);
100
+ }
61
101
  // 从url 中提取文件名
62
102
  getFilename(url) {
63
103
  // 移除URL的查询字符串和哈希部分
@@ -364,7 +404,8 @@ class ThreeLoader extends EventDispatcher {
364
404
  });
365
405
  return res;
366
406
  })
367
- .catch(() => {
407
+ .catch((err) => {
408
+ this._noteErrStatus(url, err);
368
409
  console.error(`${url},加载失败`);
369
410
  return undefined;
370
411
  });
@@ -381,6 +422,7 @@ class ThreeLoader extends EventDispatcher {
381
422
  return res.scene;
382
423
  })
383
424
  .catch((err) => {
425
+ this._noteErrStatus(url, err);
384
426
  // 打印详细错误信息,方便调试
385
427
  console.error(`${url},加载失败`, err);
386
428
  return undefined;
@@ -398,7 +440,8 @@ class ThreeLoader extends EventDispatcher {
398
440
  roughness: 0.5,
399
441
  }));
400
442
  })
401
- .catch(() => {
443
+ .catch((err) => {
444
+ this._noteErrStatus(url, err);
402
445
  console.error(`${url},加载失败`);
403
446
  return undefined;
404
447
  });
@@ -434,7 +477,8 @@ class ThreeLoader extends EventDispatcher {
434
477
  }
435
478
  return plyMesh;
436
479
  })
437
- .catch(() => {
480
+ .catch((err) => {
481
+ this._noteErrStatus(url, err);
438
482
  console.error(`${url},加载失败`);
439
483
  return undefined;
440
484
  });
@@ -462,6 +506,7 @@ class ThreeLoader extends EventDispatcher {
462
506
  return res;
463
507
  })
464
508
  .catch((err) => {
509
+ this._noteErrStatus(url, err);
465
510
  console.error(`${url},加载失败`, err);
466
511
  return undefined;
467
512
  });
package/threeLoader.ts CHANGED
@@ -44,6 +44,12 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
44
44
  DefaultLoadingManager.onLoad = () => {
45
45
  this.onload = true;
46
46
  };
47
+ // 资源加载失败:走 manager 计数的每一条(全景整图/32 片切片、图片平面 4 片、GLB 下载与内嵌贴图)
48
+ // 都只从这里出,页面侧仍只订本类这一个出口(与 onProgress 同规矩,⛔ 页面不许自己占 manager 的槽位)。
49
+ // 同一地址去重:全景/平面那两路失败会每帧重发请求 ⇒ 不去重「失败数」会被刷成无穷大。
50
+ DefaultLoadingManager.onError = (url: string) => {
51
+ this.reportLoadError(url, "asset");
52
+ };
47
53
  // 资源替换
48
54
  DefaultLoadingManager.setURLModifier((url: string) => {
49
55
  //全路径匹配资源
@@ -62,6 +68,37 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
62
68
  return new_url;
63
69
  });
64
70
  }
71
+ /** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
72
+ private _failedUrls: Set<string> = new Set();
73
+ /** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
74
+ loadErrorCount: number = 0;
75
+ /** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
76
+ * 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
77
+ private _urlStatus: Map<string, number> = new Map();
78
+ /** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
79
+ markUrlStatus(url: string, status: number): void {
80
+ if (url) this._urlStatus.set(url, status);
81
+ }
82
+ /** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
83
+ reportLoadError(url: string, kind: "asset" | "model", label?: string): void {
84
+ const isNew = !this._failedUrls.has(url);
85
+ if (isNew) {
86
+ this._failedUrls.add(url);
87
+ this.loadErrorCount++;
88
+ }
89
+ const status = this._urlStatus.get(url);
90
+ // 403 与 404 并一档:OSS 私有桶里「key 不存在」回的正是 403(AccessDenied 用来不泄露对象是否存在)。
91
+ const missing = status === 404 || status === 403;
92
+ this.dispatchEvent({ type: "loadError", url, kind, label, status, missing, failed: this.loadErrorCount, isNew });
93
+ }
94
+ /** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
95
+ * three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
96
+ private _noteErrStatus(url: string | File | Array<File>, err: unknown): void {
97
+ if (!isString(url)) return;
98
+ const e = err as { status?: number; response?: { status?: number }; message?: string };
99
+ const raw = e?.status ?? e?.response?.status ?? Number(/(?:responded with|HTTP) (\d{3})/.exec(e?.message ?? "")?.[1]);
100
+ if (Number.isFinite(raw)) this.markUrlStatus(url, raw);
101
+ }
65
102
  // 从url 中提取文件名
66
103
  getFilename(url: string): string {
67
104
  // 移除URL的查询字符串和哈希部分
@@ -370,7 +407,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
370
407
  });
371
408
  return res;
372
409
  })
373
- .catch(() => {
410
+ .catch((err) => {
411
+ this._noteErrStatus(url, err);
374
412
  console.error(`${url},加载失败`);
375
413
  return undefined;
376
414
  });
@@ -387,6 +425,7 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
387
425
  return res.scene;
388
426
  })
389
427
  .catch((err) => {
428
+ this._noteErrStatus(url, err);
390
429
  // 打印详细错误信息,方便调试
391
430
  console.error(`${url},加载失败`, err);
392
431
  return undefined;
@@ -404,7 +443,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
404
443
  roughness: 0.5,
405
444
  }));
406
445
  })
407
- .catch(() => {
446
+ .catch((err) => {
447
+ this._noteErrStatus(url, err);
408
448
  console.error(`${url},加载失败`);
409
449
  return undefined;
410
450
  });
@@ -440,7 +480,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
440
480
  }
441
481
  return plyMesh;
442
482
  })
443
- .catch(() => {
483
+ .catch((err) => {
484
+ this._noteErrStatus(url, err);
444
485
  console.error(`${url},加载失败`);
445
486
  return undefined;
446
487
  });
@@ -467,6 +508,7 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
467
508
  return res;
468
509
  })
469
510
  .catch((err) => {
511
+ this._noteErrStatus(url, err);
470
512
  console.error(`${url},加载失败`, err);
471
513
  return undefined;
472
514
  });