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