@ifc-lite/renderer 1.37.0 → 1.38.1
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.
- package/dist/device.d.ts +32 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +62 -2
- package/dist/device.js.map +1 -1
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +262 -57
- package/dist/index.js.map +1 -1
- package/dist/scene.d.ts +29 -3
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +120 -35
- package/dist/scene.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/visibility-epoch.d.ts +30 -0
- package/dist/visibility-epoch.d.ts.map +1 -0
- package/dist/visibility-epoch.js +64 -0
- package/dist/visibility-epoch.js.map +1 -0
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -59,6 +59,7 @@ import { PickingManager } from './picking-manager.js';
|
|
|
59
59
|
import { RaycastEngine } from './raycast-engine.js';
|
|
60
60
|
import { PostProcessor } from './post-processor.js';
|
|
61
61
|
import { InteractionEffectsGovernor } from './interaction-effects-governor.js';
|
|
62
|
+
import { VisibilityEpochTracker } from './visibility-epoch.js';
|
|
62
63
|
import { resolveContributionThresholdPx, projectedAabbRadiusPx, projectedInstancedRadiusPx } from './contribution-cull.js';
|
|
63
64
|
import { EdlPass } from './edl-pass.js';
|
|
64
65
|
import { SkyPass } from './sky-pass.js';
|
|
@@ -125,6 +126,15 @@ export class Renderer {
|
|
|
125
126
|
/** Set true at the end of `init()`; gates `whenReady()`. */
|
|
126
127
|
ready = false;
|
|
127
128
|
readyWaiters = [];
|
|
129
|
+
/**
|
|
130
|
+
* Set once the GPU device is lost for a non-intentional reason (driver
|
|
131
|
+
* reset / VRAM exhaustion — see `WebGPUDevice`). Every GPU resource is then
|
|
132
|
+
* dead, so `render()` becomes a no-op (it would only spew validation errors)
|
|
133
|
+
* until the host re-initialises the renderer. Consumers learn of this via
|
|
134
|
+
* `onDeviceLost` and typically respond by reloading the model.
|
|
135
|
+
*/
|
|
136
|
+
deviceLost = false;
|
|
137
|
+
deviceLostListeners = new Set();
|
|
128
138
|
deviationPipeline = null;
|
|
129
139
|
/**
|
|
130
140
|
* Cache of which mesh-set the BVH was built from. We rebuild on
|
|
@@ -145,8 +155,11 @@ export class Renderer {
|
|
|
145
155
|
// Composition: delegate to extracted managers
|
|
146
156
|
pickingManager;
|
|
147
157
|
raycastEngine;
|
|
148
|
-
// Error rate limiting (log at most once per second)
|
|
149
|
-
|
|
158
|
+
// Error rate limiting (log at most once per second). -Infinity, not 0:
|
|
159
|
+
// performance.now() is below 1000 during the first second of page life, so
|
|
160
|
+
// a 0 start would silently suppress the FIRST render/device-loss error —
|
|
161
|
+
// exactly the evidence worth keeping.
|
|
162
|
+
lastRenderErrorTime = -Infinity;
|
|
150
163
|
RENDER_ERROR_THROTTLE_MS = 1000;
|
|
151
164
|
// Diagnostic counters for mobile debugging
|
|
152
165
|
_renderCallCount = 0;
|
|
@@ -158,6 +171,35 @@ export class Renderer {
|
|
|
158
171
|
// Dirty flag: set by requestRender(), consumed by the animation loop.
|
|
159
172
|
// Centralises all render scheduling — callers never call render() directly.
|
|
160
173
|
_renderRequested = false;
|
|
174
|
+
// ─── Visibility-change bookkeeping (per-frame perf + leak fixes) ─────────
|
|
175
|
+
// Hide/isolate changes are detected by CONTENT (snapshot compare in the
|
|
176
|
+
// tracker), so callers may either mutate the same Set in place or pass a
|
|
177
|
+
// fresh Set per frame — see the RenderOptions.hiddenIds contract.
|
|
178
|
+
// `_visibilityVersion` drives the per-batch visibility cache;
|
|
179
|
+
// `_partialBatchEpoch` additionally folds colour-override changes so the
|
|
180
|
+
// partial sub-batch cache fast path stays correct.
|
|
181
|
+
_visibilityEpochs = new VisibilityEpochTracker();
|
|
182
|
+
_visibilityVersion = 0;
|
|
183
|
+
_partialBatchEpoch = 0;
|
|
184
|
+
_lastColorOverrideGen = -1;
|
|
185
|
+
_lastHadVisibilityFiltering = false;
|
|
186
|
+
// Cached per-batch visibility, valid only while `_batchVisibilityEpoch`
|
|
187
|
+
// matches `_visibilityVersion`. Avoids the O(total element count) recompute
|
|
188
|
+
// (+ per-batch visible-id Set allocation) every frame while hide/isolate
|
|
189
|
+
// holds. Keyed by batch object (immutable expressIds per instance); a
|
|
190
|
+
// rebuilt batch is a new object → recomputed lazily. WeakMap, not Map:
|
|
191
|
+
// residency eviction/restore churn while ONE filter epoch holds (e.g. a
|
|
192
|
+
// schedule animation) would otherwise pin every dead batch object until
|
|
193
|
+
// the next visibility change.
|
|
194
|
+
_batchVisibilityEpoch = -1;
|
|
195
|
+
_batchVisibilityCache = new WeakMap();
|
|
196
|
+
// Selection snapshot from the previous frame — a change triggers disposal of
|
|
197
|
+
// now-unselected hydrated meshes (leak + double-draw fix). The model index
|
|
198
|
+
// is part of the snapshot: federated models can share express ids, so a
|
|
199
|
+
// same-id selection in ANOTHER model is still a change that must free the
|
|
200
|
+
// old model's hydrated mesh.
|
|
201
|
+
_prevHydratedSelection = new Set();
|
|
202
|
+
_prevHydratedSelectionModelIndex = undefined;
|
|
161
203
|
// One-shot log guard — prints Y-up clip bounds on first section-enable so
|
|
162
204
|
// users can confirm the slider is operating on the intended range.
|
|
163
205
|
_loggedSectionBounds = false;
|
|
@@ -186,6 +228,13 @@ export class Renderer {
|
|
|
186
228
|
* Initialize renderer
|
|
187
229
|
*/
|
|
188
230
|
async init() {
|
|
231
|
+
// Clear the lost flag so a re-init (destroy()+init() on the same instance)
|
|
232
|
+
// resumes rendering instead of staying a permanent no-op from an earlier loss.
|
|
233
|
+
this.deviceLost = false;
|
|
234
|
+
// Subscribe before the device exists so a loss during the first frames
|
|
235
|
+
// is never missed — the handler is only invoked when `device.lost`
|
|
236
|
+
// actually resolves (a real fault), long after init in practice.
|
|
237
|
+
this.device.onDeviceLost((info) => this.handleDeviceLost(info));
|
|
189
238
|
await this.device.init(this.canvas);
|
|
190
239
|
// Get canvas dimensions (use pixel dimensions if set, otherwise use CSS dimensions)
|
|
191
240
|
// and clamp to the GPU's max 2D texture dimension so the initial pipeline allocations
|
|
@@ -276,6 +325,39 @@ export class Renderer {
|
|
|
276
325
|
for (const w of waiters)
|
|
277
326
|
w();
|
|
278
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Subscribe to non-intentional GPU device loss (driver reset / VRAM
|
|
330
|
+
* exhaustion — NOT an intentional `destroy()`). Fired at most once per
|
|
331
|
+
* device. After it fires, `render()` is a no-op until the renderer is
|
|
332
|
+
* re-initialised, so the typical response is to dispose this renderer and
|
|
333
|
+
* reload the model. Returns an unsubscribe function.
|
|
334
|
+
*
|
|
335
|
+
* Camera and model state live on the CPU (JS) and survive device loss, so a
|
|
336
|
+
* reload restores the model at its current orientation — the loss is a GPU
|
|
337
|
+
* event, not a data loss.
|
|
338
|
+
*/
|
|
339
|
+
onDeviceLost(listener) {
|
|
340
|
+
this.deviceLostListeners.add(listener);
|
|
341
|
+
return () => this.deviceLostListeners.delete(listener);
|
|
342
|
+
}
|
|
343
|
+
/** True once the GPU device has been lost for a non-intentional reason. */
|
|
344
|
+
isDeviceLost() {
|
|
345
|
+
return this.deviceLost;
|
|
346
|
+
}
|
|
347
|
+
handleDeviceLost(info) {
|
|
348
|
+
if (this.deviceLost)
|
|
349
|
+
return;
|
|
350
|
+
this.deviceLost = true;
|
|
351
|
+
console.warn('[Renderer] GPU device lost — halting rendering until re-init:', info.message);
|
|
352
|
+
for (const listener of this.deviceLostListeners) {
|
|
353
|
+
try {
|
|
354
|
+
listener(info);
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
console.error('[Renderer] onDeviceLost listener threw:', e);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
279
361
|
/**
|
|
280
362
|
* Replace all loaded point clouds with `assets`.
|
|
281
363
|
*
|
|
@@ -797,7 +879,10 @@ export class Renderer {
|
|
|
797
879
|
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
798
880
|
});
|
|
799
881
|
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
800
|
-
// Add to scene with identity transform (positions already in world space)
|
|
882
|
+
// Add to scene with identity transform (positions already in world space).
|
|
883
|
+
// Flagged `hydrated` so it can be freed when its entity leaves the
|
|
884
|
+
// selection — these duplicate geometry already drawn by a batch and would
|
|
885
|
+
// otherwise accumulate + double-draw (see Scene.disposeHydratedMeshesExcept).
|
|
801
886
|
this.scene.addMesh({
|
|
802
887
|
expressId: meshData.expressId,
|
|
803
888
|
modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
|
|
@@ -806,6 +891,70 @@ export class Renderer {
|
|
|
806
891
|
indexCount: meshData.indices.length,
|
|
807
892
|
transform: MathUtils.identity(),
|
|
808
893
|
color: meshData.color,
|
|
894
|
+
hydrated: true,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* On a selection change, free the hydrated (pick/selection-highlight)
|
|
899
|
+
* individual meshes whose entity is no longer selected. These duplicate
|
|
900
|
+
* geometry already drawn by a batch; without this they accumulate in
|
|
901
|
+
* scene.meshes (VRAM grows with selection history) and — for transparent
|
|
902
|
+
* entities — re-draw every frame on top of their still-drawn batch copy
|
|
903
|
+
* (double alpha-blend darkens glass). Currently-selected entities keep their
|
|
904
|
+
* hydrated meshes so the highlight pass doesn't rebuild them each change.
|
|
905
|
+
* No-op when the selection set is unchanged to avoid per-frame buffer churn.
|
|
906
|
+
* Selection identity is the (modelIndex, expressId) PAIR: federated models
|
|
907
|
+
* can share express ids, so re-selecting the same id in a different model
|
|
908
|
+
* must still free the previous model's hydrated mesh (it would otherwise
|
|
909
|
+
* stay resident and keep drawing unhighlighted).
|
|
910
|
+
*/
|
|
911
|
+
syncHydratedSelectionMeshes(selected, selectedModelIndex) {
|
|
912
|
+
const prev = this._prevHydratedSelection;
|
|
913
|
+
let changed = selected.size !== prev.size
|
|
914
|
+
|| selectedModelIndex !== this._prevHydratedSelectionModelIndex;
|
|
915
|
+
if (!changed) {
|
|
916
|
+
for (const id of selected) {
|
|
917
|
+
if (!prev.has(id)) {
|
|
918
|
+
changed = true;
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!changed)
|
|
924
|
+
return;
|
|
925
|
+
this._prevHydratedSelection = new Set(selected);
|
|
926
|
+
this._prevHydratedSelectionModelIndex = selectedModelIndex;
|
|
927
|
+
this.scene.disposeHydratedMeshesExcept(selected, selectedModelIndex);
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Pop the frame's validation error scope, recording any captured validation
|
|
931
|
+
* error into the device diagnostics (getDiagnostics().gpuErrors). The pop
|
|
932
|
+
* itself REJECTS when the GPU device is lost while the scope is pending —
|
|
933
|
+
* often the only evidence of the loss — so the rejection is logged
|
|
934
|
+
* (throttled) and the context invalidated, never swallowed silently. Used
|
|
935
|
+
* by every render() exit path that pushed a scope, so push/pop stay
|
|
936
|
+
* balanced even on skipped or throwing frames.
|
|
937
|
+
*/
|
|
938
|
+
drainErrorScope(device) {
|
|
939
|
+
device.popErrorScope().then((error) => {
|
|
940
|
+
if (error) {
|
|
941
|
+
const msg = error.message || String(error);
|
|
942
|
+
console.error('[WebGPU] Validation error in render pass:', msg);
|
|
943
|
+
this.device._lastUncapturedError = `VALIDATION: ${msg}`;
|
|
944
|
+
this.device._uncapturedErrorCount++;
|
|
945
|
+
}
|
|
946
|
+
}).catch((error) => {
|
|
947
|
+
// popErrorScope() rejects (e.g. "Instance dropped in popErrorScope")
|
|
948
|
+
// when the GPU device is lost while the scope is still pending. This
|
|
949
|
+
// escapes the surrounding synchronous try/catch and would otherwise
|
|
950
|
+
// surface as an unhandled rejection. Treat it like any other device
|
|
951
|
+
// loss: invalidate the context so it reconfigures next frame.
|
|
952
|
+
this.device.invalidateContext();
|
|
953
|
+
const now = performance.now();
|
|
954
|
+
if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
|
|
955
|
+
this.lastRenderErrorTime = now;
|
|
956
|
+
console.warn('[WebGPU] popErrorScope rejected (device likely lost):', error);
|
|
957
|
+
}
|
|
809
958
|
});
|
|
810
959
|
}
|
|
811
960
|
resolveVisualEnhancement(options) {
|
|
@@ -881,6 +1030,12 @@ export class Renderer {
|
|
|
881
1030
|
}
|
|
882
1031
|
render(options = {}) {
|
|
883
1032
|
this._renderCallCount++;
|
|
1033
|
+
// A lost device leaves every pipeline/buffer dead; rendering would only
|
|
1034
|
+
// emit a stream of validation errors. Stay quiet until re-init.
|
|
1035
|
+
if (this.deviceLost) {
|
|
1036
|
+
this._renderSkipCount++;
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
884
1039
|
if (!this.device.isInitialized() || !this.pipeline) {
|
|
885
1040
|
this._renderSkipCount++;
|
|
886
1041
|
return;
|
|
@@ -969,11 +1124,35 @@ export class Renderer {
|
|
|
969
1124
|
&& visualEnhancement.separationLines.enabled
|
|
970
1125
|
&& visualEnhancement.separationLines.quality !== 'off';
|
|
971
1126
|
const needsObjectIdPass = contactEnabled || separationEnabled;
|
|
972
|
-
let meshes = this.scene.getMeshes();
|
|
973
1127
|
// Check if visibility filtering is active
|
|
974
1128
|
const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
|
|
975
1129
|
const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
|
|
976
1130
|
const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
|
|
1131
|
+
// ─── Visibility / override epoch bookkeeping ────────────────────────
|
|
1132
|
+
// The tracker compares hide/isolate CONTENT against a snapshot, so both
|
|
1133
|
+
// in-place mutation of the caller's Set and a fresh identical Set per
|
|
1134
|
+
// frame behave correctly (see RenderOptions.hiddenIds). Bumping
|
|
1135
|
+
// `_visibilityVersion` invalidates the per-batch visibility cache; the
|
|
1136
|
+
// partial sub-batch cache additionally depends on colour-override
|
|
1137
|
+
// promotion, so its epoch bumps on either.
|
|
1138
|
+
const newVisibilityVersion = this._visibilityEpochs.update(options.hiddenIds, options.isolatedIds);
|
|
1139
|
+
const visibilityChanged = newVisibilityVersion !== this._visibilityVersion;
|
|
1140
|
+
this._visibilityVersion = newVisibilityVersion;
|
|
1141
|
+
const colorOverrideGen = this.scene.getColorOverrideGeneration();
|
|
1142
|
+
if (visibilityChanged || colorOverrideGen !== this._lastColorOverrideGen) {
|
|
1143
|
+
this._lastColorOverrideGen = colorOverrideGen;
|
|
1144
|
+
this._partialBatchEpoch++;
|
|
1145
|
+
}
|
|
1146
|
+
// When hide/isolate turns fully OFF (back to all-visible), release the
|
|
1147
|
+
// partial sub-batch clones built while filtering. They are excluded from
|
|
1148
|
+
// the GPU residency budget and are otherwise only freed on clear()/
|
|
1149
|
+
// finalize/evict — never here — so ~model-sized clone VRAM would stay
|
|
1150
|
+
// pinned until the next model reload. Any override-promotion sub-batches
|
|
1151
|
+
// dropped alongside are rebuilt on demand next frame (cache miss).
|
|
1152
|
+
if (this._lastHadVisibilityFiltering && !hasVisibilityFiltering) {
|
|
1153
|
+
this.scene.dropAllPartialCaches();
|
|
1154
|
+
}
|
|
1155
|
+
this._lastHadVisibilityFiltering = hasVisibilityFiltering;
|
|
977
1156
|
// Build the selected-id set once per frame so the X-Ray override paths
|
|
978
1157
|
// can keep highlighted entities at full alpha without per-site checks.
|
|
979
1158
|
const selectedId = options.selectedId;
|
|
@@ -989,6 +1168,13 @@ export class Renderer {
|
|
|
989
1168
|
}
|
|
990
1169
|
}
|
|
991
1170
|
const hasSelected = selectedExpressIds.size > 0;
|
|
1171
|
+
// Free hydrated (pick/selection) individual meshes whose entity is no
|
|
1172
|
+
// longer selected BEFORE we snapshot the mesh list, so stale glass
|
|
1173
|
+
// doesn't double-draw over its batch copy or accumulate until clear().
|
|
1174
|
+
// Only acts on a selection change (avoids per-frame buffer churn) and
|
|
1175
|
+
// never touches authored (non-hydrated) or batch geometry.
|
|
1176
|
+
this.syncHydratedSelectionMeshes(selectedExpressIds, selectedModelIndex);
|
|
1177
|
+
let meshes = this.scene.getMeshes();
|
|
992
1178
|
// Keep the GPU-instanced occurrences' per-instance selected flag in sync.
|
|
993
1179
|
// The Scene diff makes this a no-op (no writeBuffer) when the set is
|
|
994
1180
|
// unchanged, so calling it every frame is cheap; it no-ops entirely when
|
|
@@ -1103,14 +1289,26 @@ export class Renderer {
|
|
|
1103
1289
|
this.pipeline.resize(this.canvas.width, this.canvas.height);
|
|
1104
1290
|
}
|
|
1105
1291
|
// Push a validation error scope to capture the EXACT error (for mobile debugging)
|
|
1106
|
-
// Only do this for the first few renders to avoid performance overhead
|
|
1292
|
+
// Only do this for the first few renders to avoid performance overhead.
|
|
1293
|
+
// Tracked with a flag (not just captureGpuError) so EVERY exit path below
|
|
1294
|
+
// pops it exactly once — an unpopped scope silently swallows all later
|
|
1295
|
+
// validation errors and blinds getDiagnostics().gpuErrors.
|
|
1107
1296
|
const captureGpuError = this._renderCallCount <= 5;
|
|
1297
|
+
let errorScopePushed = false;
|
|
1108
1298
|
if (captureGpuError) {
|
|
1109
1299
|
device.pushErrorScope('validation');
|
|
1300
|
+
errorScopePushed = true;
|
|
1110
1301
|
}
|
|
1111
1302
|
// Get current texture safely - may return null if context needs reconfiguration
|
|
1112
1303
|
const currentTexture = this.device.getCurrentTexture();
|
|
1113
1304
|
if (!currentTexture) {
|
|
1305
|
+
// Balance the pushed scope before bailing so it doesn't leak into
|
|
1306
|
+
// the next frame; drainErrorScope logs a rejection (device loss)
|
|
1307
|
+
// instead of swallowing the evidence.
|
|
1308
|
+
if (errorScopePushed) {
|
|
1309
|
+
errorScopePushed = false;
|
|
1310
|
+
this.drainErrorScope(device);
|
|
1311
|
+
}
|
|
1114
1312
|
return; // Skip this frame, context will be reconfigured next frame
|
|
1115
1313
|
}
|
|
1116
1314
|
try {
|
|
@@ -1538,27 +1736,41 @@ export class Renderer {
|
|
|
1538
1736
|
viewportHeightPx: this.canvas.height,
|
|
1539
1737
|
};
|
|
1540
1738
|
}
|
|
1541
|
-
//
|
|
1542
|
-
// A batch is visible if ANY of its elements are visible
|
|
1543
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
|
|
1552
|
-
if (!isHidden && isIsolated) {
|
|
1553
|
-
visibleCount++;
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
batchVisibility.set(batch, {
|
|
1557
|
-
visible: visibleCount > 0,
|
|
1558
|
-
fullyVisible: visibleCount === total,
|
|
1559
|
-
});
|
|
1560
|
-
}
|
|
1739
|
+
// Per-batch visibility (only meaningful while filtering is active).
|
|
1740
|
+
// A batch is visible if ANY of its elements are visible, fully
|
|
1741
|
+
// visible if ALL are. Cached across frames keyed by the visibility
|
|
1742
|
+
// version so the O(total element count) scan + per-batch visible-id
|
|
1743
|
+
// Set allocation happens once per visibility change, not per frame.
|
|
1744
|
+
// The cache map is keyed by batch object (immutable expressIds per
|
|
1745
|
+
// instance); a rebuilt batch is a new object → recomputed lazily.
|
|
1746
|
+
if (this._batchVisibilityEpoch !== this._visibilityVersion) {
|
|
1747
|
+
this._batchVisibilityCache = new WeakMap();
|
|
1748
|
+
this._batchVisibilityEpoch = this._visibilityVersion;
|
|
1561
1749
|
}
|
|
1750
|
+
const batchVisibilityCache = this._batchVisibilityCache;
|
|
1751
|
+
const getBatchVisibility = (batch) => {
|
|
1752
|
+
let vis = batchVisibilityCache.get(batch);
|
|
1753
|
+
if (vis)
|
|
1754
|
+
return vis;
|
|
1755
|
+
const total = batch.expressIds.length;
|
|
1756
|
+
// Build the visible-id set in one pass; drop it for fully-visible
|
|
1757
|
+
// batches (they draw from their own buffers, no subset needed).
|
|
1758
|
+
const visibleIds = new Set();
|
|
1759
|
+
for (const expressId of batch.expressIds) {
|
|
1760
|
+
const isHidden = options.hiddenIds?.has(expressId) ?? false;
|
|
1761
|
+
const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
|
|
1762
|
+
if (!isHidden && isIsolated)
|
|
1763
|
+
visibleIds.add(expressId);
|
|
1764
|
+
}
|
|
1765
|
+
const fullyVisible = visibleIds.size === total;
|
|
1766
|
+
vis = {
|
|
1767
|
+
visible: visibleIds.size > 0,
|
|
1768
|
+
fullyVisible,
|
|
1769
|
+
visibleIds: fullyVisible ? undefined : visibleIds,
|
|
1770
|
+
};
|
|
1771
|
+
batchVisibilityCache.set(batch, vis);
|
|
1772
|
+
return vis;
|
|
1773
|
+
};
|
|
1562
1774
|
// Separate batches into opaque and transparent, filtering by visibility
|
|
1563
1775
|
// IMPORTANT: Only render FULLY visible batches - partially visible batches
|
|
1564
1776
|
// need individual mesh rendering to show only the visible elements
|
|
@@ -1640,19 +1852,15 @@ export class Renderer {
|
|
|
1640
1852
|
const nativelyTransparent = alpha < 0.99;
|
|
1641
1853
|
// Check visibility
|
|
1642
1854
|
if (hasVisibilityFiltering) {
|
|
1643
|
-
const vis =
|
|
1644
|
-
if (!vis
|
|
1855
|
+
const vis = getBatchVisibility(batch);
|
|
1856
|
+
if (!vis.visible)
|
|
1645
1857
|
continue; // Skip completely hidden batches
|
|
1646
1858
|
// Handle partially visible batches - create sub-batches instead of individual meshes
|
|
1647
1859
|
if (!vis.fullyVisible) {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
if (!isHidden && isIsolated)
|
|
1653
|
-
visibleIds.add(expressId);
|
|
1654
|
-
}
|
|
1655
|
-
if (visibleIds.size > 0) {
|
|
1860
|
+
// The visible subset was computed once for this
|
|
1861
|
+
// visibility epoch (cached) — reuse it, don't rebuild.
|
|
1862
|
+
const visibleIds = vis.visibleIds;
|
|
1863
|
+
if (visibleIds && visibleIds.size > 0) {
|
|
1656
1864
|
pushVisibleAsPartial(batch, visibleIds, nativelyTransparent);
|
|
1657
1865
|
}
|
|
1658
1866
|
// A COLD parent has no CPU meshData, so the partial
|
|
@@ -1949,7 +2157,7 @@ export class Renderer {
|
|
|
1949
2157
|
if (partiallyVisibleBatches.length > 0) {
|
|
1950
2158
|
for (const { sourceBatchKey, colorKey, visibleIds, color } of partiallyVisibleBatches) {
|
|
1951
2159
|
// Get or create a cached sub-batch for this visibility state
|
|
1952
|
-
const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline);
|
|
2160
|
+
const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline, this._partialBatchEpoch);
|
|
1953
2161
|
if (subBatch) {
|
|
1954
2162
|
// Use opaque or transparent pipeline based on resolved alpha
|
|
1955
2163
|
// (not the parent batch's color[3] — that ignores transparencyOverrides).
|
|
@@ -2404,30 +2612,20 @@ export class Renderer {
|
|
|
2404
2612
|
// buffers is deferred past in-flight work by WebGPU.
|
|
2405
2613
|
this.scene.enforceGpuBudget();
|
|
2406
2614
|
// Pop validation error scope and capture the exact error
|
|
2407
|
-
if (
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
const msg = error.message || String(error);
|
|
2411
|
-
console.error('[WebGPU] Validation error in render pass:', msg);
|
|
2412
|
-
this.device._lastUncapturedError = `VALIDATION: ${msg}`;
|
|
2413
|
-
this.device._uncapturedErrorCount++;
|
|
2414
|
-
}
|
|
2415
|
-
}).catch((error) => {
|
|
2416
|
-
// popErrorScope() rejects (e.g. "Instance dropped in popErrorScope")
|
|
2417
|
-
// when the GPU device is lost while the scope is still pending. This
|
|
2418
|
-
// escapes the surrounding synchronous try/catch and would otherwise
|
|
2419
|
-
// surface as an unhandled rejection. Treat it like any other device
|
|
2420
|
-
// loss: invalidate the context so it reconfigures next frame.
|
|
2421
|
-
this.device.invalidateContext();
|
|
2422
|
-
const now = performance.now();
|
|
2423
|
-
if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
|
|
2424
|
-
this.lastRenderErrorTime = now;
|
|
2425
|
-
console.warn('[WebGPU] popErrorScope rejected (device likely lost):', error);
|
|
2426
|
-
}
|
|
2427
|
-
});
|
|
2615
|
+
if (errorScopePushed) {
|
|
2616
|
+
errorScopePushed = false;
|
|
2617
|
+
this.drainErrorScope(device);
|
|
2428
2618
|
}
|
|
2429
2619
|
}
|
|
2430
2620
|
catch (error) {
|
|
2621
|
+
// Balance the validation scope if we threw before popping it above —
|
|
2622
|
+
// an unpopped scope would capture every later frame's errors silently.
|
|
2623
|
+
// drainErrorScope logs a pop rejection (device loss) rather than
|
|
2624
|
+
// swallowing it.
|
|
2625
|
+
if (errorScopePushed) {
|
|
2626
|
+
errorScopePushed = false;
|
|
2627
|
+
this.drainErrorScope(device);
|
|
2628
|
+
}
|
|
2431
2629
|
this._renderErrorCount++;
|
|
2432
2630
|
this._lastRenderError = error instanceof Error ? error.message : String(error);
|
|
2433
2631
|
// Handle WebGPU errors (e.g., device lost, invalid state)
|
|
@@ -2943,6 +3141,13 @@ export class Renderer {
|
|
|
2943
3141
|
this.deviationBvhFingerprint = null;
|
|
2944
3142
|
// Snap detector geometry cache
|
|
2945
3143
|
this.raycastEngine.clearCaches();
|
|
3144
|
+
// Finally, release the GPU device itself. Every buffer/pipeline/texture
|
|
3145
|
+
// above was created from it and has already been destroyed, so nothing
|
|
3146
|
+
// will touch the device after this. Without it, an app that spins up a
|
|
3147
|
+
// renderer per model keeps N live devices (and their VRAM) alive. The
|
|
3148
|
+
// lost-handler special-cases reason 'destroyed' so this is not reported
|
|
3149
|
+
// as a fault. render() early-returns while the device is uninitialised.
|
|
3150
|
+
this.device.destroy();
|
|
2946
3151
|
}
|
|
2947
3152
|
/**
|
|
2948
3153
|
* Get the canvas element
|