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,2258 @@
1
+ import { ShadowPass } from "./passes/ShadowPass.js"
2
+ import { ReflectionPass } from "./passes/ReflectionPass.js"
3
+ import { PlanarReflectionPass } from "./passes/PlanarReflectionPass.js"
4
+ import { GBufferPass } from "./passes/GBufferPass.js"
5
+ import { HiZPass } from "./passes/HiZPass.js"
6
+ import { AOPass } from "./passes/AOPass.js"
7
+ import { LightingPass } from "./passes/LightingPass.js"
8
+ import { SSGITilePass } from "./passes/SSGITilePass.js"
9
+ import { SSGIPass } from "./passes/SSGIPass.js"
10
+ import { RenderPostPass } from "./passes/RenderPostPass.js"
11
+ import { BloomPass } from "./passes/BloomPass.js"
12
+ import { TransparentPass } from "./passes/TransparentPass.js"
13
+ import { ParticlePass } from "./passes/ParticlePass.js"
14
+ import { FogPass } from "./passes/FogPass.js"
15
+ import { VolumetricFogPass } from "./passes/VolumetricFogPass.js"
16
+ import { PostProcessPass } from "./passes/PostProcessPass.js"
17
+ import { CRTPass } from "./passes/CRTPass.js"
18
+ import { AmbientCapturePass } from "./passes/AmbientCapturePass.js"
19
+ import { HistoryBufferManager } from "./HistoryBufferManager.js"
20
+ import { CullingSystem } from "../core/CullingSystem.js"
21
+ import { InstanceManager } from "../core/InstanceManager.js"
22
+ import { SpriteSystem } from "../core/SpriteSystem.js"
23
+ import { ParticleSystem } from "../core/ParticleSystem.js"
24
+ import { transformBoundingSphere, calculateShadowBoundingSphere, sphereInCascade } from "../utils/BoundingSphere.js"
25
+ import { vec3, mat4 } from "../math.js"
26
+ import { Texture } from "../Texture.js"
27
+ import { Geometry } from "../Geometry.js"
28
+
29
+ /**
30
+ * RenderGraph - Orchestrates the multi-pass rendering pipeline
31
+ *
32
+ * Manages pass execution order, resource dependencies, and integrates
33
+ * with the entity/asset system for data-oriented rendering.
34
+ *
35
+ * Pipeline order:
36
+ * 1. Shadow Pass (CSM + spotlight)
37
+ * 2. Reflection Pass (octahedral probes)
38
+ * 3. Planar Reflection Pass (mirrored camera for water/floors)
39
+ * 4. GBuffer Pass (geometry -> albedo, normal, ARM, emission, velocity, depth)
40
+ * 4b. HiZ Pass (hierarchical-Z for occlusion culling)
41
+ * 5. AO Pass (SSAO)
42
+ * 6. Lighting Pass (deferred lighting)
43
+ * 7. Bloom Pass (HDR bright extraction + blur - moved before SSGI)
44
+ * 8. SSGITile Pass (compute - tile light accumulation)
45
+ * 9. SSGI Pass (screen-space global illumination)
46
+ * 9b. Ambient Capture Pass (6-directional sky-aware ambient)
47
+ * 10. RenderPost Pass (combine SSGI/Planar/Ambient with lighting)
48
+ * 11. Transparent Pass (forward rendering for alpha-blended)
49
+ * 12. Bloom Pass (applied to transparent highlights)
50
+ * 13. PostProcess Pass (bloom composite + tone mapping -> canvas)
51
+ */
52
+ class RenderGraph {
53
+ constructor(engine = null) {
54
+ // Reference to engine for settings access
55
+ this.engine = engine
56
+
57
+ // Passes (in execution order)
58
+ this.passes = {
59
+ shadow: null, // Pass 1: Shadow maps
60
+ reflection: null, // Pass 2: Reflection probes
61
+ planarReflection: null, // Pass 3: Planar reflection (mirrored camera)
62
+ gbuffer: null, // Pass 4: GBuffer generation
63
+ hiz: null, // Pass 4b: HiZ reduction (for next frame's occlusion culling)
64
+ ao: null, // Pass 5: SSAO
65
+ lighting: null, // Pass 6: Deferred lighting
66
+ bloom: null, // Pass 7: HDR bloom/glare (moved before SSGI)
67
+ ssgiTile: null, // Pass 8: SSGI tile accumulation (compute)
68
+ ssgi: null, // Pass 9: Screen-space global illumination
69
+ ambientCapture: null, // Pass 9b: 6-directional ambient capture for sky-aware GI
70
+ renderPost: null, // Pass 10: Combine SSGI/Planar with lighting
71
+ transparent: null, // Pass 11: Forward transparent objects
72
+ particles: null, // Pass 12: GPU particle rendering
73
+ postProcess: null, // Pass 13: Tone mapping + bloom composite
74
+ crt: null, // Pass 14: CRT effect (optional)
75
+ }
76
+
77
+ // History buffer manager for temporal effects
78
+ this.historyManager = null
79
+
80
+ // Support systems (pass engine reference)
81
+ this.cullingSystem = new CullingSystem(engine)
82
+ this.instanceManager = new InstanceManager(engine)
83
+ this.spriteSystem = new SpriteSystem(engine)
84
+ this.particleSystem = new ParticleSystem(engine)
85
+
86
+ // Environment map
87
+ this.environmentMap = null
88
+
89
+ // Noise texture for dithering/jittering (can be blue noise or bayer)
90
+ this.noiseTexture = null
91
+ this.noiseSize = 64 // Will be updated when texture loads
92
+ this.noiseAnimated = true // Whether to animate noise offset each frame
93
+
94
+ // Effect scaling for expensive passes (bloom, AO, SSGI, planar reflection)
95
+ // When autoScale.enabledForEffects is true and height > maxHeight, effects render at reduced resolution
96
+ this.effectWidth = 0
97
+ this.effectHeight = 0
98
+ this.effectScale = 1.0
99
+
100
+ // Cache for cloned skins per phase group: "modelId|animation|phase" -> { skin, mesh }
101
+ this._skinnedPhaseCache = new Map()
102
+
103
+ // Cache for individual skins per entity: entityId -> { skin, mesh, geometry }
104
+ this._individualSkinCache = new Map()
105
+
106
+ // Debug/stats
107
+ this.stats = {
108
+ passTimings: {},
109
+ visibleEntities: 0,
110
+ culledEntities: 0,
111
+ drawCalls: 0,
112
+ triangles: 0
113
+ }
114
+
115
+ // Last render context for probe capture
116
+ this._lastRenderContext = null
117
+
118
+ // Probe-specific passes (256x256 for probe face capture)
119
+ this.probePasses = {
120
+ gbuffer: null,
121
+ lighting: null
122
+ }
123
+ }
124
+
125
+ // Convenience getter for individualRenderDistance from settings
126
+ get individualRenderDistance() {
127
+ return this.engine?.settings?.skinning?.individualRenderDistance ?? 20.0
128
+ }
129
+
130
+ /**
131
+ * Create and initialize the render graph
132
+ * @param {Engine} engine - Engine instance for settings access
133
+ * @param {Texture} environmentMap - HDR environment map for IBL
134
+ * @param {number} encoding - 0 = equirectangular, 1 = octahedral
135
+ * @returns {Promise<RenderGraph>}
136
+ */
137
+ static async create(engine, environmentMap, encoding = 0) {
138
+ const graph = new RenderGraph(engine)
139
+ await graph.initialize(environmentMap, encoding)
140
+ return graph
141
+ }
142
+
143
+ /**
144
+ * Initialize all passes
145
+ * @param {Texture} environmentMap - HDR environment map
146
+ * @param {number} encoding - 0 = equirectangular, 1 = octahedral
147
+ */
148
+ async initialize(environmentMap, encoding = 0) {
149
+ const timings = []
150
+ const startTotal = performance.now()
151
+
152
+ this.environmentMap = environmentMap
153
+ this.environmentEncoding = encoding
154
+
155
+ // Load noise texture based on settings
156
+ let start = performance.now()
157
+ await this._loadNoiseTexture()
158
+ timings.push({ name: 'loadNoiseTexture', time: performance.now() - start })
159
+
160
+ // Create passes (pass engine reference)
161
+ this.passes.shadow = new ShadowPass(this.engine)
162
+ this.passes.reflection = new ReflectionPass(this.engine)
163
+ this.passes.planarReflection = new PlanarReflectionPass(this.engine)
164
+ this.passes.gbuffer = new GBufferPass(this.engine)
165
+ this.passes.hiz = new HiZPass(this.engine)
166
+ this.passes.ao = new AOPass(this.engine)
167
+ this.passes.lighting = new LightingPass(this.engine)
168
+ this.passes.bloom = new BloomPass(this.engine)
169
+ this.passes.ssgiTile = new SSGITilePass(this.engine)
170
+ this.passes.ssgi = new SSGIPass(this.engine)
171
+ this.passes.ambientCapture = new AmbientCapturePass(this.engine)
172
+ this.passes.renderPost = new RenderPostPass(this.engine)
173
+ this.passes.transparent = new TransparentPass(this.engine)
174
+ this.passes.particles = new ParticlePass(this.engine)
175
+ this.passes.fog = new FogPass(this.engine)
176
+ this.passes.volumetricFog = new VolumetricFogPass(this.engine)
177
+ this.passes.postProcess = new PostProcessPass(this.engine)
178
+ this.passes.crt = new CRTPass(this.engine)
179
+
180
+ // Create history buffer manager for temporal effects
181
+ const { canvas } = this.engine
182
+ this.historyManager = new HistoryBufferManager(this.engine)
183
+ start = performance.now()
184
+ await this.historyManager.initialize(canvas.width, canvas.height)
185
+ timings.push({ name: 'init:historyManager', time: performance.now() - start })
186
+
187
+ // Initialize passes
188
+ start = performance.now()
189
+ await this.passes.shadow.initialize()
190
+ timings.push({ name: 'init:shadow', time: performance.now() - start })
191
+
192
+ start = performance.now()
193
+ await this.passes.reflection.initialize()
194
+ timings.push({ name: 'init:reflection', time: performance.now() - start })
195
+
196
+ start = performance.now()
197
+ await this.passes.planarReflection.initialize()
198
+ timings.push({ name: 'init:planarReflection', time: performance.now() - start })
199
+
200
+ start = performance.now()
201
+ await this.passes.gbuffer.initialize()
202
+ timings.push({ name: 'init:gbuffer', time: performance.now() - start })
203
+
204
+ start = performance.now()
205
+ await this.passes.hiz.initialize()
206
+ timings.push({ name: 'init:hiz', time: performance.now() - start })
207
+
208
+ start = performance.now()
209
+ await this.passes.ao.initialize()
210
+ timings.push({ name: 'init:ao', time: performance.now() - start })
211
+
212
+ start = performance.now()
213
+ await this.passes.lighting.initialize()
214
+ timings.push({ name: 'init:lighting', time: performance.now() - start })
215
+
216
+ start = performance.now()
217
+ await this.passes.bloom.initialize()
218
+ timings.push({ name: 'init:bloom', time: performance.now() - start })
219
+
220
+ start = performance.now()
221
+ await this.passes.ssgiTile.initialize()
222
+ timings.push({ name: 'init:ssgiTile', time: performance.now() - start })
223
+
224
+ start = performance.now()
225
+ await this.passes.ssgi.initialize()
226
+ timings.push({ name: 'init:ssgi', time: performance.now() - start })
227
+
228
+ start = performance.now()
229
+ await this.passes.renderPost.initialize()
230
+ timings.push({ name: 'init:renderPost', time: performance.now() - start })
231
+
232
+ start = performance.now()
233
+ await this.passes.ambientCapture.initialize()
234
+ timings.push({ name: 'init:ambientCapture', time: performance.now() - start })
235
+
236
+ start = performance.now()
237
+ await this.passes.transparent.initialize()
238
+ timings.push({ name: 'init:transparent', time: performance.now() - start })
239
+
240
+ start = performance.now()
241
+ await this.passes.particles.initialize()
242
+ timings.push({ name: 'init:particles', time: performance.now() - start })
243
+
244
+ start = performance.now()
245
+ await this.passes.fog.initialize()
246
+ timings.push({ name: 'init:fog', time: performance.now() - start })
247
+
248
+ start = performance.now()
249
+ await this.passes.volumetricFog.initialize()
250
+ timings.push({ name: 'init:volumetricFog', time: performance.now() - start })
251
+
252
+ start = performance.now()
253
+ await this.passes.postProcess.initialize()
254
+ timings.push({ name: 'init:postProcess', time: performance.now() - start })
255
+
256
+ start = performance.now()
257
+ await this.passes.crt.initialize()
258
+ timings.push({ name: 'init:crt', time: performance.now() - start })
259
+
260
+ // Wire up dependencies
261
+ start = performance.now()
262
+ this.passes.reflection.setFallbackEnvironment(environmentMap, this.environmentEncoding)
263
+ this.passes.lighting.setEnvironmentMap(environmentMap, this.environmentEncoding)
264
+ await this.passes.lighting.setGBuffer(this.passes.gbuffer.getGBuffer())
265
+ this.passes.lighting.setShadowPass(this.passes.shadow)
266
+ this.passes.lighting.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
267
+ timings.push({ name: 'wire:lighting', time: performance.now() - start })
268
+
269
+ // Wire up planar reflection pass (shares shadows, environment with main)
270
+ start = performance.now()
271
+ this.passes.planarReflection.setDependencies({
272
+ environmentMap,
273
+ encoding: this.environmentEncoding,
274
+ shadowPass: this.passes.shadow,
275
+ lightingPass: this.passes.lighting,
276
+ noise: this.noiseTexture,
277
+ noiseSize: this.noiseSize
278
+ })
279
+ this.passes.planarReflection.setParticleSystem(this.particleSystem)
280
+ timings.push({ name: 'wire:planarReflection', time: performance.now() - start })
281
+
282
+ // Wire up ambient capture pass (shares shadows, environment with main)
283
+ start = performance.now()
284
+ this.passes.ambientCapture.setDependencies({
285
+ environmentMap,
286
+ encoding: this.environmentEncoding,
287
+ shadowPass: this.passes.shadow,
288
+ noise: this.noiseTexture,
289
+ noiseSize: this.noiseSize
290
+ })
291
+ // Wire ambient capture output to RenderPost
292
+ this.passes.renderPost.setAmbientCaptureBuffer(this.passes.ambientCapture.getFaceColorsBuffer())
293
+ timings.push({ name: 'wire:ambientCapture', time: performance.now() - start })
294
+
295
+ // Initialize probe-specific passes at 256x256 (for probe face capture)
296
+ start = performance.now()
297
+ await this._initProbePasses()
298
+ timings.push({ name: 'init:probePasses', time: performance.now() - start })
299
+
300
+ // Set up probe capture to use the renderer
301
+ const probeCapture = this.passes.reflection.getProbeCapture()
302
+ if (probeCapture) {
303
+ probeCapture.setSceneRenderCallback(this._renderSceneForProbe.bind(this))
304
+ }
305
+
306
+ // Set up GBuffer pass with noise texture for alpha hashing
307
+ start = performance.now()
308
+ this.passes.gbuffer.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
309
+ timings.push({ name: 'wire:gbuffer', time: performance.now() - start })
310
+
311
+ // Wire up HiZ pass with GBuffer depth and CullingSystem
312
+ start = performance.now()
313
+ this.passes.hiz.setDepthTexture(this.passes.gbuffer.getGBuffer()?.depth)
314
+ this.cullingSystem.setHiZPass(this.passes.hiz)
315
+ // Also wire HiZ to passes that use it for occlusion culling
316
+ this.passes.gbuffer.setHiZPass(this.passes.hiz)
317
+ this.passes.lighting.setHiZPass(this.passes.hiz)
318
+ this.passes.transparent.setHiZPass(this.passes.hiz)
319
+ this.passes.shadow.setHiZPass(this.passes.hiz)
320
+ timings.push({ name: 'wire:hiz', time: performance.now() - start })
321
+
322
+ // Set up Shadow pass with noise texture for alpha hashing
323
+ start = performance.now()
324
+ this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
325
+ timings.push({ name: 'wire:shadow', time: performance.now() - start })
326
+
327
+ // Set up AO pass
328
+ start = performance.now()
329
+ await this.passes.ao.setGBuffer(this.passes.gbuffer.getGBuffer())
330
+ this.passes.ao.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
331
+ timings.push({ name: 'wire:ao', time: performance.now() - start })
332
+
333
+ // Pass AO texture to lighting
334
+ this.passes.lighting.setAOTexture(this.passes.ao.getOutputTexture())
335
+
336
+ // Set up RenderPost pass with blue noise
337
+ this.passes.renderPost.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
338
+
339
+ // SSGI passes are wired dynamically per frame (prev HDR, emissive, propagate buffer)
340
+
341
+ // Wire up transparent pass (forward rendering for alpha-blended materials)
342
+ this.passes.transparent.setGBuffer(this.passes.gbuffer.getGBuffer())
343
+ this.passes.transparent.setShadowPass(this.passes.shadow)
344
+ this.passes.transparent.setEnvironmentMap(environmentMap, this.environmentEncoding)
345
+ this.passes.transparent.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
346
+
347
+ // Wire up particle pass (GPU particle system rendering)
348
+ this.passes.particles.setParticleSystem(this.particleSystem)
349
+ this.passes.particles.setGBuffer(this.passes.gbuffer.getGBuffer())
350
+ this.passes.particles.setShadowPass(this.passes.shadow)
351
+ this.passes.particles.setEnvironmentMap(environmentMap, this.environmentEncoding)
352
+ this.passes.particles.setLightingPass(this.passes.lighting)
353
+
354
+ // Wire up volumetric fog pass
355
+ this.passes.volumetricFog.setGBuffer(this.passes.gbuffer.getGBuffer())
356
+ this.passes.volumetricFog.setShadowPass(this.passes.shadow)
357
+ this.passes.volumetricFog.setLightingPass(this.passes.lighting)
358
+ this.passes.volumetricFog.setHiZPass(this.passes.hiz)
359
+
360
+ this.passes.postProcess.setInputTexture(this.passes.lighting.getOutputTexture())
361
+ this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
362
+
363
+ // Wire up GUI canvas for overlay rendering
364
+ if (this.engine?.guiCanvas) {
365
+ this.passes.postProcess.setGuiCanvas(this.engine.guiCanvas)
366
+ }
367
+
368
+ // Invalidate occlusion culling after initialization to ensure warmup starts fresh
369
+ // This prevents stale depth data from causing incorrect culling on first frames
370
+ this.invalidateOcclusionCulling()
371
+ }
372
+
373
+ /**
374
+ * Render a frame using the new entity/asset system
375
+ *
376
+ * @param {Object} context
377
+ * @param {EntityManager} context.entityManager - Entity manager
378
+ * @param {AssetManager} context.assetManager - Asset manager
379
+ * @param {Camera} context.camera - Current camera
380
+ * @param {Object} context.meshes - Legacy meshes (optional, for hybrid rendering)
381
+ * @param {number} context.dt - Delta time
382
+ */
383
+ async renderEntities(context) {
384
+ // Skip main render while probe capture is in progress
385
+ // The main render modifies shared mesh instance counts which corrupts probe data
386
+ if (this._isCapturingProbe) {
387
+ return
388
+ }
389
+
390
+ const { entityManager, assetManager, camera, meshes, dt = 0 } = context
391
+ const { canvas, stats } = this.engine
392
+
393
+ // Register sprite entities for animation tracking (before update)
394
+ entityManager.forEach((id, entity) => {
395
+ if (entity.sprite && !entity._spriteRegistered) {
396
+ this.spriteSystem.registerEntity(id, entity)
397
+ entity._spriteRegistered = true
398
+ }
399
+ })
400
+
401
+ // Update sprite animations
402
+ this.spriteSystem.update(dt)
403
+
404
+ // Process entities with particle emitters
405
+ const particleEntities = entityManager.getParticles()
406
+ for (const { id, entity } of particleEntities) {
407
+ // Register emitter if not already registered
408
+ if (entity.particles && !entity._emitterUID) {
409
+ const emitterConfig = typeof entity.particles === 'object'
410
+ ? { ...entity.particles, position: entity.position }
411
+ : { position: entity.position }
412
+ const emitter = this.particleSystem.addEmitter(emitterConfig)
413
+ entity._emitterUID = emitter.uid
414
+ }
415
+ // Update emitter position from entity position
416
+ if (entity._emitterUID) {
417
+ const emitter = this.particleSystem.getEmitter(entity._emitterUID)
418
+ if (emitter) {
419
+ emitter.position = [...entity.position]
420
+ }
421
+ }
422
+ }
423
+
424
+ // Ensure camera matrices are up to date
425
+ camera.aspect = canvas.width / canvas.height
426
+ camera.screenSize[0] = canvas.width
427
+ camera.screenSize[1] = canvas.height
428
+ camera.jitterEnabled = false // Disable for shadow pass first
429
+ camera.updateMatrix()
430
+ camera.updateView()
431
+
432
+ // Update frustum with screen dimensions for pixel size culling
433
+ this.cullingSystem.updateFrustum(camera, canvas.width, canvas.height)
434
+
435
+ // Prepare HiZ for occlusion tests (check camera movement, invalidate if needed)
436
+ const hizPass = this.passes.hiz
437
+ if (hizPass) {
438
+ hizPass.prepareForOcclusionTests(camera)
439
+ }
440
+
441
+ // Cull entities
442
+ const { visible, skinnedCount } = this.cullingSystem.cull(
443
+ entityManager,
444
+ assetManager,
445
+ 'main'
446
+ )
447
+
448
+ this.stats.visibleEntities = visible.length
449
+ this.stats.culledEntities = entityManager.count - visible.length
450
+
451
+ // Group by model for instancing
452
+ const groups = this.cullingSystem.groupByModel(visible)
453
+
454
+ // Build instance batches
455
+ const batches = this.instanceManager.buildBatches(groups, assetManager)
456
+
457
+ // For shadow pass, we need entities within shadow range, not just camera-visible ones
458
+ // Apply pixel size culling and distance culling based on cascade coverage
459
+ // NEW: Use shadow bounding spheres for frustum/occlusion culling (only for main light)
460
+ const allEntities = []
461
+ const shadowConfig = this.cullingSystem.config.shadow
462
+
463
+ // Get max shadow distance from culling.shadow.maxDistance setting
464
+ const maxShadowDistance = shadowConfig?.maxDistance ?? 100
465
+
466
+ // Check if main light is enabled - shadow bounding sphere culling only applies to main light
467
+ const mainLight = this.engine?.settings?.mainLight
468
+ const mainLightEnabled = mainLight?.enabled !== false
469
+
470
+ // Get light direction for shadow bounding sphere calculation (only used when main light enabled)
471
+ const lightDir = vec3.fromValues(
472
+ mainLight?.direction?.[0] ?? -1,
473
+ mainLight?.direction?.[1] ?? 1,
474
+ mainLight?.direction?.[2] ?? -0.5
475
+ )
476
+ vec3.normalize(lightDir, lightDir)
477
+
478
+ // Ground level for shadow projection (default 0, or from planarReflection settings)
479
+ const groundLevel = this.engine?.settings?.planarReflection?.groundLevel ?? 0
480
+
481
+ // Shadow culling settings - only apply shadow bounding sphere culling when main light is enabled
482
+ // Spotlights have their own frustum culling in ShadowPass
483
+ const shadowCullingEnabled = mainLightEnabled && shadowConfig?.frustum !== false
484
+ const shadowHiZEnabled = mainLightEnabled && shadowConfig?.hiZ !== false && this.passes.hiz
485
+
486
+ // Track shadow culling stats
487
+ let shadowFrustumCulled = 0
488
+ let shadowHiZCulled = 0
489
+ let shadowDistanceCulled = 0
490
+ let shadowPixelCulled = 0
491
+
492
+ // Collect sprite-only entities (entities with .sprite but no .model)
493
+ const spriteOnlyEntities = []
494
+ entityManager.forEach((id, entity) => {
495
+ if (!entity._visible) return
496
+ if (entity.sprite && !entity.model) {
497
+ // Calculate bounding sphere for sprite entity based on scale
498
+ const scale = entity.scale || [1, 1, 1]
499
+ const radius = Math.max(scale[0], scale[1]) * 0.5
500
+ entity._bsphere = {
501
+ center: [...entity.position],
502
+ radius: radius
503
+ }
504
+ spriteOnlyEntities.push({ id, entity })
505
+ }
506
+ })
507
+
508
+ entityManager.forEach((id, entity) => {
509
+ // Skip invisible entities (same check as main cull)
510
+ if (!entity._visible) return
511
+
512
+ if (entity.model) {
513
+ // Update bsphere from asset for shadow culling
514
+ // Note: For skinned models, bsphere is pre-computed as combined sphere of all submeshes
515
+ const asset = assetManager.get(entity.model)
516
+ if (asset?.bsphere) {
517
+ entity._bsphere = transformBoundingSphere(asset.bsphere, entity._matrix)
518
+ }
519
+
520
+ if (entity._bsphere) {
521
+ // Calculate shadow bounding sphere only when main light is enabled
522
+ // For spotlights, we use the object's regular bsphere (spotlight culling is in ShadowPass)
523
+ if (mainLightEnabled) {
524
+ entity._shadowBsphere = calculateShadowBoundingSphere(
525
+ entity._bsphere,
526
+ lightDir,
527
+ groundLevel
528
+ )
529
+ } else {
530
+ // When main light is off, use regular bsphere for distance/pixel culling
531
+ entity._shadowBsphere = entity._bsphere
532
+ }
533
+
534
+ // Use shadow bounding sphere for distance culling
535
+ // This ensures objects whose shadows are visible are included
536
+ const distance = this.cullingSystem.frustum.getDistance(entity._shadowBsphere)
537
+ if (distance - entity._shadowBsphere.radius > maxShadowDistance) {
538
+ shadowDistanceCulled++
539
+ return // Shadow too far to be visible
540
+ }
541
+
542
+ // Skip if projected size is too small (shadow won't be visible)
543
+ // Use shadow bounding sphere for pixel size calculation
544
+ if (shadowConfig.minPixelSize > 0) {
545
+ const projectedSize = this.cullingSystem.frustum.getProjectedSize(entity._shadowBsphere, distance)
546
+ if (projectedSize < shadowConfig.minPixelSize) {
547
+ shadowPixelCulled++
548
+ return // Shadow too small to see
549
+ }
550
+ }
551
+
552
+ // Frustum cull using shadow bounding sphere (only for main light)
553
+ // Spotlights have their own frustum culling in ShadowPass._buildFilteredInstances
554
+ if (shadowCullingEnabled) {
555
+ if (!this.cullingSystem.frustum.testSpherePlanes(entity._shadowBsphere)) {
556
+ shadowFrustumCulled++
557
+ return // Shadow not in camera frustum
558
+ }
559
+ }
560
+
561
+ // HiZ occlusion cull using shadow bounding sphere (only for main light)
562
+ // Spotlights have their own distance/frustum culling in ShadowPass
563
+ if (shadowHiZEnabled && this.cullingSystem.frustum.hiZValid) {
564
+ const occluded = this.passes.hiz.testSphereOcclusion(
565
+ entity._shadowBsphere,
566
+ this.cullingSystem.frustum.viewProj
567
+ )
568
+ if (occluded) {
569
+ shadowHiZCulled++
570
+ return // Shadow occluded by depth buffer
571
+ }
572
+ }
573
+ }
574
+
575
+ allEntities.push({ id, entity })
576
+ }
577
+ })
578
+
579
+ // Store shadow culling stats
580
+ stats.shadowFrustumCulled = shadowFrustumCulled
581
+ stats.shadowHiZCulled = shadowHiZCulled
582
+ stats.shadowDistanceCulled = shadowDistanceCulled
583
+ stats.shadowPixelCulled = shadowPixelCulled
584
+
585
+ const allGroups = this.cullingSystem.groupByModel(allEntities)
586
+
587
+ // Process lights BEFORE shadow pass so shadow can use processed light data
588
+ // Pass camera for frustum culling and distance ordering of point lights
589
+ const rawLights = entityManager.getLights()
590
+ this.passes.lighting.updateLightsFromEntities(rawLights, camera)
591
+
592
+ // Update meshes for shadow pass (includes entities within shadow range, even if outside main frustum)
593
+ this._updateMeshInstancesFromEntities(allGroups, assetManager, meshes, true, camera, 0, null)
594
+
595
+ // Execute shadow pass FIRST with shadow-culled data
596
+ const passContext = {
597
+ camera,
598
+ meshes,
599
+ dt,
600
+ lights: this.passes.lighting.lights, // Use processed lights with lightType
601
+ mainLight: this.engine?.settings?.mainLight // Main directional light settings
602
+ }
603
+
604
+ // Pass 1: Shadow (uses shadow-culled entities - can include off-screen objects)
605
+ // Always execute - ShadowPass internally skips cascades when main light is off,
606
+ // but still renders spotlight shadows
607
+ await this.passes.shadow.execute(passContext)
608
+
609
+ // Pass 2: Reflection (updates active probes based on camera position)
610
+ await this.passes.reflection.execute(passContext)
611
+
612
+ // Pass 2b: Planar Reflection (render scene from mirrored camera)
613
+ // Only execute when enabled - skipped entirely when off
614
+ if (this.passes.planarReflection && this.engine?.settings?.planarReflection?.enabled) {
615
+ // Cull entities specifically for planar reflection (distance, skinned limit, pixel size)
616
+ const { visible: planarVisible } = this.cullingSystem.cull(
617
+ entityManager,
618
+ assetManager,
619
+ 'planarReflection'
620
+ )
621
+ const planarGroups = this.cullingSystem.groupByModel(planarVisible)
622
+
623
+ // Filter out horizontal sprites from planar reflection (they're flat on ground, shouldn't reflect)
624
+ const planarSpriteEntities = spriteOnlyEntities.filter(item => {
625
+ const pivot = item.entity.pivot || item.entity.sprite?.pivot
626
+ return pivot !== 'horizontal'
627
+ })
628
+
629
+ // Update meshes with planar reflection culled entities (including sprites)
630
+ this._updateMeshInstancesFromEntities(planarGroups, assetManager, meshes, true, camera, dt, entityManager, planarSpriteEntities)
631
+
632
+ // Set distance fade for planar reflection (prevents object popping at maxDistance)
633
+ const planarCulling = this.engine?.settings?.culling?.planarReflection
634
+ const planarFadeMaxDist = planarCulling?.maxDistance ?? 50
635
+ const planarFadeStart = planarCulling?.fadeStart ?? 0.7
636
+ if (this.passes.planarReflection.gbufferPass) {
637
+ this.passes.planarReflection.gbufferPass.distanceFadeEnd = planarFadeMaxDist
638
+ this.passes.planarReflection.gbufferPass.distanceFadeStart = planarFadeMaxDist * planarFadeStart
639
+ }
640
+
641
+ await this.passes.planarReflection.execute(passContext)
642
+ } else {
643
+ // Zero stats when disabled
644
+ stats.planarDrawCalls = 0
645
+ stats.planarTriangles = 0
646
+ }
647
+
648
+ // NOW update meshes for main render - overwrites shadow data with main-culled instances
649
+ // Pass sprite-only entities for sprite rendering
650
+ this._updateMeshInstancesFromEntities(groups, assetManager, meshes, true, camera, dt, entityManager, spriteOnlyEntities)
651
+
652
+ // Enable TAA jitter for main render (after shadow, before GBuffer)
653
+ // Updates projection matrix with sub-pixel offset for temporal anti-aliasing
654
+ camera.jitterEnabled = this.engine?.settings?.rendering?.jitter ?? true
655
+ camera.updateView() // Recompute proj with jitter
656
+
657
+ // Set distance fade for main render (prevents object popping at maxDistance)
658
+ const mainCulling = this.engine?.settings?.culling?.main
659
+ const mainMaxDist = mainCulling?.maxDistance ?? 1000
660
+ const mainFadeStart = mainCulling?.fadeStart ?? 0.9
661
+ this.passes.gbuffer.distanceFadeEnd = mainMaxDist
662
+ this.passes.gbuffer.distanceFadeStart = mainMaxDist * mainFadeStart
663
+
664
+ // Pass 3: GBuffer (uses main-culled entities, outputs velocity for motion vectors)
665
+ passContext.historyManager = this.historyManager
666
+ await this.passes.gbuffer.execute(passContext)
667
+
668
+ // Pass 3b: HiZ reduction (for next frame's occlusion culling)
669
+ // Must run after GBuffer to have depth data, before next frame's culling
670
+ if (this.passes.hiz) {
671
+ this.passes.hiz.setDepthTexture(this.passes.gbuffer.getGBuffer()?.depth)
672
+ await this.passes.hiz.execute(passContext)
673
+ }
674
+
675
+ // Pass 4: AO (screen-space ambient occlusion)
676
+ await this.passes.ao.execute(passContext)
677
+
678
+ // Pass 5: Lighting
679
+ await this.passes.lighting.execute(passContext)
680
+
681
+ // Copy lighting and normal to history buffers for temporal effects
682
+ const { device } = this.engine
683
+ const commandEncoder = device.createCommandEncoder({ label: 'historyCommandEncoder' })
684
+ this.historyManager.copyLightingToHistory(commandEncoder, this.passes.lighting.getOutputTexture())
685
+ this.historyManager.copyNormalToHistory(commandEncoder, this.passes.gbuffer.getGBuffer()?.normal)
686
+ device.queue.submit([commandEncoder.finish()])
687
+
688
+ const gbuffer = this.passes.gbuffer.getGBuffer()
689
+ const lightingOutput = this.passes.lighting.getOutputTexture()
690
+
691
+ // Pass 7: SSGITile (compute shader - accumulate + propagate light between tiles)
692
+ const ssgiEnabled = this.engine?.settings?.ssgi?.enabled
693
+ const prevData = this.historyManager.getPrevious()
694
+ if (this.passes.ssgiTile && ssgiEnabled && prevData.hasValidHistory) {
695
+ // Use previous frame HDR and emissive for tile accumulation
696
+ this.passes.ssgiTile.setPrevHDRTexture(prevData.color)
697
+ this.passes.ssgiTile.setEmissiveTexture(gbuffer.emission)
698
+ await this.passes.ssgiTile.execute(passContext)
699
+ }
700
+
701
+ // Pass 8: SSGI (screen-space global illumination - sample from propagated tiles)
702
+ if (this.passes.ssgi && ssgiEnabled && prevData.hasValidHistory) {
703
+ // Pass propagate buffer to SSGI for sampling
704
+ const tileInfo = this.passes.ssgiTile.getTileInfo()
705
+ this.passes.ssgi.setPropagateBuffer(
706
+ this.passes.ssgiTile.getPropagateBuffer(),
707
+ tileInfo.tileCountX,
708
+ tileInfo.tileCountY
709
+ )
710
+ this.passes.ssgi.setGBuffer(gbuffer)
711
+ await this.passes.ssgi.execute({
712
+ camera,
713
+ gbuffer,
714
+ })
715
+ }
716
+
717
+ // Pass 9b: Ambient Capture (6-directional sky-aware ambient)
718
+ // Captures sky visibility in 6 directions for ambient lighting
719
+ if (this.passes.ambientCapture && this.engine?.settings?.ambientCapture?.enabled) {
720
+ await this.passes.ambientCapture.execute(passContext)
721
+ }
722
+
723
+ // Pass 10: RenderPost (combine SSGI/PlanarReflection/AmbientCapture with lighting)
724
+ let hdrSource = lightingOutput
725
+ if (this.passes.renderPost) {
726
+ const planarEnabled = this.engine?.settings?.planarReflection?.enabled
727
+ const ambientCaptureEnabled = this.engine?.settings?.ambientCapture?.enabled
728
+ await this.passes.renderPost.execute({
729
+ lightingOutput,
730
+ gbuffer,
731
+ camera,
732
+ ssgi: this.passes.ssgi?.getSSGITexture(),
733
+ // Pass null when disabled - renderPost uses black placeholder
734
+ planarReflection: planarEnabled ? this.passes.planarReflection?.getReflectionTexture() : null,
735
+ })
736
+
737
+ // Use RenderPost output if any screen-space effect is enabled
738
+ if (ssgiEnabled || planarEnabled || ambientCaptureEnabled) {
739
+ hdrSource = this.passes.renderPost.getOutputTexture()
740
+ }
741
+ }
742
+
743
+ // Pass 11: Transparent (forward rendering for alpha-blended materials)
744
+ if (this.passes.transparent) {
745
+ this.passes.transparent.setOutputTexture(hdrSource)
746
+ // Set distance fade for transparent pass (same as main render)
747
+ const transparentCulling = this.engine?.settings?.culling?.main
748
+ const transparentMaxDist = transparentCulling?.maxDistance ?? 1000
749
+ const transparentFadeStart = transparentCulling?.fadeStart ?? 0.9
750
+ this.passes.transparent.distanceFadeEnd = transparentMaxDist
751
+ this.passes.transparent.distanceFadeStart = transparentMaxDist * transparentFadeStart
752
+ await this.passes.transparent.execute(passContext)
753
+ }
754
+
755
+ // Pass 12: Fog (distance-based fog with height fade)
756
+ // Applied BEFORE particles - scene fog uses scene depth
757
+ // Particles will apply their own fog based on particle position
758
+ const fogEnabled = this.engine?.settings?.environment?.fog?.enabled
759
+ if (this.passes.fog && fogEnabled) {
760
+ this.passes.fog.setInputTexture(hdrSource)
761
+ this.passes.fog.setGBuffer(gbuffer)
762
+ await this.passes.fog.execute(passContext)
763
+ const fogOutput = this.passes.fog.getOutputTexture()
764
+ if (fogOutput && fogOutput !== hdrSource) {
765
+ hdrSource = fogOutput
766
+ }
767
+ }
768
+
769
+ // Pass 12b: Particles (GPU particle system)
770
+ // Rendered AFTER simple fog, BEFORE volumetric fog
771
+ // Particles apply their own fog based on particle world position
772
+ if (this.passes.particles && this.particleSystem.getActiveEmitters().length > 0) {
773
+ this.passes.particles.setOutputTexture(hdrSource)
774
+ await this.passes.particles.execute(passContext)
775
+ }
776
+
777
+ // Pass 13: Volumetric Fog (light scattering through particles)
778
+ // Applied last - additive light scattering on top of everything
779
+ const volumetricFogEnabled = this.engine?.settings?.volumetricFog?.enabled
780
+ if (this.passes.volumetricFog && volumetricFogEnabled) {
781
+ this.passes.volumetricFog.setInputTexture(hdrSource)
782
+ this.passes.volumetricFog.setGBuffer(gbuffer)
783
+ await this.passes.volumetricFog.execute(passContext)
784
+ const volFogOutput = this.passes.volumetricFog.getOutputTexture()
785
+ if (volFogOutput && volFogOutput !== hdrSource) {
786
+ hdrSource = volFogOutput
787
+ }
788
+ }
789
+
790
+ // Pass 14: Bloom (HDR bright extraction + blur)
791
+ // Runs after transparent so glass/water highlights contribute to bloom
792
+ const bloomEnabled = this.engine?.settings?.bloom?.enabled
793
+ if (this.passes.bloom && bloomEnabled) {
794
+ this.passes.bloom.setInputTexture(hdrSource)
795
+ await this.passes.bloom.execute(passContext)
796
+ this.passes.postProcess.setBloomTexture(this.passes.bloom.getOutputTexture())
797
+ } else {
798
+ this.passes.postProcess.setBloomTexture(null)
799
+ }
800
+
801
+ // Pass 13: PostProcess (bloom composite + tone mapping)
802
+ // When CRT is enabled, outputs to intermediate texture instead of canvas
803
+ this.passes.postProcess.setInputTexture(hdrSource)
804
+ await this.passes.postProcess.execute(passContext)
805
+
806
+ // Pass 14: CRT effect (optional - outputs to canvas)
807
+ const crtEnabled = this.engine?.settings?.crt?.enabled
808
+ const crtUpscaleEnabled = this.engine?.settings?.crt?.upscaleEnabled
809
+ if (crtEnabled || crtUpscaleEnabled) {
810
+ // Wire CRT pass to receive PostProcess intermediate output
811
+ const postProcessOutput = this.passes.postProcess.getOutputTexture()
812
+ if (postProcessOutput) {
813
+ this.passes.crt.setInputTexture(postProcessOutput)
814
+ this.passes.crt.setRenderSize(
815
+ this.passes.gbuffer.getGBuffer()?.depth?.width || canvas.width,
816
+ this.passes.gbuffer.getGBuffer()?.depth?.height || canvas.height
817
+ )
818
+ await this.passes.crt.execute(passContext)
819
+ }
820
+ }
821
+
822
+ // Swap history buffers and save camera matrices for next frame
823
+ this.historyManager.swap(camera)
824
+
825
+ // Store render context for probe capture (entityManager/assetManager for building fresh batches)
826
+ this._lastRenderContext = {
827
+ meshes,
828
+ entityManager,
829
+ assetManager
830
+ }
831
+
832
+ // Update stats
833
+ this.stats.drawCalls = stats.drawCalls
834
+ this.stats.triangles = stats.triangles
835
+ }
836
+
837
+ /**
838
+ * Update legacy mesh instances from entity transforms
839
+ * This bridges the entity system with existing mesh rendering
840
+ *
841
+ * @param {Map} groups - Entity groups by model ID
842
+ * @param {AssetManager} assetManager - Asset manager
843
+ * @param {Object} meshes - Meshes dictionary
844
+ * @param {boolean} resetAll - Reset all mesh instance counts
845
+ * @param {Camera} camera - Camera for proximity calculation (null = no individual skins)
846
+ * @param {number} dt - Delta time for animation updates
847
+ * @param {EntityManager} entityManager - Entity manager for animation state
848
+ */
849
+ _updateMeshInstancesFromEntities(groups, assetManager, meshes, resetAll = false, camera = null, dt = 0, entityManager = null, spriteOnlyEntities = []) {
850
+ if (!meshes) return
851
+
852
+ const { device } = this.engine
853
+
854
+ // Reset ALL mesh instance counts first if requested (for frustum-culled passes)
855
+ // Skip meshes marked as static (manually placed, not entity-managed)
856
+ if (resetAll) {
857
+ for (const name in meshes) {
858
+ const mesh = meshes[name]
859
+ if (mesh.geometry && !mesh.static) {
860
+ mesh.geometry.instanceCount = 0
861
+ mesh.geometry._instanceDataDirty = true
862
+ }
863
+ }
864
+ }
865
+
866
+ // Track which meshes we've updated
867
+ const updatedMeshes = new Set()
868
+
869
+ // Get camera position for proximity check
870
+ const cameraPos = camera ? [camera.position[0], camera.position[1], camera.position[2]] : null
871
+ const individualDistSq = this.individualRenderDistance * this.individualRenderDistance
872
+
873
+ // Collect sprite entities and group by material key for batching
874
+ const spriteGroups = new Map() // materialKey -> { entities, spriteInfo }
875
+
876
+ // Separate entities by type and proximity
877
+ const nonSkinnedGroups = new Map()
878
+ const skinnedIndividualEntities = [] // Close entities needing individual skins
879
+ const skinnedInstancedGroups = new Map() // Far entities for phase-grouped instancing
880
+
881
+ for (const [modelId, entities] of groups) {
882
+ // Check for sprite entities (entities with .sprite property but no model asset)
883
+ // These are handled separately with billboard geometry
884
+ for (const item of entities) {
885
+ const entity = item.entity
886
+ if (entity.sprite) {
887
+ // Parse sprite and compute UV transform
888
+ const spriteInfo = this.spriteSystem.parseSprite(entity.sprite)
889
+ if (spriteInfo) {
890
+ // Compute UV transform from frame (uses animated _uvTransform if available)
891
+ const instanceData = this.spriteSystem.getSpriteInstanceData(entity)
892
+ entity._uvTransform = instanceData.uvTransform
893
+
894
+ // Group sprites by material key for batching
895
+ const pivot = entity.pivot || 'center'
896
+ const roughness = entity.roughness ?? 0.7
897
+ const materialKey = `sprite:${spriteInfo.url}:${pivot}:r${roughness.toFixed(2)}`
898
+
899
+ if (!spriteGroups.has(materialKey)) {
900
+ spriteGroups.set(materialKey, {
901
+ entities: [],
902
+ spriteInfo,
903
+ pivot,
904
+ roughness
905
+ })
906
+ }
907
+ spriteGroups.get(materialKey).entities.push(item)
908
+ }
909
+ }
910
+ }
911
+
912
+ const asset = assetManager.get(modelId)
913
+
914
+ // Handle parent GLTF paths (expand to all submeshes)
915
+ // If entity.model is "model.glb" instead of "model.glb|meshName", expand to all meshes
916
+ if (asset?.meshNames && !asset.mesh) {
917
+ // This is a parent GLTF asset - expand to all submeshes
918
+ // Each submesh will share the same entity transform/animation/phase
919
+ for (const meshName of asset.meshNames) {
920
+ const submeshId = assetManager.createModelId(modelId, meshName)
921
+ const submeshAsset = assetManager.get(submeshId)
922
+ if (!submeshAsset?.mesh) continue
923
+
924
+ // Add this submesh to the appropriate group
925
+ if (submeshAsset.hasSkin && submeshAsset.skin) {
926
+ // Skinned submesh - process each entity for this submesh
927
+ // Expanded submeshes ALWAYS use phase-grouped instancing (even when close)
928
+ // Individual rendering doesn't support multi-submesh expansion
929
+ for (const item of entities) {
930
+ const entity = item.entity
931
+
932
+ const animation = entity.animation || 'default'
933
+ const phase = entity.phase || 0
934
+ const quantizedPhase = Math.floor(phase / 0.05) * 0.05
935
+ const key = `${submeshId}|${animation}|${quantizedPhase.toFixed(2)}`
936
+
937
+ if (!skinnedInstancedGroups.has(key)) {
938
+ skinnedInstancedGroups.set(key, {
939
+ modelId: submeshId, animation, phase: quantizedPhase, asset: submeshAsset, entities: []
940
+ })
941
+ }
942
+ skinnedInstancedGroups.get(key).entities.push(item)
943
+ }
944
+ } else {
945
+ // Non-skinned submesh - add to non-skinned groups
946
+ if (!nonSkinnedGroups.has(submeshId)) {
947
+ nonSkinnedGroups.set(submeshId, { asset: submeshAsset, entities: [] })
948
+ }
949
+ for (const item of entities) {
950
+ nonSkinnedGroups.get(submeshId).entities.push(item)
951
+ }
952
+ }
953
+ }
954
+ continue // Skip normal processing, we've handled expansion
955
+ }
956
+
957
+ if (!asset?.mesh) continue
958
+
959
+ if (asset.hasSkin && asset.skin) {
960
+ // For skinned meshes, check proximity for each entity
961
+ for (const item of entities) {
962
+ const entity = item.entity
963
+ const entityId = item.id
964
+
965
+ // Calculate distance to camera
966
+ let useIndividual = false
967
+ if (cameraPos && entity._bsphere) {
968
+ const dx = entity._bsphere.center[0] - cameraPos[0]
969
+ const dy = entity._bsphere.center[1] - cameraPos[1]
970
+ const dz = entity._bsphere.center[2] - cameraPos[2]
971
+ const distSq = dx * dx + dy * dy + dz * dz
972
+ useIndividual = distSq < individualDistSq
973
+ }
974
+
975
+ // Check if individual mesh is ready and pipeline is stable
976
+ // If not, keep in instanced group to avoid flash during transition
977
+ let addToIndividualList = false
978
+ if (useIndividual) {
979
+ const cached = this._individualSkinCache.get(entityId)
980
+ if (cached?.mesh && this.passes.gbuffer) {
981
+ // Only switch if pipeline is stable
982
+ if (!this.passes.gbuffer.isPipelineStable(cached.mesh)) {
983
+ addToIndividualList = true // Create/warm pipeline
984
+ useIndividual = false // But keep in instanced until ready
985
+ }
986
+ } else if (!cached) {
987
+ // No cache yet - will be created, but keep in instanced for this frame
988
+ addToIndividualList = true // Create the mesh/pipeline
989
+ useIndividual = false // Keep in instanced for this frame
990
+ }
991
+ }
992
+
993
+ if (useIndividual || addToIndividualList) {
994
+ skinnedIndividualEntities.push({ id: entityId, entity, asset, modelId })
995
+ }
996
+ if (!useIndividual) {
997
+ // Group by animation and phase for instancing
998
+ const animation = entity.animation || 'default'
999
+ const phase = entity.phase || 0
1000
+ const quantizedPhase = Math.floor(phase / 0.05) * 0.05
1001
+ const key = `${modelId}|${animation}|${quantizedPhase.toFixed(2)}`
1002
+
1003
+ if (!skinnedInstancedGroups.has(key)) {
1004
+ skinnedInstancedGroups.set(key, {
1005
+ modelId, animation, phase: quantizedPhase, asset, entities: []
1006
+ })
1007
+ }
1008
+ skinnedInstancedGroups.get(key).entities.push(item)
1009
+ }
1010
+ }
1011
+ } else {
1012
+ nonSkinnedGroups.set(modelId, { asset, entities })
1013
+ }
1014
+ }
1015
+
1016
+ // Process non-skinned meshes (simple path)
1017
+ for (const [modelId, { asset, entities }] of nonSkinnedGroups) {
1018
+ const mesh = asset.mesh
1019
+ const geometry = mesh.geometry
1020
+
1021
+ let meshName = null
1022
+ for (const name in meshes) {
1023
+ if (meshes[name] === mesh || meshes[name].geometry === geometry) {
1024
+ meshName = name
1025
+ break
1026
+ }
1027
+ }
1028
+
1029
+ if (!meshName) {
1030
+ // Use sanitized modelId as base name, but ensure uniqueness
1031
+ let baseName = modelId.replace(/[^a-zA-Z0-9]/g, '_')
1032
+ meshName = baseName
1033
+ // If name already exists with a DIFFERENT mesh, make it unique
1034
+ let counter = 1
1035
+ while (meshes[meshName] && meshes[meshName] !== mesh && meshes[meshName].geometry !== geometry) {
1036
+ meshName = `${baseName}_${counter++}`
1037
+ }
1038
+ meshes[meshName] = mesh
1039
+ }
1040
+
1041
+ geometry.instanceCount = 0
1042
+
1043
+ for (const item of entities) {
1044
+ const entity = item.entity
1045
+ const idx = geometry.instanceCount
1046
+
1047
+ if (idx >= geometry.maxInstances) {
1048
+ geometry.growInstanceBuffer(entities.length)
1049
+ }
1050
+
1051
+ geometry.instanceCount++
1052
+ const base = idx * 28
1053
+ geometry.instanceData.set(entity._matrix, base)
1054
+ geometry.instanceData[base + 16] = entity._bsphere.center[0]
1055
+ geometry.instanceData[base + 17] = entity._bsphere.center[1]
1056
+ geometry.instanceData[base + 18] = entity._bsphere.center[2]
1057
+ // Negative radius signals shader to skip pixel/position rounding
1058
+ geometry.instanceData[base + 19] = entity.noRounding
1059
+ ? -Math.max(entity._bsphere.radius, 1)
1060
+ : entity._bsphere.radius
1061
+
1062
+ // uvTransform: [offsetX, offsetY, scaleX, scaleY] - default full texture
1063
+ const uvTransform = entity._uvTransform || [0, 0, 1, 1]
1064
+ geometry.instanceData[base + 20] = uvTransform[0]
1065
+ geometry.instanceData[base + 21] = uvTransform[1]
1066
+ geometry.instanceData[base + 22] = uvTransform[2]
1067
+ geometry.instanceData[base + 23] = uvTransform[3]
1068
+
1069
+ // color: [r, g, b, a] - default white
1070
+ const color = entity.color || [1, 1, 1, 1]
1071
+ geometry.instanceData[base + 24] = color[0]
1072
+ geometry.instanceData[base + 25] = color[1]
1073
+ geometry.instanceData[base + 26] = color[2]
1074
+ geometry.instanceData[base + 27] = color[3]
1075
+ }
1076
+
1077
+ geometry._instanceDataDirty = true
1078
+ updatedMeshes.add(meshName)
1079
+ }
1080
+
1081
+ // Process individual skinned entities (close to camera, with blending support)
1082
+ // Animation time can be scaled by settings.animation.speed (default 1.0)
1083
+ const animationSpeed = this.engine?.settings?.animation?.speed ?? 1.0
1084
+ const globalTime = (performance.now() / 1000) * animationSpeed
1085
+
1086
+ for (const { id: entityId, entity, asset, modelId } of skinnedIndividualEntities) {
1087
+ const entityAnimation = entity.animation || 'default'
1088
+ const entityPhase = entity.phase || 0
1089
+
1090
+ // Get or create individual skin for this entity
1091
+ let cached = this._individualSkinCache.get(entityId)
1092
+
1093
+ if (!cached) {
1094
+ // Create individual skin with local transforms for blending
1095
+ const individualSkin = asset.skin.cloneForIndividual()
1096
+
1097
+ // Create geometry wrapper for single instance
1098
+ const originalGeom = asset.mesh.geometry
1099
+ const geomUid = `individual_${entityId}_${Date.now()}`
1100
+
1101
+ const individualGeometry = {
1102
+ uid: geomUid,
1103
+ vertexBuffer: originalGeom.vertexBuffer,
1104
+ indexBuffer: originalGeom.indexBuffer,
1105
+ vertexBufferLayout: originalGeom.vertexBufferLayout,
1106
+ instanceBufferLayout: originalGeom.instanceBufferLayout,
1107
+ vertexCount: originalGeom.vertexCount,
1108
+ indexArray: originalGeom.indexArray,
1109
+ attributes: originalGeom.attributes,
1110
+ maxInstances: 1,
1111
+ instanceCount: 1,
1112
+ instanceData: new Float32Array(28), // 28 floats: matrix(16) + posRadius(4) + uvTransform(4) + color(4)
1113
+ instanceBuffer: device.createBuffer({
1114
+ size: 112, // 28 floats * 4 bytes
1115
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1116
+ }),
1117
+ _instanceDataDirty: true,
1118
+ writeInstanceBuffer() {
1119
+ device.queue.writeBuffer(this.instanceBuffer, 0, this.instanceData)
1120
+ },
1121
+ update() {
1122
+ if (this._instanceDataDirty) {
1123
+ this.writeInstanceBuffer()
1124
+ this._instanceDataDirty = false
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ const individualMesh = {
1130
+ geometry: individualGeometry,
1131
+ material: asset.mesh.material,
1132
+ skin: individualSkin,
1133
+ hasSkin: true,
1134
+ uid: `individual_${entityId}`,
1135
+ // Use asset's combined bsphere for culling (all skinned submeshes share one sphere)
1136
+ combinedBsphere: asset.bsphere || null
1137
+ }
1138
+
1139
+ cached = {
1140
+ skin: individualSkin,
1141
+ mesh: individualMesh,
1142
+ geometry: individualGeometry,
1143
+ lastAnimation: entityAnimation,
1144
+ // Blending state
1145
+ blendStartTime: 0,
1146
+ blendFromAnim: null,
1147
+ blendFromPhaseOffset: 0
1148
+ }
1149
+ this._individualSkinCache.set(entityId, cached)
1150
+
1151
+ // Initialize animation
1152
+ individualSkin.currentAnimation = entityAnimation
1153
+ }
1154
+
1155
+ const { skin: individualSkin, mesh: individualMesh, geometry: individualGeometry } = cached
1156
+
1157
+ // Get animation info
1158
+ const anim = individualSkin.animations[entityAnimation]
1159
+ if (!anim) continue
1160
+
1161
+ // Calculate time EXACTLY the same way as far mode
1162
+ // This ensures no glitch when switching between modes
1163
+ const phaseOffset = entityPhase * anim.duration
1164
+ const baseTime = globalTime + phaseOffset
1165
+
1166
+ // Check if animation changed - start blend
1167
+ if (dt > 0 && cached.lastAnimation !== entityAnimation) {
1168
+ // Animation changed! Start blending
1169
+ cached.blendFromAnim = cached.lastAnimation
1170
+ cached.blendFromPhaseOffset = entityPhase * (individualSkin.animations[cached.lastAnimation]?.duration || anim.duration)
1171
+ cached.blendStartTime = globalTime
1172
+ cached.lastAnimation = entityAnimation
1173
+ individualSkin.currentAnimation = entityAnimation
1174
+ individualSkin.isBlending = true
1175
+ individualSkin.blendDuration = 0.3
1176
+ }
1177
+
1178
+ // Handle blending with global time (not dt-based)
1179
+ if (individualSkin.isBlending && cached.blendFromAnim) {
1180
+ const blendElapsed = globalTime - cached.blendStartTime
1181
+ const blendWeight = Math.min(blendElapsed / individualSkin.blendDuration, 1.0)
1182
+
1183
+ if (blendWeight >= 1.0) {
1184
+ // Blend complete
1185
+ individualSkin.isBlending = false
1186
+ cached.blendFromAnim = null
1187
+ individualSkin.time = baseTime
1188
+ individualSkin.update(0) // Apply current animation only
1189
+ } else {
1190
+ // Blending - manually apply both animations
1191
+ const fromAnim = individualSkin.animations[cached.blendFromAnim]
1192
+ const fromTime = globalTime + cached.blendFromPhaseOffset
1193
+
1194
+ // Set up blend state
1195
+ individualSkin.blendFromAnimation = cached.blendFromAnim
1196
+ individualSkin.blendFromTime = fromTime
1197
+ individualSkin.blendWeight = blendWeight
1198
+ individualSkin.time = baseTime
1199
+
1200
+ // Update applies blended animation
1201
+ individualSkin.update(0)
1202
+ }
1203
+ } else {
1204
+ // No blending - just set time and update
1205
+ individualSkin.time = baseTime
1206
+ individualSkin.update(0)
1207
+ }
1208
+
1209
+ // Update instance data
1210
+ individualGeometry.instanceCount = 1
1211
+ individualGeometry.instanceData.set(entity._matrix, 0)
1212
+ individualGeometry.instanceData[16] = entity._bsphere.center[0]
1213
+ individualGeometry.instanceData[17] = entity._bsphere.center[1]
1214
+ individualGeometry.instanceData[18] = entity._bsphere.center[2]
1215
+ individualGeometry.instanceData[19] = entity._bsphere.radius
1216
+ // uvTransform: default full texture
1217
+ const uvTransform = entity._uvTransform || [0, 0, 1, 1]
1218
+ individualGeometry.instanceData[20] = uvTransform[0]
1219
+ individualGeometry.instanceData[21] = uvTransform[1]
1220
+ individualGeometry.instanceData[22] = uvTransform[2]
1221
+ individualGeometry.instanceData[23] = uvTransform[3]
1222
+ // color: default white
1223
+ const color = entity.color || [1, 1, 1, 1]
1224
+ individualGeometry.instanceData[24] = color[0]
1225
+ individualGeometry.instanceData[25] = color[1]
1226
+ individualGeometry.instanceData[26] = color[2]
1227
+ individualGeometry.instanceData[27] = color[3]
1228
+ individualGeometry._instanceDataDirty = true
1229
+
1230
+ // Register in meshes dict
1231
+ const meshName = `individual_${entityId}`
1232
+ meshes[meshName] = individualMesh
1233
+ updatedMeshes.add(meshName)
1234
+ }
1235
+
1236
+ // Process instanced skinned entities (far from camera, phase-grouped)
1237
+ for (const [key, group] of skinnedInstancedGroups) {
1238
+ const { modelId, animation, phase, asset, entities } = group
1239
+
1240
+ // Get or create cloned skin and geometry wrapper for this phase group
1241
+ let cached = this._skinnedPhaseCache.get(key)
1242
+ if (!cached) {
1243
+ const clonedSkin = asset.skin.clone()
1244
+ clonedSkin.currentAnimation = animation
1245
+
1246
+ const originalGeom = asset.mesh.geometry
1247
+ const phaseGeomUid = `phase_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
1248
+
1249
+ const phaseGeometry = {
1250
+ uid: phaseGeomUid,
1251
+ vertexBuffer: originalGeom.vertexBuffer,
1252
+ indexBuffer: originalGeom.indexBuffer,
1253
+ vertexBufferLayout: originalGeom.vertexBufferLayout,
1254
+ instanceBufferLayout: originalGeom.instanceBufferLayout,
1255
+ vertexCount: originalGeom.vertexCount,
1256
+ indexArray: originalGeom.indexArray,
1257
+ attributes: originalGeom.attributes,
1258
+ maxInstances: 64,
1259
+ instanceCount: 0,
1260
+ instanceData: new Float32Array(28 * 64), // 28 floats per instance
1261
+ instanceBuffer: device.createBuffer({
1262
+ size: 112 * 64, // 112 bytes per instance
1263
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1264
+ }),
1265
+ _instanceDataDirty: true,
1266
+ growInstanceBuffer(minCapacity) {
1267
+ let newMax = this.maxInstances * 2
1268
+ while (newMax < minCapacity) newMax *= 2
1269
+ const newBuffer = device.createBuffer({
1270
+ size: 112 * newMax, // 112 bytes per instance
1271
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1272
+ })
1273
+ const newData = new Float32Array(28 * newMax) // 28 floats per instance
1274
+ newData.set(this.instanceData)
1275
+ this.instanceBuffer.destroy()
1276
+ this.instanceBuffer = newBuffer
1277
+ this.instanceData = newData
1278
+ this.maxInstances = newMax
1279
+ this._instanceDataDirty = true
1280
+ },
1281
+ writeInstanceBuffer() {
1282
+ device.queue.writeBuffer(this.instanceBuffer, 0, this.instanceData)
1283
+ },
1284
+ update() {
1285
+ if (this._instanceDataDirty) {
1286
+ this.writeInstanceBuffer()
1287
+ this._instanceDataDirty = false
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ const phaseMesh = {
1293
+ geometry: phaseGeometry,
1294
+ material: asset.mesh.material,
1295
+ skin: clonedSkin,
1296
+ hasSkin: true,
1297
+ uid: asset.mesh.uid + '_phase_' + key.replace(/[^a-zA-Z0-9]/g, '_'),
1298
+ // Use asset's combined bsphere for culling (all skinned submeshes share one sphere)
1299
+ combinedBsphere: asset.bsphere || null
1300
+ }
1301
+
1302
+ cached = { skin: clonedSkin, mesh: phaseMesh, geometry: phaseGeometry }
1303
+ this._skinnedPhaseCache.set(key, cached)
1304
+ }
1305
+
1306
+ const { skin: clonedSkin, mesh: phaseMesh, geometry: phaseGeometry } = cached
1307
+
1308
+ const meshName = `skinned_${key.replace(/[^a-zA-Z0-9]/g, '_')}`
1309
+ meshes[meshName] = phaseMesh
1310
+
1311
+ const anim = clonedSkin.animations[animation]
1312
+ if (!anim) continue
1313
+
1314
+ const phaseOffset = phase * anim.duration
1315
+ clonedSkin.currentAnimation = animation
1316
+ clonedSkin.updateAtTime(globalTime + phaseOffset)
1317
+
1318
+ phaseGeometry.instanceCount = 0
1319
+
1320
+ for (const item of entities) {
1321
+ const entity = item.entity
1322
+ const idx = phaseGeometry.instanceCount
1323
+
1324
+ if (idx >= phaseGeometry.maxInstances) {
1325
+ phaseGeometry.growInstanceBuffer(entities.length)
1326
+ }
1327
+
1328
+ phaseGeometry.instanceCount++
1329
+ const base = idx * 28
1330
+ phaseGeometry.instanceData.set(entity._matrix, base)
1331
+ phaseGeometry.instanceData[base + 16] = entity._bsphere.center[0]
1332
+ phaseGeometry.instanceData[base + 17] = entity._bsphere.center[1]
1333
+ phaseGeometry.instanceData[base + 18] = entity._bsphere.center[2]
1334
+ phaseGeometry.instanceData[base + 19] = entity._bsphere.radius
1335
+ // uvTransform: default full texture
1336
+ const uvTransform = entity._uvTransform || [0, 0, 1, 1]
1337
+ phaseGeometry.instanceData[base + 20] = uvTransform[0]
1338
+ phaseGeometry.instanceData[base + 21] = uvTransform[1]
1339
+ phaseGeometry.instanceData[base + 22] = uvTransform[2]
1340
+ phaseGeometry.instanceData[base + 23] = uvTransform[3]
1341
+ // color: default white
1342
+ const color = entity.color || [1, 1, 1, 1]
1343
+ phaseGeometry.instanceData[base + 24] = color[0]
1344
+ phaseGeometry.instanceData[base + 25] = color[1]
1345
+ phaseGeometry.instanceData[base + 26] = color[2]
1346
+ phaseGeometry.instanceData[base + 27] = color[3]
1347
+ }
1348
+
1349
+ phaseGeometry._instanceDataDirty = true
1350
+ updatedMeshes.add(meshName)
1351
+ }
1352
+
1353
+ // Add sprite-only entities (entities with .sprite but no .model) to spriteGroups
1354
+ for (const item of spriteOnlyEntities) {
1355
+ const entity = item.entity
1356
+ const spriteInfo = this.spriteSystem.parseSprite(entity.sprite)
1357
+ if (!spriteInfo) continue
1358
+
1359
+ // Compute UV transform for sprite-only entities
1360
+ const instanceData = this.spriteSystem.getSpriteInstanceData(entity)
1361
+ entity._uvTransform = instanceData.uvTransform
1362
+
1363
+ // Group by material key (same format as model+sprite entities)
1364
+ const pivot = entity.pivot || 'center'
1365
+ const roughness = entity.roughness ?? 0.7
1366
+ const materialKey = `sprite:${spriteInfo.url}:${pivot}:r${roughness.toFixed(2)}`
1367
+
1368
+ if (!spriteGroups.has(materialKey)) {
1369
+ spriteGroups.set(materialKey, {
1370
+ entities: [],
1371
+ spriteInfo,
1372
+ pivot,
1373
+ roughness
1374
+ })
1375
+ }
1376
+ spriteGroups.get(materialKey).entities.push(item)
1377
+ }
1378
+
1379
+ // Process sprite entities (billboard quads)
1380
+ // This is done synchronously - sprite assets are cached after first load
1381
+ for (const [materialKey, group] of spriteGroups) {
1382
+ const { entities, spriteInfo, pivot, roughness } = group
1383
+
1384
+ // Get or create sprite mesh (async on first call, cached thereafter)
1385
+ // Note: We use a simple approach - if the asset isn't loaded yet, skip this frame
1386
+ const meshName = `sprite_${materialKey.replace(/[^a-zA-Z0-9]/g, '_')}`
1387
+
1388
+ let spriteMesh = meshes[meshName]
1389
+ if (!spriteMesh) {
1390
+ // Check if material is ready
1391
+ const material = this.spriteSystem._materialCache.get(materialKey)
1392
+
1393
+ if (!material) {
1394
+ // Material not loaded yet - trigger async load and skip this frame
1395
+ this.spriteSystem.getSpriteMaterial(spriteInfo.url, roughness, pivot)
1396
+ continue
1397
+ }
1398
+
1399
+ // Create a NEW geometry for each sprite batch (not shared)
1400
+ // This is needed because each batch has its own instance data
1401
+ const geometry = Geometry.billboardQuad(this.engine, pivot)
1402
+
1403
+ // Create mesh with sprite geometry and material
1404
+ spriteMesh = {
1405
+ geometry: geometry,
1406
+ material: material,
1407
+ hasSkin: false,
1408
+ uid: meshName
1409
+ }
1410
+ meshes[meshName] = spriteMesh
1411
+ }
1412
+
1413
+ const geometry = spriteMesh.geometry
1414
+
1415
+ // Reset instance count for this frame
1416
+ geometry.instanceCount = 0
1417
+
1418
+ // Ensure geometry has instance buffer large enough
1419
+ if (entities.length > geometry.maxInstances) {
1420
+ geometry.growInstanceBuffer(entities.length)
1421
+ }
1422
+
1423
+ // Add sprite instances
1424
+ for (const item of entities) {
1425
+ const entity = item.entity
1426
+ const idx = geometry.instanceCount
1427
+
1428
+ geometry.instanceCount++
1429
+ const base = idx * 28
1430
+ geometry.instanceData.set(entity._matrix, base)
1431
+
1432
+ // Bounding sphere (use scale for radius approximation)
1433
+ const center = entity.position || [0, 0, 0]
1434
+ const radius = Math.max(entity.scale?.[0] || 1, entity.scale?.[1] || 1) * 0.5
1435
+ geometry.instanceData[base + 16] = center[0]
1436
+ geometry.instanceData[base + 17] = center[1]
1437
+ geometry.instanceData[base + 18] = center[2]
1438
+ geometry.instanceData[base + 19] = entity.noRounding ? -radius : radius
1439
+
1440
+ // uvTransform from sprite frame
1441
+ const uvTransform = entity._uvTransform || [0, 0, 1, 1]
1442
+ geometry.instanceData[base + 20] = uvTransform[0]
1443
+ geometry.instanceData[base + 21] = uvTransform[1]
1444
+ geometry.instanceData[base + 22] = uvTransform[2]
1445
+ geometry.instanceData[base + 23] = uvTransform[3]
1446
+
1447
+ // color tint
1448
+ const color = entity.color || [1, 1, 1, 1]
1449
+ geometry.instanceData[base + 24] = color[0]
1450
+ geometry.instanceData[base + 25] = color[1]
1451
+ geometry.instanceData[base + 26] = color[2]
1452
+ geometry.instanceData[base + 27] = color[3]
1453
+ }
1454
+
1455
+ geometry._instanceDataDirty = true
1456
+ updatedMeshes.add(meshName)
1457
+ }
1458
+ }
1459
+
1460
+ /**
1461
+ * Render a frame using the legacy mesh system (backward compatibility)
1462
+ *
1463
+ * @param {Object} meshes - Dictionary of meshes
1464
+ * @param {Camera} camera - Current camera
1465
+ * @param {number} dt - Delta time
1466
+ */
1467
+ async render(meshes, camera, dt = 0) {
1468
+ const { stats } = this.engine
1469
+
1470
+ stats.drawCalls = 0
1471
+ stats.triangles = 0
1472
+
1473
+ const passContext = {
1474
+ camera,
1475
+ meshes,
1476
+ dt
1477
+ }
1478
+
1479
+ // Pass 4: GBuffer
1480
+ await this.passes.gbuffer.execute(passContext)
1481
+
1482
+ // Pass 6: Lighting
1483
+ await this.passes.lighting.execute(passContext)
1484
+
1485
+ // Pass 7: PostProcess
1486
+ await this.passes.postProcess.execute(passContext)
1487
+
1488
+ this.stats.drawCalls = stats.drawCalls
1489
+ this.stats.triangles = stats.triangles
1490
+ }
1491
+
1492
+ /**
1493
+ * Handle window resize
1494
+ * @param {number} width - Canvas width (full device pixels)
1495
+ * @param {number} height - Canvas height (full device pixels)
1496
+ * @param {number} renderScale - Scale for internal rendering (1.0 = full resolution)
1497
+ */
1498
+ async resize(width, height, renderScale = 1.0) {
1499
+ const timings = []
1500
+ const startTotal = performance.now()
1501
+
1502
+ // Store full canvas dimensions (for CRT pixel-perfect output)
1503
+ this.canvasWidth = width
1504
+ this.canvasHeight = height
1505
+
1506
+ // Calculate internal render dimensions (scaled for performance)
1507
+ const renderWidth = Math.max(1, Math.round(width * renderScale))
1508
+ const renderHeight = Math.max(1, Math.round(height * renderScale))
1509
+
1510
+ // Store render dimensions
1511
+ this.renderWidth = renderWidth
1512
+ this.renderHeight = renderHeight
1513
+ this.renderScale = renderScale
1514
+
1515
+ // Calculate effect scale for expensive passes
1516
+ // When autoScale.enabled is false but enabledForEffects is true and height > maxHeight,
1517
+ // expensive effects (bloom, AO, SSGI, planar reflection) render at reduced resolution
1518
+ const autoScale = this.engine?.settings?.rendering?.autoScale
1519
+ let effectScale = 1.0
1520
+
1521
+ if (autoScale && !autoScale.enabled && autoScale.enabledForEffects) {
1522
+ if (renderHeight > (autoScale.maxHeight ?? 1536)) {
1523
+ effectScale = autoScale.scaleFactor ?? 0.5
1524
+ if (!this._effectScaleWarned) {
1525
+ console.log(`Effect auto-scale: Reducing effect resolution by ${effectScale} (height: ${renderHeight}px > ${autoScale.maxHeight}px)`)
1526
+ this._effectScaleWarned = true
1527
+ }
1528
+ } else if (this._effectScaleWarned) {
1529
+ console.log(`Effect auto-scale: Restoring full effect resolution (height: ${renderHeight}px <= ${autoScale.maxHeight}px)`)
1530
+ this._effectScaleWarned = false
1531
+ }
1532
+ }
1533
+
1534
+ // Passes that render at full canvas resolution (for pixel-perfect output)
1535
+ const fullResPasses = new Set(['crt'])
1536
+
1537
+ // Expensive passes that should be scaled down at high resolutions
1538
+ // Note: ssgiTile and ssgi must be at the same resolution (they share tile grid)
1539
+ // Note: planarReflection combines effectScale with its own resolution setting
1540
+ const effectScaledPasses = new Set(['bloom', 'ao', 'planarReflection'])
1541
+
1542
+ // Calculate scaled dimensions for expensive effects (relative to render dimensions)
1543
+ const effectWidth = Math.max(1, Math.floor(renderWidth * effectScale))
1544
+ const effectHeight = Math.max(1, Math.floor(renderHeight * effectScale))
1545
+
1546
+ // Store effect dimensions for use in rendering
1547
+ this.effectWidth = effectWidth
1548
+ this.effectHeight = effectHeight
1549
+ this.effectScale = effectScale
1550
+
1551
+ // Resize all passes
1552
+ for (const passName in this.passes) {
1553
+ if (this.passes[passName]) {
1554
+ const start = performance.now()
1555
+ let w, h
1556
+ if (fullResPasses.has(passName)) {
1557
+ // CRT and similar passes render at full canvas resolution
1558
+ w = width
1559
+ h = height
1560
+ } else if (effectScaledPasses.has(passName) && effectScale < 1.0) {
1561
+ // Expensive effects use effect-scaled dimensions
1562
+ w = effectWidth
1563
+ h = effectHeight
1564
+ } else {
1565
+ // All other passes use render-scaled dimensions
1566
+ w = renderWidth
1567
+ h = renderHeight
1568
+ }
1569
+ await this.passes[passName].resize(w, h)
1570
+ timings.push({ name: `pass:${passName}`, time: performance.now() - start })
1571
+ }
1572
+ }
1573
+
1574
+ // Resize history buffer manager (uses render dimensions)
1575
+ if (this.historyManager) {
1576
+ const start = performance.now()
1577
+ await this.historyManager.resize(renderWidth, renderHeight)
1578
+ timings.push({ name: 'historyManager', time: performance.now() - start })
1579
+ }
1580
+
1581
+ // Rewire dependencies after resize
1582
+ let start = performance.now()
1583
+ await this.passes.lighting.setGBuffer(this.passes.gbuffer.getGBuffer())
1584
+ timings.push({ name: 'rewire:lighting.setGBuffer', time: performance.now() - start })
1585
+
1586
+ start = performance.now()
1587
+ this.passes.lighting.setShadowPass(this.passes.shadow)
1588
+ timings.push({ name: 'rewire:lighting.setShadowPass', time: performance.now() - start })
1589
+
1590
+ start = performance.now()
1591
+ this.passes.gbuffer.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
1592
+ timings.push({ name: 'rewire:gbuffer.setNoise', time: performance.now() - start })
1593
+
1594
+ // Rewire HiZ pass with new GBuffer depth and all passes that use it
1595
+ start = performance.now()
1596
+ if (this.passes.hiz) {
1597
+ this.passes.hiz.setDepthTexture(this.passes.gbuffer.getGBuffer()?.depth)
1598
+ this.passes.gbuffer.setHiZPass(this.passes.hiz)
1599
+ this.passes.lighting.setHiZPass(this.passes.hiz)
1600
+ this.passes.transparent.setHiZPass(this.passes.hiz)
1601
+ this.passes.shadow.setHiZPass(this.passes.hiz)
1602
+ if (this.passes.volumetricFog) {
1603
+ this.passes.volumetricFog.setHiZPass(this.passes.hiz)
1604
+ }
1605
+ }
1606
+ timings.push({ name: 'rewire:hiz', time: performance.now() - start })
1607
+
1608
+ // Rewire volumetric fog pass
1609
+ if (this.passes.volumetricFog) {
1610
+ start = performance.now()
1611
+ this.passes.volumetricFog.setGBuffer(this.passes.gbuffer.getGBuffer())
1612
+ this.passes.volumetricFog.setShadowPass(this.passes.shadow)
1613
+ this.passes.volumetricFog.setLightingPass(this.passes.lighting)
1614
+ timings.push({ name: 'rewire:volumetricFog', time: performance.now() - start })
1615
+ }
1616
+
1617
+ start = performance.now()
1618
+ this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
1619
+ timings.push({ name: 'rewire:shadow.setNoise', time: performance.now() - start })
1620
+
1621
+ start = performance.now()
1622
+ await this.passes.ao.setGBuffer(this.passes.gbuffer.getGBuffer())
1623
+ timings.push({ name: 'rewire:ao.setGBuffer', time: performance.now() - start })
1624
+
1625
+ start = performance.now()
1626
+ this.passes.lighting.setAOTexture(this.passes.ao.getOutputTexture())
1627
+ timings.push({ name: 'rewire:lighting.setAOTexture', time: performance.now() - start })
1628
+
1629
+ start = performance.now()
1630
+ this.passes.postProcess.setInputTexture(this.passes.lighting.getOutputTexture())
1631
+ timings.push({ name: 'rewire:postProcess.setInputTexture', time: performance.now() - start })
1632
+
1633
+ start = performance.now()
1634
+ this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
1635
+ timings.push({ name: 'rewire:postProcess.setNoise', time: performance.now() - start })
1636
+
1637
+ // Rewire transparent pass
1638
+ start = performance.now()
1639
+ this.passes.transparent.setGBuffer(this.passes.gbuffer.getGBuffer())
1640
+ this.passes.transparent.setShadowPass(this.passes.shadow)
1641
+ this.passes.transparent.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
1642
+ timings.push({ name: 'rewire:transparent', time: performance.now() - start })
1643
+
1644
+ // Rewire particle pass
1645
+ start = performance.now()
1646
+ this.passes.particles.setGBuffer(this.passes.gbuffer.getGBuffer())
1647
+ timings.push({ name: 'rewire:particles', time: performance.now() - start })
1648
+
1649
+ // SSGI passes are wired dynamically per frame (no static rewire needed)
1650
+
1651
+ }
1652
+
1653
+ /**
1654
+ * Update environment map
1655
+ * @param {Texture} environmentMap - New environment map
1656
+ * @param {number} encoding - 0 = equirectangular (default), 1 = octahedral
1657
+ */
1658
+ setEnvironmentMap(environmentMap, encoding = 0) {
1659
+ this.environmentMap = environmentMap
1660
+ this.environmentEncoding = encoding
1661
+ this.passes.lighting.setEnvironmentMap(environmentMap, encoding)
1662
+ if (this.passes.reflection) {
1663
+ this.passes.reflection.setFallbackEnvironment(environmentMap, encoding)
1664
+ }
1665
+ if (this.passes.transparent) {
1666
+ this.passes.transparent.setEnvironmentMap(environmentMap, encoding)
1667
+ }
1668
+ if (this.passes.ambientCapture) {
1669
+ this.passes.ambientCapture.setDependencies({
1670
+ environmentMap,
1671
+ encoding
1672
+ })
1673
+ }
1674
+ }
1675
+
1676
+ /**
1677
+ * Load reflection probes for a world
1678
+ * @param {string} worldId - World identifier
1679
+ */
1680
+ async loadWorldProbes(worldId) {
1681
+ if (this.passes.reflection) {
1682
+ await this.passes.reflection.loadWorldProbes(worldId)
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * Load a specific reflection probe
1688
+ * @param {string} url - URL to probe HDR image
1689
+ * @param {vec3} position - World position
1690
+ * @param {string} worldId - World identifier
1691
+ */
1692
+ async loadProbe(url, position, worldId = 'default') {
1693
+ if (this.passes.reflection) {
1694
+ return await this.passes.reflection.loadProbe(url, position, worldId)
1695
+ }
1696
+ return null
1697
+ }
1698
+
1699
+ /**
1700
+ * Request a probe capture at position
1701
+ * @param {vec3} position - Capture position
1702
+ * @param {string} worldId - World identifier
1703
+ */
1704
+ requestProbeCapture(position, worldId = 'default') {
1705
+ if (this.passes.reflection) {
1706
+ this.passes.reflection.requestCapture(position, worldId)
1707
+ }
1708
+ }
1709
+
1710
+ /**
1711
+ * Get the reflection probe manager
1712
+ */
1713
+ getProbeManager() {
1714
+ return this.passes.reflection?.getProbeManager()
1715
+ }
1716
+
1717
+ /**
1718
+ * Capture a probe at position and optionally save/use it
1719
+ * @param {vec3} position - Capture position
1720
+ * @param {Object} options - { save: bool, filename: string, format: 'hdr'|'jpg', useAsEnvironment: bool, saveDebug: bool, saveFaces: bool }
1721
+ */
1722
+ async captureProbe(position, options = {}) {
1723
+ const {
1724
+ save = true,
1725
+ filename = 'probe', // Base filename without extension
1726
+ format = 'jpg', // 'hdr' or 'jpg' (jpg = RGB + exp pair)
1727
+ useAsEnvironment = false,
1728
+ saveDebug = false, // Save tone-mapped PNG for preview
1729
+ saveFaces = false
1730
+ } = options
1731
+ const probeCapture = this.passes.reflection?.getProbeCapture()
1732
+
1733
+ if (!probeCapture) {
1734
+ console.error('RenderGraph: ProbeCapture not initialized')
1735
+ return
1736
+ }
1737
+
1738
+ // CRITICAL: Pause main render loop during probe capture
1739
+ // The main render modifies mesh.geometry.instanceCount on shared mesh objects
1740
+ // which corrupts the probe capture data
1741
+ this._isCapturingProbe = true
1742
+
1743
+ try {
1744
+ // Clear probe pass pipeline caches to ensure fresh creation
1745
+ // This fixes issues where pipelines from previous captures may be stale
1746
+ if (this.probePasses.gbuffer) {
1747
+ this.probePasses.gbuffer.pipelines.clear()
1748
+ this.probePasses.gbuffer.skinnedPipelines.clear()
1749
+ }
1750
+
1751
+ // Clear probe meshes dictionary to avoid stale entries
1752
+ this._probeMeshes = {}
1753
+
1754
+ await probeCapture.capture(position)
1755
+
1756
+ // Save cube faces for debugging (before octahedral conversion)
1757
+ if (saveFaces) {
1758
+ await probeCapture.saveCubeFaces('face')
1759
+ }
1760
+
1761
+ if (save) {
1762
+ if (format === 'hdr') {
1763
+ // Save as Radiance HDR file
1764
+ await probeCapture.saveAsHDR(`${filename}.hdr`)
1765
+ } else {
1766
+ // Save as JPG pair (RGB + exponent)
1767
+ await probeCapture.saveAsJPG(filename)
1768
+ }
1769
+ }
1770
+
1771
+ if (saveDebug) {
1772
+ // Save tone-mapped PNG for preview/debugging
1773
+ await probeCapture.saveAsDebugPNG(`${filename}_debug.png`)
1774
+ }
1775
+
1776
+ if (useAsEnvironment) {
1777
+ const envTex = await probeCapture.getAsEnvironmentTexture()
1778
+ if (envTex) {
1779
+ this.passes.lighting.setEnvironmentMap(envTex, 1) // 1 = octahedral encoding
1780
+ console.log('RenderGraph: Using captured probe as environment (octahedral, RGBE)')
1781
+ }
1782
+ }
1783
+
1784
+ return probeCapture.getProbeTexture()
1785
+ } finally {
1786
+ // Always reset the flag, even if capture fails
1787
+ this._isCapturingProbe = false
1788
+ }
1789
+ }
1790
+
1791
+ /**
1792
+ * Convert an equirectangular HDR environment map to octahedral format
1793
+ * and save as RGBI JPG pair for efficient storage
1794
+ *
1795
+ * @param {Object} options - Conversion options
1796
+ * @param {string} options.url - URL to HDR file (uses current environment if not provided)
1797
+ * @param {string} options.filename - Base filename without extension (default: 'environment')
1798
+ * @param {boolean} options.useAsEnvironment - Set converted map as active environment (default: true)
1799
+ * @param {boolean} options.saveDebug - Also save tone-mapped PNG for preview (default: false)
1800
+ * @returns {Promise<Object>} The converted texture
1801
+ */
1802
+ async convertEquirectToOctahedral(options = {}) {
1803
+ const {
1804
+ url = null,
1805
+ filename = 'environment',
1806
+ useAsEnvironment = true,
1807
+ saveDebug = false
1808
+ } = options
1809
+
1810
+ const probeCapture = this.passes.reflection?.getProbeCapture()
1811
+
1812
+ if (!probeCapture) {
1813
+ console.error('RenderGraph: ProbeCapture not initialized')
1814
+ return null
1815
+ }
1816
+
1817
+ // Load HDR file if URL provided, otherwise use current environment
1818
+ let sourceEnvMap = this.environmentMap
1819
+ if (url) {
1820
+ console.log(`RenderGraph: Loading HDR from ${url}`)
1821
+ sourceEnvMap = await Texture.fromImage(this.engine, url)
1822
+ }
1823
+
1824
+ if (!sourceEnvMap) {
1825
+ console.error('RenderGraph: No environment map available')
1826
+ return null
1827
+ }
1828
+
1829
+ console.log('RenderGraph: Converting equirectangular to octahedral format...')
1830
+
1831
+ // Convert equirectangular to octahedral
1832
+ await probeCapture.convertEquirectToOctahedral(sourceEnvMap)
1833
+
1834
+ // Save as RGBI JPG pair
1835
+ await probeCapture.saveAsJPG(filename)
1836
+
1837
+ if (saveDebug) {
1838
+ // Save tone-mapped PNG for preview
1839
+ await probeCapture.saveAsDebugPNG(`${filename}_debug.png`)
1840
+ }
1841
+
1842
+ // Optionally set as environment
1843
+ if (useAsEnvironment) {
1844
+ const envTex = await probeCapture.getAsEnvironmentTexture()
1845
+ if (envTex) {
1846
+ this.passes.lighting.setEnvironmentMap(envTex, 1) // 1 = octahedral encoding
1847
+ console.log('RenderGraph: Using converted environment (octahedral, RGBE)')
1848
+ }
1849
+ }
1850
+
1851
+ console.log(`RenderGraph: Saved octahedral environment as ${filename}.jpg + ${filename}.int.jpg`)
1852
+ return probeCapture.getProbeTexture()
1853
+ }
1854
+
1855
+ /**
1856
+ * Initialize probe-specific passes at fixed 256x256 size
1857
+ * These are used for probe face capture (smaller than main passes)
1858
+ */
1859
+ async _initProbePasses() {
1860
+ const { device } = this.engine
1861
+ const probeSize = 1024
1862
+
1863
+ // Create probe GBuffer pass
1864
+ this.probePasses.gbuffer = new GBufferPass(this.engine)
1865
+ await this.probePasses.gbuffer.initialize()
1866
+ await this.probePasses.gbuffer.resize(probeSize, probeSize)
1867
+
1868
+ // Create probe Lighting pass
1869
+ this.probePasses.lighting = new LightingPass(this.engine)
1870
+ await this.probePasses.lighting.initialize()
1871
+ await this.probePasses.lighting.resize(probeSize, probeSize)
1872
+ // Use exposure = 1.0 for probe capture (raw HDR values, no display exposure)
1873
+ this.probePasses.lighting.exposureOverride = 1.0
1874
+
1875
+ // Create a simple white AO texture for probes (skip AO computation)
1876
+ const dummyAOTexture = device.createTexture({
1877
+ label: 'probeAO',
1878
+ size: [probeSize, probeSize],
1879
+ format: 'r8unorm',
1880
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST
1881
+ })
1882
+ // Fill with white (AO = 1.0 = no occlusion)
1883
+ const whiteData = new Uint8Array(probeSize * probeSize).fill(255)
1884
+ device.queue.writeTexture(
1885
+ { texture: dummyAOTexture },
1886
+ whiteData,
1887
+ { bytesPerRow: probeSize },
1888
+ { width: probeSize, height: probeSize }
1889
+ )
1890
+ this.probePasses.dummyAO = {
1891
+ texture: dummyAOTexture,
1892
+ view: dummyAOTexture.createView()
1893
+ }
1894
+
1895
+ // Wire up probe passes
1896
+ await this.probePasses.lighting.setGBuffer(this.probePasses.gbuffer.getGBuffer())
1897
+ this.probePasses.lighting.setEnvironmentMap(this.environmentMap, this.environmentEncoding)
1898
+ this.probePasses.lighting.setShadowPass(this.passes.shadow) // Share shadow pass
1899
+ this.probePasses.lighting.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
1900
+ this.probePasses.lighting.setAOTexture(this.probePasses.dummyAO)
1901
+ }
1902
+
1903
+ /**
1904
+ * Render scene for a probe face
1905
+ * Called by ProbeCapture for each of the 6 cube faces
1906
+ *
1907
+ * @param {mat4} viewMatrix - View matrix for this face
1908
+ * @param {mat4} projMatrix - Projection matrix (90° FOV)
1909
+ * @param {Object} colorTarget - Target texture object with .texture and .view
1910
+ * @param {Object} depthTarget - Depth texture object with .texture and .view
1911
+ * @param {number} faceIndex - Which face (0-5) for debugging
1912
+ * @param {vec3} position - Capture position
1913
+ */
1914
+ async _renderSceneForProbe(viewMatrix, projMatrix, colorTarget, depthTarget, faceIndex, position) {
1915
+ if (!this._lastRenderContext) {
1916
+ console.warn('RenderGraph: No render context available for probe capture')
1917
+ return
1918
+ }
1919
+
1920
+ if (!this.probePasses.gbuffer || !this.probePasses.lighting) {
1921
+ console.warn('RenderGraph: Probe passes not initialized')
1922
+ return
1923
+ }
1924
+
1925
+ const { device } = this.engine
1926
+ const { entityManager, assetManager } = this._lastRenderContext
1927
+
1928
+ // CRITICAL: Use a SEPARATE meshes dictionary for probe capture
1929
+ // The main render loop (via requestAnimationFrame) can overwrite instance counts
1930
+ // in the shared meshes dictionary while probe capture is running
1931
+ if (!this._probeMeshes) {
1932
+ this._probeMeshes = {}
1933
+ }
1934
+ const meshes = this._probeMeshes
1935
+
1936
+ // Build entity list for probe capture (ALL entities, no frustum culling)
1937
+ const probeEntities = []
1938
+ entityManager.forEach((id, entity) => {
1939
+ if (entity.model) {
1940
+ probeEntities.push({ id, entity, distance: 0 })
1941
+ }
1942
+ })
1943
+ const probeGroups = this.cullingSystem.groupByModel(probeEntities)
1944
+
1945
+ // Update meshes dictionary with ALL entities for probe rendering
1946
+ this._updateMeshInstancesFromEntities(probeGroups, assetManager, meshes, true, null, 0, null)
1947
+
1948
+ // Ensure all geometry buffers are written to GPU before rendering
1949
+ for (const name in meshes) {
1950
+ const mesh = meshes[name]
1951
+ if (mesh?.geometry?.update) {
1952
+ mesh.geometry.update()
1953
+ }
1954
+ }
1955
+
1956
+ // Wait for GPU to complete buffer writes before rendering each face
1957
+ await device.queue.onSubmittedWorkDone()
1958
+
1959
+ // Create inverse matrices for lighting calculations
1960
+ const iView = mat4.create()
1961
+ const iProj = mat4.create()
1962
+ const iViewProj = mat4.create()
1963
+ const viewProj = mat4.create()
1964
+ mat4.invert(iView, viewMatrix)
1965
+ mat4.invert(iProj, projMatrix)
1966
+ mat4.multiply(viewProj, projMatrix, viewMatrix)
1967
+ mat4.invert(iViewProj, viewProj)
1968
+
1969
+ // Create a temporary camera-like object with the probe matrices
1970
+ // No jitter for probe capture - we want clean, stable environment maps
1971
+ const probeCamera = {
1972
+ view: viewMatrix,
1973
+ proj: projMatrix,
1974
+ iView,
1975
+ iProj,
1976
+ iViewProj,
1977
+ position: position || [0, 0, 0],
1978
+ near: 0.1,
1979
+ far: 10000,
1980
+ aspect: 1.0,
1981
+ jitterEnabled: false,
1982
+ jitterOffset: [0, 0],
1983
+ updateMatrix: () => {},
1984
+ updateView: () => {}
1985
+ }
1986
+
1987
+ // Execute probe GBuffer pass (uses meshes with ALL entity instances)
1988
+ await this.probePasses.gbuffer.execute({
1989
+ camera: probeCamera,
1990
+ meshes,
1991
+ dt: 0
1992
+ })
1993
+
1994
+ // Copy light data and environment from main lighting pass to probe pass
1995
+ this.probePasses.lighting.lights = this.passes.lighting.lights
1996
+ // Ensure probe lighting has current environment map with correct encoding
1997
+ if (this.environmentMap) {
1998
+ this.probePasses.lighting.setEnvironmentMap(this.environmentMap, this.environmentEncoding)
1999
+ }
2000
+
2001
+ // Execute probe Lighting pass
2002
+ await this.probePasses.lighting.execute({
2003
+ camera: probeCamera,
2004
+ meshes,
2005
+ dt: 0,
2006
+ lights: this.passes.lighting.lights,
2007
+ mainLight: this.engine?.settings?.mainLight
2008
+ })
2009
+
2010
+ // Copy lighting output to the probe face texture
2011
+ const lightingOutput = this.probePasses.lighting.getOutputTexture()
2012
+ const copySize = colorTarget.texture.width // Get size from target texture
2013
+
2014
+ if (lightingOutput && colorTarget.texture) {
2015
+ const commandEncoder = device.createCommandEncoder()
2016
+ commandEncoder.copyTextureToTexture(
2017
+ { texture: lightingOutput.texture },
2018
+ { texture: colorTarget.texture },
2019
+ { width: copySize, height: copySize }
2020
+ )
2021
+ device.queue.submit([commandEncoder.finish()])
2022
+ }
2023
+
2024
+ // Copy GBuffer depth to face depth texture (for skybox depth testing)
2025
+ const gbufferDepth = this.probePasses.gbuffer.getGBuffer()?.depth
2026
+ if (gbufferDepth && depthTarget.texture) {
2027
+ const commandEncoder = device.createCommandEncoder()
2028
+ commandEncoder.copyTextureToTexture(
2029
+ { texture: gbufferDepth.texture },
2030
+ { texture: depthTarget.texture },
2031
+ { width: copySize, height: copySize }
2032
+ )
2033
+ device.queue.submit([commandEncoder.finish()])
2034
+ }
2035
+ }
2036
+
2037
+ /**
2038
+ * Get culling system for external configuration
2039
+ */
2040
+ getCullingSystem() {
2041
+ return this.cullingSystem
2042
+ }
2043
+
2044
+ /**
2045
+ * Get instance manager for stats
2046
+ */
2047
+ getInstanceManager() {
2048
+ return this.instanceManager
2049
+ }
2050
+
2051
+ /**
2052
+ * Get sprite system for external access
2053
+ */
2054
+ getSpriteSystem() {
2055
+ return this.spriteSystem
2056
+ }
2057
+
2058
+ /**
2059
+ * Get particle system for external access
2060
+ */
2061
+ getParticleSystem() {
2062
+ return this.particleSystem
2063
+ }
2064
+
2065
+ /**
2066
+ * Get render stats
2067
+ */
2068
+ getStats() {
2069
+ return {
2070
+ ...this.stats,
2071
+ instance: this.instanceManager.getStats(),
2072
+ culling: this.cullingSystem.getStats(),
2073
+ occlusion: this.cullingSystem.getOcclusionStats()
2074
+ }
2075
+ }
2076
+
2077
+ /**
2078
+ * Enable/disable a specific pass
2079
+ * @param {string} passName - Name of pass ('gbuffer', 'lighting', 'postProcess')
2080
+ * @param {boolean} enabled - Whether to enable
2081
+ */
2082
+ setPassEnabled(passName, enabled) {
2083
+ if (this.passes[passName]) {
2084
+ this.passes[passName].enabled = enabled
2085
+ }
2086
+ }
2087
+
2088
+ /**
2089
+ * Get a specific pass for configuration
2090
+ * @param {string} passName - Name of pass
2091
+ * @returns {BasePass} The pass instance
2092
+ */
2093
+ getPass(passName) {
2094
+ return this.passes[passName]
2095
+ }
2096
+
2097
+ /**
2098
+ * Invalidate occlusion culling data and reset warmup period.
2099
+ * Call this after scene loading or major camera changes to prevent
2100
+ * incorrect occlusion culling with stale depth buffer data.
2101
+ */
2102
+ invalidateOcclusionCulling() {
2103
+ if (this.passes.hiz) {
2104
+ this.passes.hiz.invalidate()
2105
+ }
2106
+ }
2107
+
2108
+ /**
2109
+ * Load noise texture based on settings
2110
+ * Supports: 'bluenoise' (loaded from file), 'bayer8' (generated 8x8 ordered dither)
2111
+ */
2112
+ async _loadNoiseTexture() {
2113
+ const noiseSettings = this.engine?.settings?.noise || { type: 'bluenoise', animated: true }
2114
+ this.noiseAnimated = noiseSettings.animated !== false
2115
+
2116
+ if (noiseSettings.type === 'bayer8') {
2117
+ // Create 8x8 Bayer ordered dither pattern
2118
+ this.noiseTexture = this._createBayerTexture()
2119
+ this.noiseSize = 8
2120
+ console.log('RenderGraph: Using Bayer 8x8 dither pattern')
2121
+ } else {
2122
+ // Default: load blue noise
2123
+ try {
2124
+ this.noiseTexture = await Texture.fromImage(this.engine, '/bluenoise.png', {
2125
+ flipY: false,
2126
+ srgb: false, // Linear data
2127
+ generateMips: false,
2128
+ addressMode: 'repeat' // Tile across screen
2129
+ })
2130
+ if (this.noiseTexture.width) {
2131
+ this.noiseSize = this.noiseTexture.width
2132
+ }
2133
+ } catch (e) {
2134
+ console.warn('RenderGraph: Failed to load blue noise texture:', e)
2135
+ }
2136
+ }
2137
+ }
2138
+
2139
+ /**
2140
+ * Create an 8x8 Bayer ordered dither texture
2141
+ * @returns {Object} Texture object with texture and view properties
2142
+ */
2143
+ _createBayerTexture() {
2144
+ const { device } = this.engine
2145
+
2146
+ // Bayer 8x8 matrix (values 0-63, we normalize to 0-1)
2147
+ const bayer8x8 = [
2148
+ 0, 32, 8, 40, 2, 34, 10, 42,
2149
+ 48, 16, 56, 24, 50, 18, 58, 26,
2150
+ 12, 44, 4, 36, 14, 46, 6, 38,
2151
+ 60, 28, 52, 20, 62, 30, 54, 22,
2152
+ 3, 35, 11, 43, 1, 33, 9, 41,
2153
+ 51, 19, 59, 27, 49, 17, 57, 25,
2154
+ 15, 47, 7, 39, 13, 45, 5, 37,
2155
+ 63, 31, 55, 23, 61, 29, 53, 21
2156
+ ]
2157
+
2158
+ // Create RGBA texture data (normalized to 0-255)
2159
+ const data = new Uint8Array(8 * 8 * 4)
2160
+ for (let i = 0; i < 64; i++) {
2161
+ const value = Math.round((bayer8x8[i] / 63) * 255)
2162
+ data[i * 4 + 0] = value // R
2163
+ data[i * 4 + 1] = value // G
2164
+ data[i * 4 + 2] = value // B
2165
+ data[i * 4 + 3] = 255 // A
2166
+ }
2167
+
2168
+ // Create GPU texture
2169
+ const texture = device.createTexture({
2170
+ label: 'Bayer 8x8 Dither',
2171
+ size: [8, 8, 1],
2172
+ format: 'rgba8unorm',
2173
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
2174
+ })
2175
+
2176
+ device.queue.writeTexture(
2177
+ { texture },
2178
+ data,
2179
+ { bytesPerRow: 8 * 4 },
2180
+ { width: 8, height: 8 }
2181
+ )
2182
+
2183
+ // Create sampler with repeat addressing for tiling
2184
+ const sampler = device.createSampler({
2185
+ label: 'Bayer 8x8 Sampler',
2186
+ addressModeU: 'repeat',
2187
+ addressModeV: 'repeat',
2188
+ magFilter: 'nearest',
2189
+ minFilter: 'nearest',
2190
+ })
2191
+
2192
+ return {
2193
+ texture,
2194
+ view: texture.createView(),
2195
+ sampler,
2196
+ width: 8,
2197
+ height: 8
2198
+ }
2199
+ }
2200
+
2201
+ /**
2202
+ * Reload noise texture and update all passes that use it
2203
+ * Called when noise settings change at runtime
2204
+ */
2205
+ async reloadNoiseTexture() {
2206
+ // Destroy old texture if it exists
2207
+ if (this.noiseTexture?.texture) {
2208
+ this.noiseTexture.texture.destroy()
2209
+ }
2210
+
2211
+ // Load new noise texture based on current settings
2212
+ await this._loadNoiseTexture()
2213
+
2214
+ // Update all passes that use noise
2215
+ if (this.passes.gbuffer) {
2216
+ this.passes.gbuffer.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2217
+ }
2218
+ if (this.passes.shadow) {
2219
+ this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2220
+ }
2221
+ if (this.passes.lighting) {
2222
+ this.passes.lighting.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2223
+ }
2224
+ if (this.passes.ao) {
2225
+ this.passes.ao.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2226
+ }
2227
+ if (this.passes.transparent) {
2228
+ this.passes.transparent.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2229
+ }
2230
+ if (this.passes.postProcess) {
2231
+ this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2232
+ }
2233
+ if (this.passes.renderPost) {
2234
+ this.passes.renderPost.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated)
2235
+ }
2236
+
2237
+ console.log(`RenderGraph: Reloaded noise texture (${this.engine?.settings?.noise?.type || 'bluenoise'})`)
2238
+ }
2239
+
2240
+ /**
2241
+ * Destroy all resources
2242
+ */
2243
+ destroy() {
2244
+ for (const passName in this.passes) {
2245
+ if (this.passes[passName]) {
2246
+ this.passes[passName].destroy()
2247
+ }
2248
+ }
2249
+ if (this.historyManager) {
2250
+ this.historyManager.destroy()
2251
+ }
2252
+ this.instanceManager.destroy()
2253
+ this.spriteSystem.destroy()
2254
+ this.particleSystem.destroy()
2255
+ }
2256
+ }
2257
+
2258
+ export { RenderGraph }