topazcube 0.1.31 → 0.1.35

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.
Files changed (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. package/src/utils.ts +0 -403
@@ -0,0 +1,394 @@
1
+ import { loadGltfData, loadGltf } from "../gltf.js"
2
+ import { Geometry } from "../Geometry.js"
3
+ import { Material } from "../Material.js"
4
+ import { Mesh } from "../Mesh.js"
5
+ import { Texture } from "../Texture.js"
6
+ import { calculateBoundingSphere } from "../utils/BoundingSphere.js"
7
+
8
+ /**
9
+ * AssetManager - Lazy loading and caching of assets
10
+ *
11
+ * Asset keys:
12
+ * - "path/to/model.glb" - Raw GLTF data (gltf object, ready state)
13
+ * - "path/to/model.glb|meshName" - Processed mesh (geometry, material, skin, bsphere)
14
+ *
15
+ * Assets structure:
16
+ * {
17
+ * "models/fox.glb": { gltf: {...}, meshNames: [...], ready: true, loading: false }
18
+ * "models/fox.glb|fox1": { geometry, material, skin, bsphere, ready: true }
19
+ * }
20
+ */
21
+ class AssetManager {
22
+ constructor(engine = null) {
23
+ this.engine = engine
24
+
25
+ // Asset storage
26
+ this.assets = {}
27
+
28
+ // Loading promises for deduplication
29
+ this._loadingPromises = {}
30
+
31
+ // Callbacks for when assets become ready
32
+ this._readyCallbacks = {}
33
+ }
34
+
35
+ /**
36
+ * Parse a ModelID into path and mesh name
37
+ * @param {string} modelId - Format: "path/to/model.glb|meshName"
38
+ * @returns {{ path: string, meshName: string|null }}
39
+ */
40
+ parseModelId(modelId) {
41
+ const parts = modelId.split("|")
42
+ return {
43
+ path: parts[0],
44
+ meshName: parts[1] || null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Create a ModelID from path and mesh name
50
+ */
51
+ createModelId(path, meshName) {
52
+ return `${path}|${meshName}`
53
+ }
54
+
55
+ /**
56
+ * Check if an asset exists and is ready
57
+ */
58
+ isReady(assetKey) {
59
+ return this.assets[assetKey]?.ready === true
60
+ }
61
+
62
+ /**
63
+ * Check if an asset is currently loading
64
+ */
65
+ isLoading(assetKey) {
66
+ return this.assets[assetKey]?.loading === true || this._loadingPromises[assetKey] !== undefined
67
+ }
68
+
69
+ /**
70
+ * Get an asset if ready, otherwise return null
71
+ */
72
+ get(assetKey) {
73
+ const asset = this.assets[assetKey]
74
+ if (asset?.ready) {
75
+ return asset
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Get or load a GLTF file
82
+ * @param {string} path - Path to the GLTF file
83
+ * @param {Object} options - Loading options
84
+ * @returns {Promise<Object>} The loaded GLTF asset
85
+ */
86
+ async loadGltfFile(path, options = {}) {
87
+ // Check if already loaded
88
+ if (this.assets[path]?.ready) {
89
+ return this.assets[path]
90
+ }
91
+
92
+ // Check if already loading (deduplicate)
93
+ if (this._loadingPromises[path]) {
94
+ return this._loadingPromises[path]
95
+ }
96
+
97
+ // Mark as loading
98
+ this.assets[path] = { ready: false, loading: true }
99
+
100
+ // Create loading promise
101
+ this._loadingPromises[path] = (async () => {
102
+ try {
103
+ const result = await loadGltf(this.engine, path, options)
104
+
105
+ // Store the full result
106
+ const meshNames = Object.keys(result.meshes)
107
+
108
+ // For skinned models, compute a combined bounding sphere for ALL meshes
109
+ // This prevents individual submeshes from being culled independently
110
+ // Include all meshes (skinned and rigid parts) in the combined sphere
111
+ let combinedBsphere = null
112
+ const hasAnySkin = Object.values(result.meshes).some(m => m.hasSkin)
113
+
114
+ if (hasAnySkin) {
115
+ // Collect all vertex positions from ALL meshes (not just skinned ones)
116
+ // This ensures rigid parts attached to skinned models share the same bounds
117
+ const allPositions = []
118
+ for (const mesh of Object.values(result.meshes)) {
119
+ if (mesh.geometry?.attributes?.position) {
120
+ const positions = mesh.geometry.attributes.position
121
+ for (let i = 0; i < positions.length; i += 3) {
122
+ allPositions.push(positions[i], positions[i + 1], positions[i + 2])
123
+ }
124
+ }
125
+ }
126
+
127
+ if (allPositions.length > 0) {
128
+ combinedBsphere = calculateBoundingSphere(new Float32Array(allPositions))
129
+ }
130
+ }
131
+
132
+ // Store the parent GLTF asset with combined bsphere for entities using parent path
133
+ this.assets[path] = {
134
+ gltf: result,
135
+ meshes: result.meshes,
136
+ skins: result.skins,
137
+ animations: result.animations,
138
+ nodes: result.nodes,
139
+ meshNames: meshNames,
140
+ bsphere: combinedBsphere, // Combined bsphere for parent path entities
141
+ hasSkin: hasAnySkin, // Flag for skinned model detection
142
+ ready: true,
143
+ loading: false
144
+ }
145
+
146
+ // Auto-register individual meshes
147
+ for (const meshName of meshNames) {
148
+ const mesh = result.meshes[meshName]
149
+ // Use combined bsphere for ALL meshes when model has any skinning
150
+ // This ensures all submeshes are culled together as a unit
151
+ const bsphere = (hasAnySkin && combinedBsphere) ? combinedBsphere : null
152
+ await this._registerMesh(path, meshName, mesh, bsphere)
153
+ }
154
+
155
+ // Trigger ready callbacks
156
+ this._triggerReady(path)
157
+
158
+ return this.assets[path]
159
+ } catch (error) {
160
+ console.error(`Failed to load GLTF: ${path}`, error)
161
+ this.assets[path] = { ready: false, loading: false, error: error.message }
162
+ throw error
163
+ } finally {
164
+ delete this._loadingPromises[path]
165
+ }
166
+ })()
167
+
168
+ return this._loadingPromises[path]
169
+ }
170
+
171
+ /**
172
+ * Register a mesh asset (internal)
173
+ * @param {string} path - GLTF file path
174
+ * @param {string} meshName - Mesh name
175
+ * @param {Object} mesh - Mesh object
176
+ * @param {Object|null} overrideBsphere - Optional bounding sphere (for skinned mesh combined sphere)
177
+ */
178
+ async _registerMesh(path, meshName, mesh, overrideBsphere = null) {
179
+ const modelId = this.createModelId(path, meshName)
180
+
181
+ // Use override bsphere if provided, otherwise calculate from geometry
182
+ const bsphere = overrideBsphere || calculateBoundingSphere(mesh.geometry.attributes.position)
183
+
184
+ this.assets[modelId] = {
185
+ mesh: mesh,
186
+ geometry: mesh.geometry,
187
+ material: mesh.material,
188
+ skin: mesh.skin || null,
189
+ hasSkin: mesh.hasSkin || false,
190
+ bsphere: bsphere,
191
+ ready: true,
192
+ loading: false
193
+ }
194
+
195
+ // Trigger ready callbacks
196
+ this._triggerReady(modelId)
197
+ }
198
+
199
+ /**
200
+ * Get or load a specific mesh from a GLTF file
201
+ * @param {string} modelId - Format: "path/to/model.glb|meshName"
202
+ * @param {Object} options - Loading options
203
+ * @returns {Promise<Object>} The mesh asset
204
+ */
205
+ async loadMesh(modelId, options = {}) {
206
+ const { path, meshName } = this.parseModelId(modelId)
207
+
208
+ // If mesh is already loaded, return it
209
+ if (this.assets[modelId]?.ready) {
210
+ return this.assets[modelId]
211
+ }
212
+
213
+ // Load the GLTF file first (will auto-register meshes)
214
+ await this.loadGltfFile(path, options)
215
+
216
+ // Now get the mesh
217
+ const meshAsset = this.assets[modelId]
218
+ if (!meshAsset) {
219
+ throw new Error(`Mesh "${meshName}" not found in "${path}"`)
220
+ }
221
+
222
+ return meshAsset
223
+ }
224
+
225
+ /**
226
+ * Preload multiple assets
227
+ * @param {string[]} assetKeys - List of asset keys to preload
228
+ * @param {Object} options - Loading options
229
+ * @returns {Promise<void>}
230
+ */
231
+ async preload(assetKeys, options = {}) {
232
+ const promises = assetKeys.map(key => {
233
+ const { path, meshName } = this.parseModelId(key)
234
+ if (meshName) {
235
+ return this.loadMesh(key, options)
236
+ } else {
237
+ return this.loadGltfFile(key, options)
238
+ }
239
+ })
240
+
241
+ await Promise.all(promises)
242
+ }
243
+
244
+ /**
245
+ * Register a callback for when an asset becomes ready
246
+ */
247
+ onReady(assetKey, callback) {
248
+ // If already ready, call immediately
249
+ if (this.assets[assetKey]?.ready) {
250
+ callback(this.assets[assetKey])
251
+ return
252
+ }
253
+
254
+ // Register callback
255
+ if (!this._readyCallbacks[assetKey]) {
256
+ this._readyCallbacks[assetKey] = []
257
+ }
258
+ this._readyCallbacks[assetKey].push(callback)
259
+ }
260
+
261
+ /**
262
+ * Trigger ready callbacks
263
+ */
264
+ _triggerReady(assetKey) {
265
+ const callbacks = this._readyCallbacks[assetKey]
266
+ if (callbacks) {
267
+ for (const callback of callbacks) {
268
+ callback(this.assets[assetKey])
269
+ }
270
+ delete this._readyCallbacks[assetKey]
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Get all loaded mesh names for a GLTF file
276
+ */
277
+ getMeshNames(path) {
278
+ const asset = this.assets[path]
279
+ return asset?.meshNames || []
280
+ }
281
+
282
+ /**
283
+ * Get all unique GLTF paths currently loaded
284
+ */
285
+ getLoadedPaths() {
286
+ return Object.keys(this.assets).filter(key => !key.includes("|") && this.assets[key].ready)
287
+ }
288
+
289
+ /**
290
+ * Get all ModelIDs currently loaded
291
+ */
292
+ getLoadedModelIds() {
293
+ return Object.keys(this.assets).filter(key => key.includes("|") && this.assets[key].ready)
294
+ }
295
+
296
+ /**
297
+ * Get bounding sphere for a model
298
+ */
299
+ getBoundingSphere(modelId) {
300
+ const asset = this.assets[modelId]
301
+ return asset?.bsphere || null
302
+ }
303
+
304
+ /**
305
+ * Create a clone of a mesh (shares geometry/material, but separate instance buffers)
306
+ */
307
+ cloneMesh(modelId) {
308
+ const asset = this.assets[modelId]
309
+ if (!asset?.ready) {
310
+ return null
311
+ }
312
+
313
+ // Create a new Mesh with the same geometry and material
314
+ const clone = new Mesh(asset.geometry, asset.material)
315
+ if (asset.skin) {
316
+ clone.skin = asset.skin
317
+ clone.hasSkin = true
318
+ }
319
+ return clone
320
+ }
321
+
322
+ /**
323
+ * Unload an asset to free memory
324
+ */
325
+ unload(assetKey) {
326
+ const asset = this.assets[assetKey]
327
+ if (!asset) return
328
+
329
+ // If this is a GLTF file, also unload all its meshes
330
+ if (asset.meshNames) {
331
+ for (const meshName of asset.meshNames) {
332
+ const modelId = this.createModelId(assetKey, meshName)
333
+ delete this.assets[modelId]
334
+ }
335
+ }
336
+
337
+ delete this.assets[assetKey]
338
+ }
339
+
340
+ /**
341
+ * Clear all assets
342
+ */
343
+ clear() {
344
+ this.assets = {}
345
+ this._loadingPromises = {}
346
+ this._readyCallbacks = {}
347
+ }
348
+
349
+ /**
350
+ * Get loading status for all assets
351
+ */
352
+ getStatus() {
353
+ const ready = []
354
+ const loading = []
355
+ const failed = []
356
+
357
+ for (const key in this.assets) {
358
+ const asset = this.assets[key]
359
+ if (asset.ready) {
360
+ ready.push(key)
361
+ } else if (asset.loading) {
362
+ loading.push(key)
363
+ } else if (asset.error) {
364
+ failed.push({ key, error: asset.error })
365
+ }
366
+ }
367
+
368
+ return { ready, loading, failed }
369
+ }
370
+
371
+ /**
372
+ * Register a manually created mesh (for procedural geometry)
373
+ */
374
+ registerMesh(modelId, mesh, bsphere = null) {
375
+ if (!bsphere) {
376
+ bsphere = calculateBoundingSphere(mesh.geometry.attributes.position)
377
+ }
378
+
379
+ this.assets[modelId] = {
380
+ mesh: mesh,
381
+ geometry: mesh.geometry,
382
+ material: mesh.material,
383
+ skin: mesh.skin || null,
384
+ hasSkin: mesh.hasSkin || false,
385
+ bsphere: bsphere,
386
+ ready: true,
387
+ loading: false
388
+ }
389
+
390
+ this._triggerReady(modelId)
391
+ }
392
+ }
393
+
394
+ export { AssetManager }
@@ -0,0 +1,308 @@
1
+ import { Frustum } from "../utils/Frustum.js"
2
+ import { transformBoundingSphere } from "../utils/BoundingSphere.js"
3
+
4
+ /**
5
+ * CullingSystem - Manages visibility culling for entities
6
+ *
7
+ * Performs cone-based frustum culling, distance filtering,
8
+ * and HiZ occlusion culling with configurable limits per pass type.
9
+ */
10
+ class CullingSystem {
11
+ constructor(engine = null) {
12
+ // Reference to engine for settings access
13
+ this.engine = engine
14
+
15
+ // Frustum for culling
16
+ this.frustum = new Frustum()
17
+
18
+ // HiZ pass reference for occlusion culling
19
+ this.hizPass = null
20
+
21
+ // Current camera data for HiZ testing
22
+ this._viewProj = null
23
+ this._near = 0.05
24
+ this._far = 1000
25
+ this._cameraPos = null
26
+
27
+ // Stats for occlusion culling
28
+ this._occlusionStats = {
29
+ tested: 0,
30
+ culled: 0
31
+ }
32
+
33
+ // Cached visible entity lists per pass
34
+ this._visibleCache = {
35
+ shadow: null,
36
+ reflection: null,
37
+ planarReflection: null,
38
+ main: null
39
+ }
40
+
41
+ // Frame counter for cache invalidation
42
+ this._frameId = 0
43
+ this._cacheFrameId = -1
44
+ }
45
+
46
+ /**
47
+ * Set the HiZ pass for occlusion culling
48
+ * @param {HiZPass} hizPass - The HiZ pass instance
49
+ */
50
+ setHiZPass(hizPass) {
51
+ this.hizPass = hizPass
52
+ }
53
+
54
+ // Culling config is now a getter that reads from engine.settings.culling
55
+ get config() {
56
+ return this.engine.settings.culling
57
+ }
58
+
59
+ /**
60
+ * Update frustum from camera
61
+ * @param {Camera} camera - Camera object
62
+ * @param {number} screenWidth - Screen width in pixels
63
+ * @param {number} screenHeight - Screen height in pixels
64
+ */
65
+ updateFrustum(camera, screenWidth, screenHeight) {
66
+ // Camera uses: view, proj, fov (degrees), aspect, near, far, position, direction
67
+ const fovRadians = camera.fov * (Math.PI / 180)
68
+ this.frustum.update(
69
+ camera.view,
70
+ camera.proj,
71
+ camera.position,
72
+ camera.direction,
73
+ fovRadians,
74
+ camera.aspect,
75
+ camera.near,
76
+ camera.far,
77
+ screenWidth,
78
+ screenHeight
79
+ )
80
+
81
+ // Store camera data for HiZ testing
82
+ // Copy position to avoid issues with mutable references
83
+ this._viewProj = camera.viewProj
84
+ this._near = camera.near
85
+ this._far = camera.far
86
+ this._cameraPos = [camera.position[0], camera.position[1], camera.position[2]]
87
+
88
+ // Reset occlusion stats
89
+ this._occlusionStats.tested = 0
90
+ this._occlusionStats.culled = 0
91
+
92
+ this._frameId++
93
+ }
94
+
95
+ /**
96
+ * Set culling configuration for a pass type
97
+ * @param {string} passType - 'shadow', 'reflection', or 'main'
98
+ * @param {Object} config - { maxDistance, maxSkinned }
99
+ */
100
+ setConfig(passType, config) {
101
+ const cullingConfig = this.engine?.settings?.culling
102
+ if (cullingConfig && cullingConfig[passType]) {
103
+ Object.assign(cullingConfig[passType], config)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Cull entities for a specific pass type
109
+ *
110
+ * @param {EntityManager} entityManager - Entity manager
111
+ * @param {AssetManager} assetManager - Asset manager
112
+ * @param {string} passType - 'shadow', 'reflection', or 'main'
113
+ * @returns {{ visible: Array, skinnedCount: number }}
114
+ */
115
+ cull(entityManager, assetManager, passType = 'main') {
116
+ const config = this.config[passType] || this.config.main
117
+ const visible = []
118
+ let skinnedCount = 0
119
+ let skippedNoVisible = 0
120
+ let skippedNoModel = 0
121
+
122
+ entityManager.forEach((id, entity) => {
123
+ // Skip if not visible
124
+ if (!entity._visible) {
125
+ skippedNoVisible++
126
+ return
127
+ }
128
+
129
+ // Skip if no model
130
+ if (!entity.model) {
131
+ skippedNoModel++
132
+ return
133
+ }
134
+
135
+ // Get bounding sphere from asset and transform by entity matrix
136
+ const asset = assetManager.get(entity.model)
137
+ let bsphere
138
+
139
+ if (asset?.bsphere) {
140
+ // Transform asset's bsphere by entity's current matrix
141
+ // Note: For skinned models, bsphere is pre-computed as combined sphere of all submeshes
142
+ bsphere = transformBoundingSphere(asset.bsphere, entity._matrix)
143
+ // Cache it on entity for other uses
144
+ entity._bsphere = bsphere
145
+ } else if (entity._bsphere && entity._bsphere.radius > 0) {
146
+ // Use existing bsphere if available
147
+ bsphere = entity._bsphere
148
+ } else {
149
+ // No bsphere available, include by default
150
+ visible.push({ id, entity, distance: 0 })
151
+ return
152
+ }
153
+
154
+ // Check if culling is enabled
155
+ const globalCullingEnabled = this.engine?.settings?.culling?.frustumEnabled !== false
156
+ const passFrustumEnabled = config.frustum !== false
157
+
158
+ // For planar reflection, mirror the bounding sphere across the ground level
159
+ // This ensures we cull based on where the object appears in the reflection
160
+ let cullBsphere = bsphere
161
+ if (passType === 'planarReflection') {
162
+ const groundLevel = this.engine?.settings?.planarReflection?.groundLevel ?? 0
163
+ // Mirror Y position: mirroredY = 2 * groundLevel - originalY
164
+ cullBsphere = {
165
+ center: [
166
+ bsphere.center[0],
167
+ 2 * groundLevel - bsphere.center[1],
168
+ bsphere.center[2]
169
+ ],
170
+ radius: bsphere.radius
171
+ }
172
+ }
173
+
174
+ // Distance test (always apply when global culling is enabled)
175
+ const distance = this.frustum.getDistance(cullBsphere)
176
+ if (globalCullingEnabled && distance - cullBsphere.radius > config.maxDistance) {
177
+ return // Too far
178
+ }
179
+
180
+ // Pixel size test (always apply when global culling is enabled)
181
+ if (globalCullingEnabled && config.minPixelSize > 0) {
182
+ const projectedSize = this.frustum.getProjectedSize(cullBsphere, distance)
183
+ if (projectedSize < config.minPixelSize) {
184
+ return // Too small to see
185
+ }
186
+ }
187
+
188
+ // Frustum test (only when both global AND per-pass frustum culling is enabled)
189
+ if (globalCullingEnabled && passFrustumEnabled && !this.frustum.testSpherePlanes(cullBsphere)) {
190
+ return // Outside frustum
191
+ }
192
+
193
+ // HiZ occlusion culling for entities
194
+ if (passType === 'main' && this.hizPass && this._viewProj && this._cameraPos) {
195
+ const occlusionEnabled = this.engine?.settings?.occlusionCulling?.enabled
196
+ if (occlusionEnabled) {
197
+ this._occlusionStats.tested++
198
+ if (this.hizPass.testSphereOcclusion(bsphere, this._viewProj, this._near, this._far, this._cameraPos)) {
199
+ this._occlusionStats.culled++
200
+ return // Occluded by previous frame's geometry
201
+ }
202
+ }
203
+ }
204
+
205
+ // Check skinned limit (asset already fetched above)
206
+ const isSkinned = asset?.hasSkin === true
207
+
208
+ if (isSkinned) {
209
+ if (skinnedCount >= config.maxSkinned) {
210
+ return // Too many skinned already
211
+ }
212
+ skinnedCount++
213
+ }
214
+
215
+ visible.push({
216
+ id,
217
+ entity,
218
+ distance,
219
+ isSkinned
220
+ })
221
+ })
222
+
223
+ // Sort by distance for front-to-back rendering (reduces overdraw)
224
+ visible.sort((a, b) => a.distance - b.distance)
225
+
226
+ return { visible, skinnedCount }
227
+ }
228
+
229
+ /**
230
+ * Group visible entities by model for instancing
231
+ *
232
+ * @param {Array} visibleEntities - Array from cull()
233
+ * @returns {Map<string, Array>} Map of modelId -> entities
234
+ */
235
+ groupByModel(visibleEntities) {
236
+ const groups = new Map()
237
+
238
+ for (const item of visibleEntities) {
239
+ const modelId = item.entity.model
240
+ if (!groups.has(modelId)) {
241
+ groups.set(modelId, [])
242
+ }
243
+ groups.get(modelId).push(item)
244
+ }
245
+
246
+ return groups
247
+ }
248
+
249
+ /**
250
+ * Group visible entities by model and animation for skinned meshes
251
+ * Entities with same animation can potentially share animation state
252
+ *
253
+ * @param {Array} visibleEntities - Array from cull()
254
+ * @param {number} phaseQuantization - Quantize phase to this step (default 0.05 = 20 groups per animation)
255
+ * @returns {Map<string, Array>} Map of "modelId|animation|quantizedPhase" -> entities
256
+ */
257
+ groupByModelAndAnimation(visibleEntities, phaseQuantization = 0.05) {
258
+ const groups = new Map()
259
+
260
+ for (const item of visibleEntities) {
261
+ const entity = item.entity
262
+ let key = entity.model
263
+
264
+ if (item.isSkinned && entity.animation) {
265
+ const quantizedPhase = Math.floor(entity.phase / phaseQuantization) * phaseQuantization
266
+ key = `${entity.model}|${entity.animation}|${quantizedPhase.toFixed(2)}`
267
+ }
268
+
269
+ if (!groups.has(key)) {
270
+ groups.set(key, [])
271
+ }
272
+ groups.get(key).push(item)
273
+ }
274
+
275
+ return groups
276
+ }
277
+
278
+ /**
279
+ * Get statistics about culling
280
+ */
281
+ getStats(entityManager, assetManager) {
282
+ const total = entityManager.count
283
+ const { visible } = this.cull(entityManager, assetManager, 'main')
284
+ const culled = total - visible.length
285
+
286
+ return {
287
+ total,
288
+ visible: visible.length,
289
+ culled,
290
+ cullPercent: total > 0 ? ((culled / total) * 100).toFixed(1) : 0
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Get occlusion culling statistics
296
+ */
297
+ getOcclusionStats() {
298
+ return {
299
+ tested: this._occlusionStats.tested,
300
+ culled: this._occlusionStats.culled,
301
+ cullPercent: this._occlusionStats.tested > 0
302
+ ? ((this._occlusionStats.culled / this._occlusionStats.tested) * 100).toFixed(1)
303
+ : 0
304
+ }
305
+ }
306
+ }
307
+
308
+ export { CullingSystem }