@marble.fun/track-catalog 3.0.0
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/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- package/src/three/wild-west-sand-appearance.ts +2 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
3
|
+
|
|
4
|
+
import { STATIC_SHADOW_PROXY_LAYER } from '../../../three/scene-lighting'
|
|
5
|
+
import { StaticTrackBatchedMesh } from '../../../three/static-track-batched-mesh'
|
|
6
|
+
import { preserveGeometryAttributeAliases } from '../../../three/geometry-attribute-aliases'
|
|
7
|
+
import { batchTrackConnectorMarkers } from '../../../three/track-connector-batch'
|
|
8
|
+
|
|
9
|
+
const DYNAMIC_SHADOW_ROOT = 'trackDynamicShadowRoot'
|
|
10
|
+
const STATIC_COLOR_BATCH_SOURCE = 'trackStaticColorBatchSource'
|
|
11
|
+
|
|
12
|
+
interface StaticColorBatchSource {
|
|
13
|
+
geometry: THREE.BufferGeometry
|
|
14
|
+
material: THREE.Material
|
|
15
|
+
matrix: THREE.Matrix4
|
|
16
|
+
mesh: THREE.Mesh
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface StaticColorBatchBinding {
|
|
20
|
+
batch: THREE.BatchedMesh
|
|
21
|
+
instanceId: number
|
|
22
|
+
source: StaticColorBatchSource
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StaticTrackColorBatch {
|
|
26
|
+
allocatedGeometryBytes: number
|
|
27
|
+
connectorBatchCount: number
|
|
28
|
+
connectorSourceCount: number
|
|
29
|
+
updateConnectorVisibility(): void
|
|
30
|
+
batchCount: number
|
|
31
|
+
candidateCount: number
|
|
32
|
+
rejectionCounts: Readonly<Record<string, number>>
|
|
33
|
+
releasedSourceGeometryBytes: number
|
|
34
|
+
releasedSourceGeometryCount: number
|
|
35
|
+
sourceCount: number
|
|
36
|
+
detachSources(sourceRoots: readonly THREE.Object3D[]): number
|
|
37
|
+
releaseSourceGeometryCpuData(): { bytes: number; geometries: number }
|
|
38
|
+
updateTransforms(): void
|
|
39
|
+
dispose(): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ShadowGeometrySource {
|
|
43
|
+
mesh: THREE.Mesh
|
|
44
|
+
matrix: THREE.Matrix4
|
|
45
|
+
position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute
|
|
46
|
+
index?: THREE.BufferAttribute
|
|
47
|
+
start: number
|
|
48
|
+
count: number
|
|
49
|
+
side: THREE.Side
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Marks a piece subtree whose transforms change while the simulation runs. */
|
|
53
|
+
export function markDynamicTrackShadowRoot(root: THREE.Object3D): void {
|
|
54
|
+
root.userData[DYNAMIC_SHADOW_ROOT] = true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Keeps race shadow maps limited to static track and environment geometry. */
|
|
58
|
+
export function disableDynamicTrackShadowCasters(root: THREE.Object3D): void {
|
|
59
|
+
root.traverse(object => {
|
|
60
|
+
if (object instanceof THREE.Mesh && hasDynamicShadowAncestor(object, root)) object.castShadow = false
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Consolidates compatible opaque meshes from several immutable Track Lab
|
|
66
|
+
* pieces without flattening their culling bounds into one map-sized mesh.
|
|
67
|
+
* `BatchedMesh` retains per-object frustum culling while issuing one draw per
|
|
68
|
+
* compatible material/attribute group. The original meshes stay in the scene
|
|
69
|
+
* graph (hidden from rendering) so editor ray-picking, bounds, theme bindings,
|
|
70
|
+
* and piece-local disposal keep their existing ownership.
|
|
71
|
+
*
|
|
72
|
+
* Run this after static shadow proxies have been built: the color batches do
|
|
73
|
+
* not cast shadows because the exact three-side shadow streams already do so
|
|
74
|
+
* with fewer submissions.
|
|
75
|
+
*/
|
|
76
|
+
export function batchStaticTrackColorMeshes(
|
|
77
|
+
root: THREE.Group,
|
|
78
|
+
sourceRoots: readonly THREE.Object3D[],
|
|
79
|
+
options: { multiDrawSupported?: boolean; maxAdditionalGeometryBytes?: number } = {},
|
|
80
|
+
): StaticTrackColorBatch {
|
|
81
|
+
root.updateWorldMatrix(true, true)
|
|
82
|
+
const rootInverse = root.matrixWorld.clone().invert()
|
|
83
|
+
const updatedMatrix = new THREE.Matrix4()
|
|
84
|
+
const sources: StaticColorBatchSource[] = []
|
|
85
|
+
const rejectionCounts: Record<string, number> = {}
|
|
86
|
+
const geometryConsumers = collectGeometryConsumers(root)
|
|
87
|
+
const connectors = batchTrackConnectorMarkers(root, sourceRoots)
|
|
88
|
+
for (const sourceRoot of sourceRoots) {
|
|
89
|
+
sourceRoot.traverse(object => {
|
|
90
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
91
|
+
const rejection = staticColorBatchRejectionReason(object, sourceRoot)
|
|
92
|
+
if (rejection) {
|
|
93
|
+
rejectionCounts[rejection] = (rejectionCounts[rejection] ?? 0) + 1
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
sources.push({
|
|
97
|
+
geometry: object.geometry,
|
|
98
|
+
material: object.material,
|
|
99
|
+
matrix: rootInverse.clone().multiply(object.matrixWorld),
|
|
100
|
+
mesh: object,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const groups = new Map<string, StaticColorBatchSource[]>()
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
const key = [
|
|
108
|
+
source.material.uuid,
|
|
109
|
+
geometryAttributeSignature(source.geometry),
|
|
110
|
+
source.geometry.index ? 'indexed' : 'non-indexed',
|
|
111
|
+
source.mesh.receiveShadow ? 1 : 0,
|
|
112
|
+
source.mesh.layers.mask,
|
|
113
|
+
source.mesh.renderOrder,
|
|
114
|
+
].join('|')
|
|
115
|
+
const group = groups.get(key) ?? []
|
|
116
|
+
group.push(source)
|
|
117
|
+
groups.set(key, group)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const batches: THREE.BatchedMesh[] = []
|
|
121
|
+
const bindings: StaticColorBatchBinding[] = []
|
|
122
|
+
const hiddenSources = new Map<THREE.Mesh, boolean>()
|
|
123
|
+
const sourceRanges = new Map<THREE.BufferGeometry, { batch: THREE.BatchedMesh; geometryId: number }>()
|
|
124
|
+
let batchIndex = 0
|
|
125
|
+
let batchedSourceCount = 0
|
|
126
|
+
let allocatedGeometryBytes = 0
|
|
127
|
+
for (const group of groups.values()) {
|
|
128
|
+
// Without WEBGL_multi_draw, Three submits one ordinary draw per instance
|
|
129
|
+
// anyway, plus a DrawID uniform update and batch texture lookups. Keep the
|
|
130
|
+
// original geometry instead of paying for a course-sized duplicate buffer.
|
|
131
|
+
if (options.multiDrawSupported === false || group.length < 2) continue
|
|
132
|
+
const uniqueGeometries = [...new Set(group.map(source => source.geometry))]
|
|
133
|
+
const maxVertexCount = uniqueGeometries.reduce(
|
|
134
|
+
(sum, geometry) => sum + (geometry.getAttribute('position')?.count ?? 0),
|
|
135
|
+
0,
|
|
136
|
+
)
|
|
137
|
+
const maxIndexCount = uniqueGeometries.reduce((sum, geometry) => sum + (geometry.index?.count ?? 0), 0)
|
|
138
|
+
if (maxVertexCount === 0) continue
|
|
139
|
+
const first = group[0]!
|
|
140
|
+
const vertexStride = Object.values(first.geometry.attributes).reduce(
|
|
141
|
+
(sum, attribute) => sum + attribute.itemSize * attribute.array.BYTES_PER_ELEMENT,
|
|
142
|
+
0,
|
|
143
|
+
)
|
|
144
|
+
const allocationBytes = maxVertexCount * vertexStride + maxIndexCount * (maxVertexCount > 65535 ? 4 : 2)
|
|
145
|
+
// Include Three's initial duplicate channels and index widening in the
|
|
146
|
+
// staging budget, even when attribute aliases are restored afterward.
|
|
147
|
+
if (allocatedGeometryBytes + allocationBytes > (options.maxAdditionalGeometryBytes ?? Infinity)) continue
|
|
148
|
+
allocatedGeometryBytes += allocationBytes
|
|
149
|
+
const batch = new StaticTrackBatchedMesh(group.length, maxVertexCount, maxIndexCount, first.material)
|
|
150
|
+
batch.name = `track-static-color-batch-${batchIndex++}`
|
|
151
|
+
batch.castShadow = false
|
|
152
|
+
batch.receiveShadow = first.mesh.receiveShadow
|
|
153
|
+
batch.layers.mask = first.mesh.layers.mask
|
|
154
|
+
batch.renderOrder = first.mesh.renderOrder
|
|
155
|
+
batch.perObjectFrustumCulled = true
|
|
156
|
+
batch.sortObjects = true
|
|
157
|
+
batch.userData.trackStaticColorBatch = true
|
|
158
|
+
batch.userData.decorative = true
|
|
159
|
+
batch.userData.visualOnly = true
|
|
160
|
+
batch.userData.excludeFromEditorBounds = true
|
|
161
|
+
batch.userData.materialSlot = first.mesh.userData.materialSlot
|
|
162
|
+
const geometryIds = new Map<THREE.BufferGeometry, number>()
|
|
163
|
+
for (const source of group) {
|
|
164
|
+
let geometryId = geometryIds.get(source.geometry)
|
|
165
|
+
if (geometryId === undefined) {
|
|
166
|
+
geometryId = batch.addGeometry(source.geometry)
|
|
167
|
+
geometryIds.set(source.geometry, geometryId)
|
|
168
|
+
sourceRanges.set(source.geometry, { batch, geometryId })
|
|
169
|
+
}
|
|
170
|
+
const instanceId = batch.addInstance(geometryId)
|
|
171
|
+
batch.setMatrixAt(instanceId, source.matrix)
|
|
172
|
+
bindings.push({ batch, instanceId, source })
|
|
173
|
+
if (!hiddenSources.has(source.mesh)) hiddenSources.set(source.mesh, source.mesh.visible)
|
|
174
|
+
source.mesh.userData[STATIC_COLOR_BATCH_SOURCE] = true
|
|
175
|
+
source.mesh.visible = false
|
|
176
|
+
batchedSourceCount += 1
|
|
177
|
+
}
|
|
178
|
+
preserveGeometryAttributeAliases(batch.geometry, uniqueGeometries)
|
|
179
|
+
batch.computeBoundingBox()
|
|
180
|
+
batch.computeBoundingSphere()
|
|
181
|
+
root.add(batch)
|
|
182
|
+
batches.push(batch)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// BatchedMesh copies each source geometry into its own combined buffers.
|
|
186
|
+
// Keep hidden source meshes and exact CPU views for Lab picking, bounds,
|
|
187
|
+
// and later edits, but release redundant WebGL buffers once
|
|
188
|
+
// every renderer consumer of a geometry is represented by a color batch.
|
|
189
|
+
// BufferGeometry.dispose() does not delete its attributes; Three can upload
|
|
190
|
+
// them again if dispose() below restores the source meshes for a rebuild.
|
|
191
|
+
let releasedSourceGeometryBytes = 0
|
|
192
|
+
let releasedSourceGeometryCount = 0
|
|
193
|
+
const releasableSourceGeometries = new Set<THREE.BufferGeometry>()
|
|
194
|
+
for (const [geometry, consumers] of geometryConsumers) {
|
|
195
|
+
if (consumers.size === 0 || [...consumers].some(mesh => !hiddenSources.has(mesh))) continue
|
|
196
|
+
releasedSourceGeometryBytes += bufferGeometryByteLength(geometry)
|
|
197
|
+
releasedSourceGeometryCount += 1
|
|
198
|
+
releasableSourceGeometries.add(geometry)
|
|
199
|
+
geometry.dispose()
|
|
200
|
+
const range = sourceRanges.get(geometry)
|
|
201
|
+
if (range) shareStaticSourceAttributes(geometry, range.batch, range.geometryId)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
allocatedGeometryBytes,
|
|
206
|
+
connectorBatchCount: connectors.batchCount,
|
|
207
|
+
connectorSourceCount: connectors.sourceCount,
|
|
208
|
+
updateConnectorVisibility: connectors.updateTransforms,
|
|
209
|
+
batchCount: batches.length,
|
|
210
|
+
candidateCount: sources.length,
|
|
211
|
+
rejectionCounts,
|
|
212
|
+
releasedSourceGeometryBytes,
|
|
213
|
+
releasedSourceGeometryCount,
|
|
214
|
+
sourceCount: batchedSourceCount,
|
|
215
|
+
detachSources(sourceRoots) {
|
|
216
|
+
connectors.detachSources(sourceRoots)
|
|
217
|
+
const detachedMeshes = new Set<THREE.Mesh>()
|
|
218
|
+
for (const sourceRoot of sourceRoots) {
|
|
219
|
+
sourceRoot.traverse(object => {
|
|
220
|
+
if (object instanceof THREE.Mesh) detachedMeshes.add(object)
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
if (detachedMeshes.size === 0) return 0
|
|
224
|
+
|
|
225
|
+
let detachedCount = 0
|
|
226
|
+
let writeIndex = 0
|
|
227
|
+
for (const binding of bindings) {
|
|
228
|
+
const mesh = binding.source.mesh
|
|
229
|
+
if (!detachedMeshes.has(mesh)) {
|
|
230
|
+
bindings[writeIndex++] = binding
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
releasableSourceGeometries.delete(binding.source.geometry)
|
|
234
|
+
binding.batch.deleteInstance(binding.instanceId)
|
|
235
|
+
if (hiddenSources.has(mesh)) {
|
|
236
|
+
mesh.visible = hiddenSources.get(mesh)!
|
|
237
|
+
hiddenSources.delete(mesh)
|
|
238
|
+
}
|
|
239
|
+
delete mesh.userData[STATIC_COLOR_BATCH_SOURCE]
|
|
240
|
+
detachedCount += 1
|
|
241
|
+
}
|
|
242
|
+
bindings.length = writeIndex
|
|
243
|
+
return detachedCount
|
|
244
|
+
},
|
|
245
|
+
releaseSourceGeometryCpuData() {
|
|
246
|
+
let bytes = 0
|
|
247
|
+
let geometries = 0
|
|
248
|
+
for (const geometry of releasableSourceGeometries) {
|
|
249
|
+
const byteLength = bufferGeometryByteLength(geometry)
|
|
250
|
+
if (byteLength === 0) continue
|
|
251
|
+
// Attribute views already share the live batch allocation. Only the
|
|
252
|
+
// source-local index (or an unshared attribute) is released here.
|
|
253
|
+
const batchGeometry = sourceRanges.get(geometry)?.batch.geometry
|
|
254
|
+
const privateArrays = new Set<ArrayBufferLike>()
|
|
255
|
+
if (geometry.index) privateArrays.add(geometry.index.array.buffer)
|
|
256
|
+
for (const [name, attribute] of Object.entries(geometry.attributes)) {
|
|
257
|
+
if (attribute.array.buffer !== batchGeometry?.getAttribute(name)?.array.buffer) {
|
|
258
|
+
privateArrays.add(attribute.array.buffer)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
bytes += [...privateArrays].reduce((sum, array) => sum + array.byteLength, 0)
|
|
262
|
+
geometries += 1
|
|
263
|
+
geometry.setIndex(null)
|
|
264
|
+
for (const name of Object.keys(geometry.attributes)) geometry.deleteAttribute(name)
|
|
265
|
+
geometry.morphAttributes = {}
|
|
266
|
+
geometry.clearGroups()
|
|
267
|
+
geometry.boundingBox = null
|
|
268
|
+
geometry.boundingSphere = null
|
|
269
|
+
}
|
|
270
|
+
releasableSourceGeometries.clear()
|
|
271
|
+
return { bytes, geometries }
|
|
272
|
+
},
|
|
273
|
+
updateTransforms() {
|
|
274
|
+
connectors.updateTransforms()
|
|
275
|
+
root.updateWorldMatrix(true, true)
|
|
276
|
+
rootInverse.copy(root.matrixWorld).invert()
|
|
277
|
+
for (const { batch, instanceId, source } of bindings) {
|
|
278
|
+
updatedMatrix.copy(rootInverse).multiply(source.mesh.matrixWorld)
|
|
279
|
+
batch.setMatrixAt(instanceId, updatedMatrix)
|
|
280
|
+
}
|
|
281
|
+
for (const batch of batches) {
|
|
282
|
+
batch.computeBoundingBox()
|
|
283
|
+
batch.computeBoundingSphere()
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
dispose() {
|
|
287
|
+
connectors.dispose()
|
|
288
|
+
for (const [mesh, visible] of hiddenSources) {
|
|
289
|
+
mesh.visible = visible
|
|
290
|
+
delete mesh.userData[STATIC_COLOR_BATCH_SOURCE]
|
|
291
|
+
}
|
|
292
|
+
for (const batch of batches) {
|
|
293
|
+
batch.removeFromParent()
|
|
294
|
+
batch.dispose()
|
|
295
|
+
}
|
|
296
|
+
hiddenSources.clear()
|
|
297
|
+
releasableSourceGeometries.clear()
|
|
298
|
+
bindings.length = 0
|
|
299
|
+
batches.length = 0
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function collectGeometryConsumers(root: THREE.Object3D): Map<THREE.BufferGeometry, Set<THREE.Mesh>> {
|
|
305
|
+
const consumers = new Map<THREE.BufferGeometry, Set<THREE.Mesh>>()
|
|
306
|
+
root.traverse(object => {
|
|
307
|
+
if (!(object instanceof THREE.Mesh) || object instanceof THREE.BatchedMesh) return
|
|
308
|
+
const geometryConsumers = consumers.get(object.geometry) ?? new Set<THREE.Mesh>()
|
|
309
|
+
geometryConsumers.add(object)
|
|
310
|
+
consumers.set(object.geometry, geometryConsumers)
|
|
311
|
+
})
|
|
312
|
+
return consumers
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function bufferGeometryByteLength(geometry: THREE.BufferGeometry): number {
|
|
316
|
+
const arrays = new Set<ArrayBufferLike>()
|
|
317
|
+
const collectAttribute = (attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute): void => {
|
|
318
|
+
arrays.add(
|
|
319
|
+
attribute instanceof THREE.InterleavedBufferAttribute ? attribute.data.array.buffer : attribute.array.buffer,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
if (geometry.index) collectAttribute(geometry.index)
|
|
323
|
+
for (const attribute of Object.values(geometry.attributes)) collectAttribute(attribute)
|
|
324
|
+
for (const attributes of Object.values(geometry.morphAttributes)) {
|
|
325
|
+
for (const attribute of attributes) collectAttribute(attribute)
|
|
326
|
+
}
|
|
327
|
+
let byteLength = 0
|
|
328
|
+
for (const array of arrays) byteLength += array.byteLength
|
|
329
|
+
return byteLength
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Editor picking deliberately keeps hidden batch sources raycastable. */
|
|
333
|
+
export function isStaticTrackColorBatchSource(object: THREE.Object3D): boolean {
|
|
334
|
+
return object.userData[STATIC_COLOR_BATCH_SOURCE] === true
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Combines nearby opaque, static track meshes into exact shadow-only
|
|
339
|
+
* triangle streams (front/back/double sided). Visible meshes and materials are
|
|
340
|
+
* untouched; only their redundant per-object shadow submissions are replaced.
|
|
341
|
+
*/
|
|
342
|
+
export function batchStaticTrackShadowCasters(root: THREE.Group): number {
|
|
343
|
+
const sources = collectStaticShadowSources(root)
|
|
344
|
+
buildStaticShadowProxies(root, sources)
|
|
345
|
+
return sources.length
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Builds the same exact shadow proxies as `batchStaticTrackShadowCasters`, but
|
|
350
|
+
* yields between large vertex streams. Lab Test Run uses this path so a large
|
|
351
|
+
* course cannot monopolise the browser main thread while its runtime copy is
|
|
352
|
+
* prepared.
|
|
353
|
+
*/
|
|
354
|
+
export async function batchStaticTrackShadowCastersAsync(root: THREE.Group, sliceMilliseconds = 4): Promise<number> {
|
|
355
|
+
const sources = collectStaticShadowSources(root)
|
|
356
|
+
await buildStaticShadowProxiesAsync(root, sources, sliceMilliseconds)
|
|
357
|
+
return sources.length
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function collectStaticShadowSources(root: THREE.Group): ShadowGeometrySource[] {
|
|
361
|
+
root.updateWorldMatrix(true, true)
|
|
362
|
+
const rootInverse = root.matrixWorld.clone().invert()
|
|
363
|
+
const sources: ShadowGeometrySource[] = []
|
|
364
|
+
root.traverse(object => {
|
|
365
|
+
if (
|
|
366
|
+
!(object instanceof THREE.Mesh) ||
|
|
367
|
+
object instanceof THREE.InstancedMesh ||
|
|
368
|
+
object instanceof THREE.SkinnedMesh ||
|
|
369
|
+
object.userData.trackStaticShadowProxy
|
|
370
|
+
) {
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
if (!object.castShadow || !visibleThroughRoot(object, root) || hasDynamicShadowAncestor(object, root)) return
|
|
374
|
+
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
|
375
|
+
if (materials.length === 0 || materials.some(material => !canBatchShadowMaterial(material))) return
|
|
376
|
+
const sides = new Set(materials.map(material => material.shadowSide ?? material.side))
|
|
377
|
+
if (sides.size !== 1) return
|
|
378
|
+
const position = object.geometry.getAttribute('position')
|
|
379
|
+
if (!position || position.itemSize < 3 || object.geometry.morphAttributes.position?.length) return
|
|
380
|
+
const index = object.geometry.index ?? undefined
|
|
381
|
+
const availableCount = index?.count ?? position.count
|
|
382
|
+
const start = Math.max(0, Math.floor(object.geometry.drawRange.start))
|
|
383
|
+
const requestedCount = object.geometry.drawRange.count
|
|
384
|
+
const count = Math.max(
|
|
385
|
+
0,
|
|
386
|
+
Math.min(availableCount - start, Number.isFinite(requestedCount) ? Math.floor(requestedCount) : availableCount),
|
|
387
|
+
)
|
|
388
|
+
const triangleCount = count - (count % 3)
|
|
389
|
+
if (triangleCount === 0) return
|
|
390
|
+
sources.push({
|
|
391
|
+
mesh: object,
|
|
392
|
+
matrix: rootInverse.clone().multiply(object.matrixWorld),
|
|
393
|
+
position,
|
|
394
|
+
...(index ? { index } : {}),
|
|
395
|
+
start,
|
|
396
|
+
count: triangleCount,
|
|
397
|
+
side: [...sides][0]!,
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
return sources
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function spatialShadowGroups(sources: readonly ShadowGeometrySource[]): ShadowGeometrySource[][] {
|
|
404
|
+
// A course-wide merged bound defeats the shadow camera's frustum culling:
|
|
405
|
+
// touching one end submits every triangle. Keep nearby sources together,
|
|
406
|
+
// including vertically stacked tracks, without clipping or duplicating them.
|
|
407
|
+
// Bounds still enclose the entire source, including pieces crossing cells.
|
|
408
|
+
const cellSize = 32
|
|
409
|
+
const groups = new Map<string, ShadowGeometrySource[]>()
|
|
410
|
+
const bounds = new THREE.Box3()
|
|
411
|
+
const center = new THREE.Vector3()
|
|
412
|
+
for (const source of sources) {
|
|
413
|
+
if (!source.mesh.geometry.boundingBox) source.mesh.geometry.computeBoundingBox()
|
|
414
|
+
bounds.copy(source.mesh.geometry.boundingBox!).applyMatrix4(source.matrix).getCenter(center)
|
|
415
|
+
const key = `${source.side}:${source.mesh.layers.mask}:${Math.round(center.x / cellSize)}:${Math.round(center.y / cellSize)}:${Math.round(center.z / cellSize)}`
|
|
416
|
+
const group = groups.get(key) ?? []
|
|
417
|
+
group.push(source)
|
|
418
|
+
groups.set(key, group)
|
|
419
|
+
}
|
|
420
|
+
return [...groups.values()]
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function buildStaticShadowProxies(root: THREE.Group, sources: readonly ShadowGeometrySource[]): void {
|
|
424
|
+
for (const sideSources of spatialShadowGroups(sources)) {
|
|
425
|
+
const { positions, indices } = createShadowProxyArrays(sideSources)
|
|
426
|
+
addStaticShadowProxy(root, sideSources[0]!.side, positions, indices, sideSources[0]!.mesh.layers.mask)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function buildStaticShadowProxiesAsync(
|
|
431
|
+
root: THREE.Group,
|
|
432
|
+
sources: readonly ShadowGeometrySource[],
|
|
433
|
+
sliceMilliseconds: number,
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
let sliceStartedAt = performance.now()
|
|
436
|
+
const yieldIfNeeded = async (): Promise<void> => {
|
|
437
|
+
if (performance.now() - sliceStartedAt < sliceMilliseconds) return
|
|
438
|
+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
|
439
|
+
sliceStartedAt = performance.now()
|
|
440
|
+
}
|
|
441
|
+
for (const sideSources of spatialShadowGroups(sources)) {
|
|
442
|
+
const vertexCount = sideSources.reduce((sum, source) => sum + source.position.count, 0)
|
|
443
|
+
const positions = new Float32Array(vertexCount * 3)
|
|
444
|
+
const indices = new Uint32Array(sideSources.reduce((sum, source) => sum + source.count, 0))
|
|
445
|
+
const vertex = new THREE.Vector3()
|
|
446
|
+
let vertexOffset = 0
|
|
447
|
+
let indexOffset = 0
|
|
448
|
+
for (const source of sideSources) {
|
|
449
|
+
for (let sourceVertex = 0; sourceVertex < source.position.count; sourceVertex += 1) {
|
|
450
|
+
vertex.fromBufferAttribute(source.position, sourceVertex).applyMatrix4(source.matrix)
|
|
451
|
+
const writeOffset = (vertexOffset + sourceVertex) * 3
|
|
452
|
+
positions[writeOffset] = vertex.x
|
|
453
|
+
positions[writeOffset + 1] = vertex.y
|
|
454
|
+
positions[writeOffset + 2] = vertex.z
|
|
455
|
+
if ((sourceVertex & 1_023) === 1_023) await yieldIfNeeded()
|
|
456
|
+
}
|
|
457
|
+
for (let offset = source.start; offset < source.start + source.count; offset += 1) {
|
|
458
|
+
indices[indexOffset++] = vertexOffset + (source.index?.getX(offset) ?? offset)
|
|
459
|
+
}
|
|
460
|
+
vertexOffset += source.position.count
|
|
461
|
+
source.mesh.castShadow = false
|
|
462
|
+
await yieldIfNeeded()
|
|
463
|
+
}
|
|
464
|
+
addStaticShadowProxy(root, sideSources[0]!.side, positions, indices, sideSources[0]!.mesh.layers.mask)
|
|
465
|
+
await yieldIfNeeded()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function createShadowProxyArrays(sideSources: readonly ShadowGeometrySource[]): {
|
|
470
|
+
positions: Float32Array
|
|
471
|
+
indices: Uint32Array
|
|
472
|
+
} {
|
|
473
|
+
const vertexCount = sideSources.reduce((sum, source) => sum + source.position.count, 0)
|
|
474
|
+
const positions = new Float32Array(vertexCount * 3)
|
|
475
|
+
const indices = new Uint32Array(sideSources.reduce((sum, source) => sum + source.count, 0))
|
|
476
|
+
const vertex = new THREE.Vector3()
|
|
477
|
+
let vertexOffset = 0
|
|
478
|
+
let indexOffset = 0
|
|
479
|
+
for (const source of sideSources) {
|
|
480
|
+
for (let sourceVertex = 0; sourceVertex < source.position.count; sourceVertex += 1) {
|
|
481
|
+
vertex.fromBufferAttribute(source.position, sourceVertex).applyMatrix4(source.matrix)
|
|
482
|
+
const writeOffset = (vertexOffset + sourceVertex) * 3
|
|
483
|
+
positions[writeOffset] = vertex.x
|
|
484
|
+
positions[writeOffset + 1] = vertex.y
|
|
485
|
+
positions[writeOffset + 2] = vertex.z
|
|
486
|
+
}
|
|
487
|
+
for (let offset = source.start; offset < source.start + source.count; offset += 1) {
|
|
488
|
+
indices[indexOffset++] = vertexOffset + (source.index?.getX(offset) ?? offset)
|
|
489
|
+
}
|
|
490
|
+
vertexOffset += source.position.count
|
|
491
|
+
source.mesh.castShadow = false
|
|
492
|
+
}
|
|
493
|
+
return { positions, indices }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function addStaticShadowProxy(
|
|
497
|
+
root: THREE.Group,
|
|
498
|
+
side: THREE.Side,
|
|
499
|
+
positions: Float32Array,
|
|
500
|
+
indices: Uint32Array,
|
|
501
|
+
layersMask: number,
|
|
502
|
+
): void {
|
|
503
|
+
const geometry = new THREE.BufferGeometry()
|
|
504
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
|
505
|
+
geometry.setIndex(new THREE.BufferAttribute(indices, 1))
|
|
506
|
+
geometry.computeBoundingBox()
|
|
507
|
+
geometry.computeBoundingSphere()
|
|
508
|
+
// WebGLShadowMap filters casters against the viewing camera's layers, not
|
|
509
|
+
// the shadow camera's layers. Keep proxies on the source layer, but make
|
|
510
|
+
// their ordinary pass write neither color nor depth. Three's shadow pass
|
|
511
|
+
// uses its own depth material, so the exact caster remains visible there.
|
|
512
|
+
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side, colorWrite: false, depthWrite: false })
|
|
513
|
+
material.name = 'track-static-shadow-proxy-material'
|
|
514
|
+
material.userData.ownsMaterial = true
|
|
515
|
+
const proxy = new THREE.Mesh(geometry, material)
|
|
516
|
+
proxy.name = `track-static-shadow-proxy-${side}`
|
|
517
|
+
proxy.layers.mask = layersMask
|
|
518
|
+
proxy.layers.enable(STATIC_SHADOW_PROXY_LAYER)
|
|
519
|
+
proxy.castShadow = true
|
|
520
|
+
proxy.receiveShadow = false
|
|
521
|
+
proxy.userData.trackStaticShadowProxy = true
|
|
522
|
+
proxy.userData.excludeFromEditorBounds = true
|
|
523
|
+
proxy.userData.decorative = true
|
|
524
|
+
root.add(proxy)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function canBatchShadowMaterial(material: THREE.Material): boolean {
|
|
528
|
+
if (!material.visible || material.transparent || material.opacity < 1 || material.alphaTest > 0) return false
|
|
529
|
+
if ('alphaMap' in material && material.alphaMap) return false
|
|
530
|
+
if (material instanceof THREE.MeshStandardMaterial && material.displacementMap && material.displacementScale !== 0) {
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Keep exact source-local data for editor picking without retaining a second
|
|
538
|
+
* course-sized set of CPU arrays. Batch positions are copied before instance
|
|
539
|
+
* transforms, so views into their ranges have the original coordinates.
|
|
540
|
+
* Indices stay source-local: batch indices include a vertex offset.
|
|
541
|
+
*/
|
|
542
|
+
function shareStaticSourceAttributes(source: THREE.BufferGeometry, batch: THREE.BatchedMesh, geometryId: number): void {
|
|
543
|
+
const range = batch.getGeometryRangeAt(geometryId)
|
|
544
|
+
if (!range) return
|
|
545
|
+
const aliases = new Map<THREE.BufferAttribute, THREE.BufferAttribute>()
|
|
546
|
+
for (const [name, attribute] of Object.entries(source.attributes)) {
|
|
547
|
+
const target = batch.geometry.getAttribute(name)
|
|
548
|
+
if (!(attribute instanceof THREE.BufferAttribute) || !(target instanceof THREE.BufferAttribute)) continue
|
|
549
|
+
let shared = aliases.get(attribute)
|
|
550
|
+
if (!shared) {
|
|
551
|
+
const start = range.vertexStart * attribute.itemSize
|
|
552
|
+
shared = new THREE.BufferAttribute(
|
|
553
|
+
target.array.subarray(start, start + attribute.count * attribute.itemSize),
|
|
554
|
+
attribute.itemSize,
|
|
555
|
+
attribute.normalized,
|
|
556
|
+
)
|
|
557
|
+
shared.name = attribute.name
|
|
558
|
+
shared.setUsage(attribute.usage)
|
|
559
|
+
shared.gpuType = attribute.gpuType
|
|
560
|
+
aliases.set(attribute, shared)
|
|
561
|
+
}
|
|
562
|
+
source.setAttribute(name, shared)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function staticColorBatchRejectionReason(object: THREE.Mesh, sourceRoot: THREE.Object3D): string | undefined {
|
|
567
|
+
if (
|
|
568
|
+
object instanceof THREE.BatchedMesh ||
|
|
569
|
+
object instanceof THREE.InstancedMesh ||
|
|
570
|
+
object instanceof THREE.SkinnedMesh
|
|
571
|
+
) {
|
|
572
|
+
return 'special-mesh'
|
|
573
|
+
}
|
|
574
|
+
if (object.userData.trackStaticColorBatch || object.userData.trackStaticShadowProxy) return 'existing-batch'
|
|
575
|
+
if (object.userData.isTrackConnectorMarker) return 'connector'
|
|
576
|
+
if (Array.isArray(object.material)) return 'material-array'
|
|
577
|
+
if (object.geometry.morphAttributes.position?.length) return 'morph'
|
|
578
|
+
if (hasDynamicShadowAncestor(object, sourceRoot)) return 'dynamic'
|
|
579
|
+
if (!visibleThroughRoot(object, sourceRoot)) return 'hidden'
|
|
580
|
+
const material = object.material
|
|
581
|
+
const baseTransparent = material.userData.themeBaseTransparent
|
|
582
|
+
const baseOpacity = material.userData.themeBaseOpacity
|
|
583
|
+
const inherentlyTransparent = typeof baseTransparent === 'boolean' ? baseTransparent : material.transparent
|
|
584
|
+
const inherentOpacity = typeof baseOpacity === 'number' ? baseOpacity : material.opacity
|
|
585
|
+
if (!material.visible || inherentlyTransparent || inherentOpacity < 1 || material.alphaTest > 0) return 'transparent'
|
|
586
|
+
const position = object.geometry.getAttribute('position')
|
|
587
|
+
if (!position || position.itemSize < 3) return 'position'
|
|
588
|
+
const availableCount = object.geometry.index?.count ?? position.count
|
|
589
|
+
const requestedCount = object.geometry.drawRange.count
|
|
590
|
+
const drawCount = Number.isFinite(requestedCount) ? Math.floor(requestedCount) : availableCount
|
|
591
|
+
return object.geometry.drawRange.start === 0 && drawCount >= availableCount ? undefined : 'draw-range'
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function visibleThroughRoot(object: THREE.Object3D, root: THREE.Object3D): boolean {
|
|
595
|
+
for (let current: THREE.Object3D | null = object; current; current = current.parent) {
|
|
596
|
+
if (!current.visible) return false
|
|
597
|
+
if (current === root) return true
|
|
598
|
+
}
|
|
599
|
+
return false
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function hasDynamicShadowAncestor(object: THREE.Object3D, root: THREE.Object3D): boolean {
|
|
603
|
+
for (let current: THREE.Object3D | null = object; current && current !== root; current = current.parent) {
|
|
604
|
+
if (current.userData[DYNAMIC_SHADOW_ROOT]) return true
|
|
605
|
+
}
|
|
606
|
+
return false
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
interface OverviewDecorationSource {
|
|
610
|
+
geometry: THREE.BufferGeometry
|
|
611
|
+
material: THREE.Material
|
|
612
|
+
matrix: THREE.Matrix4
|
|
613
|
+
mesh: THREE.Mesh
|
|
614
|
+
themeHidden?: readonly string[]
|
|
615
|
+
themeOnly?: readonly string[]
|
|
616
|
+
visibilitySignature: string
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Merges static decorative color meshes inside one editable Track Lab piece.
|
|
621
|
+
* Geometry is grouped by material, attributes, render state, and inherited
|
|
622
|
+
* theme visibility, so a later in-place theme change cannot reveal geometry
|
|
623
|
+
* from another visual family. Instanced and dynamic details stay independent.
|
|
624
|
+
*/
|
|
625
|
+
export function batchStaticOverviewDecorations(root: THREE.Group): number {
|
|
626
|
+
root.updateWorldMatrix(true, true)
|
|
627
|
+
const rootInverse = root.matrixWorld.clone().invert()
|
|
628
|
+
const sources: OverviewDecorationSource[] = []
|
|
629
|
+
root.traverse(object => {
|
|
630
|
+
if (
|
|
631
|
+
!(object instanceof THREE.Mesh) ||
|
|
632
|
+
object instanceof THREE.InstancedMesh ||
|
|
633
|
+
object instanceof THREE.SkinnedMesh ||
|
|
634
|
+
object.userData.trackStaticOverviewBatch ||
|
|
635
|
+
object.userData.trackStaticShadowProxy ||
|
|
636
|
+
Array.isArray(object.material) ||
|
|
637
|
+
object.renderOrder !== 0 ||
|
|
638
|
+
object.geometry.morphAttributes.position?.length
|
|
639
|
+
) {
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
const material = object.material
|
|
643
|
+
if (!material.visible || material.transparent || material.opacity < 1 || material.alphaTest > 0) return
|
|
644
|
+
const visibility = overviewDecorationVisibility(object, root)
|
|
645
|
+
if (!visibility) return
|
|
646
|
+
sources.push({
|
|
647
|
+
geometry: object.geometry,
|
|
648
|
+
material,
|
|
649
|
+
matrix: rootInverse.clone().multiply(object.matrixWorld),
|
|
650
|
+
mesh: object,
|
|
651
|
+
...visibility,
|
|
652
|
+
})
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
const groups = new Map<string, OverviewDecorationSource[]>()
|
|
656
|
+
for (const source of sources) {
|
|
657
|
+
const key = [
|
|
658
|
+
source.material.uuid,
|
|
659
|
+
source.visibilitySignature,
|
|
660
|
+
source.mesh.castShadow ? 1 : 0,
|
|
661
|
+
source.mesh.receiveShadow ? 1 : 0,
|
|
662
|
+
geometryAttributeSignature(source.geometry),
|
|
663
|
+
].join('|')
|
|
664
|
+
const group = groups.get(key) ?? []
|
|
665
|
+
group.push(source)
|
|
666
|
+
groups.set(key, group)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let mergedSourceCount = 0
|
|
670
|
+
let batchIndex = 0
|
|
671
|
+
for (const group of groups.values()) {
|
|
672
|
+
if (group.length < 2) continue
|
|
673
|
+
const transformed = group.map(source => {
|
|
674
|
+
const geometry = source.geometry.clone()
|
|
675
|
+
// Primitive geometries such as BoxGeometry retain face groups even
|
|
676
|
+
// when the mesh owns one material. The groups are redundant in that
|
|
677
|
+
// case and would otherwise prevent safe cross-object merging.
|
|
678
|
+
geometry.clearGroups()
|
|
679
|
+
return geometry.applyMatrix4(source.matrix)
|
|
680
|
+
})
|
|
681
|
+
const geometry = mergeGeometries(transformed, false)
|
|
682
|
+
transformed.forEach(candidate => candidate.dispose())
|
|
683
|
+
if (!geometry) continue
|
|
684
|
+
preserveGeometryAttributeAliases(
|
|
685
|
+
geometry,
|
|
686
|
+
group.map(source => source.geometry),
|
|
687
|
+
)
|
|
688
|
+
geometry.computeBoundingBox()
|
|
689
|
+
geometry.computeBoundingSphere()
|
|
690
|
+
const first = group[0]!
|
|
691
|
+
const batch = new THREE.Mesh(geometry, first.material)
|
|
692
|
+
batch.name = `${root.name}-overview-decoration-batch-${batchIndex++}`
|
|
693
|
+
batch.castShadow = first.mesh.castShadow
|
|
694
|
+
batch.receiveShadow = first.mesh.receiveShadow
|
|
695
|
+
batch.userData.trackStaticOverviewBatch = true
|
|
696
|
+
batch.userData.decorative = true
|
|
697
|
+
batch.userData.visualOnly = true
|
|
698
|
+
batch.userData.materialSlot = first.mesh.userData.materialSlot
|
|
699
|
+
if (first.themeOnly) {
|
|
700
|
+
batch.userData.themeOnly = first.themeOnly.length === 1 ? first.themeOnly[0] : [...first.themeOnly]
|
|
701
|
+
}
|
|
702
|
+
if (first.themeHidden) {
|
|
703
|
+
batch.userData.themeHidden = first.themeHidden.length === 1 ? first.themeHidden[0] : [...first.themeHidden]
|
|
704
|
+
}
|
|
705
|
+
root.add(batch)
|
|
706
|
+
for (const source of group) {
|
|
707
|
+
source.mesh.removeFromParent()
|
|
708
|
+
if (!source.mesh.userData.sharedGeometry) source.geometry.dispose()
|
|
709
|
+
}
|
|
710
|
+
mergedSourceCount += group.length
|
|
711
|
+
}
|
|
712
|
+
return mergedSourceCount
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function overviewDecorationVisibility(
|
|
716
|
+
object: THREE.Object3D,
|
|
717
|
+
root: THREE.Object3D,
|
|
718
|
+
): { themeHidden?: readonly string[]; themeOnly?: readonly string[]; visibilitySignature: string } | undefined {
|
|
719
|
+
let decorative = false
|
|
720
|
+
let only: Set<string> | undefined
|
|
721
|
+
const hidden = new Set<string>()
|
|
722
|
+
for (let current: THREE.Object3D | null = object; current; current = current.parent) {
|
|
723
|
+
decorative ||= current.userData.decorative === true
|
|
724
|
+
if (current.userData[DYNAMIC_SHADOW_ROOT] || current.userData.isPortalViewSurface) return undefined
|
|
725
|
+
const currentOnly = themeVisibilityIds(current.userData.themeOnly)
|
|
726
|
+
const currentHidden = themeVisibilityIds(current.userData.themeHidden)
|
|
727
|
+
if (!current.visible && currentOnly.length === 0 && currentHidden.length === 0) return undefined
|
|
728
|
+
if (currentOnly.length > 0) {
|
|
729
|
+
const values = new Set(currentOnly)
|
|
730
|
+
only = only ? new Set([...only].filter(themeId => values.has(themeId))) : values
|
|
731
|
+
}
|
|
732
|
+
currentHidden.forEach(themeId => hidden.add(themeId))
|
|
733
|
+
if (current === root) break
|
|
734
|
+
}
|
|
735
|
+
if (!decorative) return undefined
|
|
736
|
+
if (only) hidden.forEach(themeId => only!.delete(themeId))
|
|
737
|
+
if (only?.size === 0) return undefined
|
|
738
|
+
const themeOnly = only ? [...only].sort() : undefined
|
|
739
|
+
const themeHidden = hidden.size > 0 ? [...hidden].sort() : undefined
|
|
740
|
+
return {
|
|
741
|
+
...(themeOnly ? { themeOnly } : {}),
|
|
742
|
+
...(themeHidden ? { themeHidden } : {}),
|
|
743
|
+
visibilitySignature: `only=${themeOnly?.join(',') ?? '*'};hidden=${themeHidden?.join(',') ?? ''}`,
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function themeVisibilityIds(value: unknown): string[] {
|
|
748
|
+
if (typeof value === 'string') return [value]
|
|
749
|
+
if (Array.isArray(value)) return value.filter((themeId): themeId is string => typeof themeId === 'string')
|
|
750
|
+
return []
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function geometryAttributeSignature(geometry: THREE.BufferGeometry): string {
|
|
754
|
+
const attributes = Object.entries(geometry.attributes)
|
|
755
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
756
|
+
.map(([name, attribute]) =>
|
|
757
|
+
[name, attribute.itemSize, attribute.normalized ? 1 : 0, attribute.array.constructor.name].join(':'),
|
|
758
|
+
)
|
|
759
|
+
return `${geometry.index ? 'indexed' : 'plain'}:${attributes.join(',')}`
|
|
760
|
+
}
|