topazcube 0.1.30 → 0.1.33

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