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.
- package/LICENSE.txt +0 -0
- package/README.md +0 -0
- package/dist/Renderer.cjs +18200 -0
- package/dist/Renderer.cjs.map +1 -0
- package/dist/Renderer.js +18183 -0
- package/dist/Renderer.js.map +1 -0
- package/dist/client.cjs +94 -260
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +71 -215
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +165 -432
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +117 -370
- package/dist/server.js.map +1 -1
- package/dist/terminal.cjs +113 -200
- package/dist/terminal.cjs.map +1 -1
- package/dist/terminal.js +50 -51
- package/dist/terminal.js.map +1 -1
- package/dist/utils-CRhi1BDa.cjs +259 -0
- package/dist/utils-CRhi1BDa.cjs.map +1 -0
- package/dist/utils-D7tXt6-2.js +260 -0
- package/dist/utils-D7tXt6-2.js.map +1 -0
- package/package.json +19 -15
- package/src/{client.ts → network/client.js} +173 -403
- package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
- package/src/{compress-node.ts → network/compress-node.js} +8 -14
- package/src/{server.ts → network/server.js} +229 -317
- package/src/{terminal.js → network/terminal.js} +0 -0
- package/src/{topazcube.ts → network/topazcube.js} +2 -2
- package/src/network/utils.js +375 -0
- package/src/renderer/Camera.js +191 -0
- package/src/renderer/DebugUI.js +572 -0
- package/src/renderer/Geometry.js +1049 -0
- package/src/renderer/Material.js +61 -0
- package/src/renderer/Mesh.js +211 -0
- package/src/renderer/Node.js +112 -0
- package/src/renderer/Pipeline.js +643 -0
- package/src/renderer/Renderer.js +1324 -0
- package/src/renderer/Skin.js +792 -0
- package/src/renderer/Texture.js +584 -0
- package/src/renderer/core/AssetManager.js +359 -0
- package/src/renderer/core/CullingSystem.js +307 -0
- package/src/renderer/core/EntityManager.js +541 -0
- package/src/renderer/core/InstanceManager.js +343 -0
- package/src/renderer/core/ParticleEmitter.js +358 -0
- package/src/renderer/core/ParticleSystem.js +564 -0
- package/src/renderer/core/SpriteSystem.js +349 -0
- package/src/renderer/gltf.js +546 -0
- package/src/renderer/math.js +161 -0
- package/src/renderer/rendering/HistoryBufferManager.js +333 -0
- package/src/renderer/rendering/ProbeCapture.js +1495 -0
- package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
- package/src/renderer/rendering/RenderGraph.js +2064 -0
- package/src/renderer/rendering/passes/AOPass.js +308 -0
- package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
- package/src/renderer/rendering/passes/BasePass.js +101 -0
- package/src/renderer/rendering/passes/BloomPass.js +417 -0
- package/src/renderer/rendering/passes/FogPass.js +419 -0
- package/src/renderer/rendering/passes/GBufferPass.js +706 -0
- package/src/renderer/rendering/passes/HiZPass.js +714 -0
- package/src/renderer/rendering/passes/LightingPass.js +739 -0
- package/src/renderer/rendering/passes/ParticlePass.js +835 -0
- package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
- package/src/renderer/rendering/passes/PostProcessPass.js +282 -0
- package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
- package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
- package/src/renderer/rendering/passes/SSGIPass.js +265 -0
- package/src/renderer/rendering/passes/SSGITilePass.js +296 -0
- package/src/renderer/rendering/passes/ShadowPass.js +1822 -0
- package/src/renderer/rendering/passes/TransparentPass.js +831 -0
- package/src/renderer/rendering/shaders/ao.wgsl +182 -0
- package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
- package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
- package/src/renderer/rendering/shaders/geometry.wgsl +550 -0
- package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
- package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
- package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
- package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
- package/src/renderer/rendering/shaders/particle_render.wgsl +525 -0
- package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
- package/src/renderer/rendering/shaders/postproc.wgsl +272 -0
- package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
- package/src/renderer/rendering/shaders/shadow.wgsl +76 -0
- package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
- package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
- package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
- package/src/renderer/utils/BoundingSphere.js +439 -0
- package/src/renderer/utils/Frustum.js +281 -0
- package/dist/client.d.cts +0 -211
- package/dist/client.d.ts +0 -211
- package/dist/server.d.cts +0 -120
- package/dist/server.d.ts +0 -120
- package/dist/terminal.d.cts +0 -64
- package/dist/terminal.d.ts +0 -64
- 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 }
|