topazcube 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/Renderer.cjs +2925 -281
  2. package/dist/Renderer.cjs.map +1 -1
  3. package/dist/Renderer.js +2925 -281
  4. package/dist/Renderer.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/renderer/DebugUI.js +176 -45
  7. package/src/renderer/Material.js +3 -0
  8. package/src/renderer/Pipeline.js +3 -1
  9. package/src/renderer/Renderer.js +185 -13
  10. package/src/renderer/core/AssetManager.js +40 -5
  11. package/src/renderer/core/CullingSystem.js +1 -0
  12. package/src/renderer/gltf.js +17 -0
  13. package/src/renderer/rendering/RenderGraph.js +224 -30
  14. package/src/renderer/rendering/passes/BloomPass.js +5 -2
  15. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  16. package/src/renderer/rendering/passes/FogPass.js +26 -0
  17. package/src/renderer/rendering/passes/GBufferPass.js +31 -7
  18. package/src/renderer/rendering/passes/HiZPass.js +30 -0
  19. package/src/renderer/rendering/passes/LightingPass.js +14 -0
  20. package/src/renderer/rendering/passes/ParticlePass.js +10 -4
  21. package/src/renderer/rendering/passes/PostProcessPass.js +127 -4
  22. package/src/renderer/rendering/passes/SSGIPass.js +3 -2
  23. package/src/renderer/rendering/passes/SSGITilePass.js +14 -5
  24. package/src/renderer/rendering/passes/ShadowPass.js +265 -15
  25. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  26. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  27. package/src/renderer/rendering/shaders/geometry.wgsl +36 -6
  28. package/src/renderer/rendering/shaders/particle_render.wgsl +153 -6
  29. package/src/renderer/rendering/shaders/postproc.wgsl +23 -2
  30. package/src/renderer/rendering/shaders/shadow.wgsl +42 -1
  31. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  32. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  33. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  34. package/src/renderer/utils/Raycaster.js +761 -0
@@ -37,6 +37,7 @@ class FogPass extends BasePass {
37
37
  get fogAlpha() { return this.settings?.environment?.fog?.alpha ?? [0.0, 0.3, 0.8] }
38
38
  get fogHeightFade() { return this.settings?.environment?.fog?.heightFade ?? [-10, 100] }
39
39
  get fogBrightResist() { return this.settings?.environment?.fog?.brightResist ?? 0.8 }
40
+ get fogDebug() { return this.settings?.environment?.fog?.debug ?? 0 } // 0=off, 1=show fogAlpha, 2=show distance, 3=show heightFactor
40
41
 
41
42
  /**
42
43
  * Set the input texture (HDR lighting output)
@@ -134,6 +135,8 @@ class FogPass extends BasePass {
134
135
  brightResist: f32, // float 47
135
136
  heightFade: vec2f, // floats 48-49
136
137
  screenSize: vec2f, // floats 50-51
138
+ debug: f32, // float 52: 0=off, 1=fogAlpha, 2=distance, 3=heightFactor
139
+ _pad: vec3f, // floats 53-55
137
140
  }
138
141
 
139
142
  @group(0) @binding(0) var<uniform> uniforms: Uniforms;
@@ -262,6 +265,26 @@ class FogPass extends BasePass {
262
265
  let brightnessResist = clamp((luminance - 1.0) / 2.0, 0.0, 1.0);
263
266
  fogAlpha *= (1.0 - brightnessResist * uniforms.brightResist);
264
267
 
268
+ // Debug output
269
+ let debugMode = i32(uniforms.debug);
270
+ if (debugMode == 1) {
271
+ // Show fog alpha as grayscale
272
+ return vec4f(vec3f(fogAlpha), 1.0);
273
+ } else if (debugMode == 2) {
274
+ // Show distance (normalized to 0-100m range)
275
+ let normDist = clamp(cameraDistance / 100.0, 0.0, 1.0);
276
+ return vec4f(vec3f(normDist), 1.0);
277
+ } else if (debugMode == 3) {
278
+ // Show height factor
279
+ return vec4f(vec3f(heightFactor), 1.0);
280
+ } else if (debugMode == 4) {
281
+ // Show distance fog (before height fade)
282
+ return vec4f(vec3f(distanceFog), 1.0);
283
+ } else if (debugMode == 5) {
284
+ // Show the actual fog color being used (to verify it matches particles)
285
+ return vec4f(uniforms.fogColor, 1.0);
286
+ }
287
+
265
288
  // Apply fog
266
289
  let foggedColor = mix(color.rgb, uniforms.fogColor, fogAlpha);
267
290
 
@@ -363,6 +386,9 @@ class FogPass extends BasePass {
363
386
  uniformData[50] = this.width
364
387
  uniformData[51] = this.height
365
388
 
389
+ // debug (float 52) + padding (floats 53-55)
390
+ uniformData[52] = this.fogDebug
391
+
366
392
  device.queue.writeBuffer(this.uniformBuffer, 0, uniformData)
367
393
 
368
394
  // Render fog pass
@@ -293,7 +293,8 @@ class GBufferPass extends BasePass {
293
293
  const isSkinned = mesh.hasSkin && mesh.skin
294
294
  const meshId = mesh.uid || mesh.geometry?.uid || 'default'
295
295
  const forceEmissive = mesh.material?.forceEmissive ? '_emissive' : ''
296
- return `${mesh.material.uid}_${meshId}${isSkinned ? '_skinned' : ''}${forceEmissive}`
296
+ const doubleSided = mesh.material?.doubleSided ? '_dbl' : ''
297
+ return `${mesh.material.uid}_${meshId}${isSkinned ? '_skinned' : ''}${forceEmissive}${doubleSided}`
297
298
  }
298
299
 
299
300
  /**
@@ -341,6 +342,7 @@ class GBufferPass extends BasePass {
341
342
  renderTarget: this.gbuffer,
342
343
  skin: isSkinned ? mesh.skin : null,
343
344
  noiseTexture: this.noiseTexture,
345
+ doubleSided: mesh.material?.doubleSided ?? false,
344
346
  }).then(pipeline => {
345
347
  // Move from pending to ready
346
348
  this.pendingPipelines.delete(key)
@@ -386,6 +388,7 @@ class GBufferPass extends BasePass {
386
388
  renderTarget: this.gbuffer,
387
389
  skin: isSkinned ? mesh.skin : null,
388
390
  noiseTexture: this.noiseTexture,
391
+ doubleSided: mesh.material?.doubleSided ?? false,
389
392
  })
390
393
  // Mark as warming up - needs 2 frames to stabilize
391
394
  pipeline._warmupFrames = 2
@@ -418,6 +421,11 @@ class GBufferPass extends BasePass {
418
421
  const emissionFactor = this.settings?.environment?.emissionFactor ?? [1.0, 1.0, 1.0, 4.0]
419
422
  const mipBias = this.settings?.rendering?.mipBias ?? options.mipBias ?? 0
420
423
 
424
+ // Use absolute time for scene-loaded skins (same as entity animations)
425
+ // This ensures consistent timing regardless of frame rate fluctuations
426
+ const animationSpeed = this.settings?.animation?.speed ?? 1.0
427
+ const globalAnimTime = (performance.now() / 1000) * animationSpeed
428
+
421
429
  stats.drawCalls = 0
422
430
  stats.triangles = 0
423
431
 
@@ -432,15 +440,25 @@ class GBufferPass extends BasePass {
432
440
  let commandEncoder = null
433
441
  let passEncoder = null
434
442
 
443
+ // Track which skins have been updated this frame (avoids duplicate updates)
444
+ const updatedSkins = new Set()
445
+
435
446
  // New system: render batches from InstanceManager
436
447
  if (batches && batches.size > 0) {
437
448
  for (const [modelId, batch] of batches) {
438
449
  const mesh = batch.mesh
439
450
  if (!mesh) continue
440
451
 
441
- // Update skin animation if skinned (skip if externally managed)
442
- if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged) {
443
- batch.skin.update(dt)
452
+ // Update skin animation if skinned (skip if externally managed or already updated)
453
+ // Use absolute time (same as entities) for consistent animation speed
454
+ if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged && !updatedSkins.has(batch.skin)) {
455
+ // Track animation start time per skin for absolute timing
456
+ if (batch.skin._animStartTime === undefined) {
457
+ batch.skin._animStartTime = globalAnimTime
458
+ }
459
+ const skinAnimTime = globalAnimTime - batch.skin._animStartTime
460
+ batch.skin.updateAtTime(skinAnimTime)
461
+ updatedSkins.add(batch.skin)
444
462
  }
445
463
 
446
464
  const pipeline = await this._getOrCreatePipeline(mesh)
@@ -598,9 +616,15 @@ class GBufferPass extends BasePass {
598
616
 
599
617
  this.legacyCullingStats.rendered++
600
618
 
601
- // Update skin animation (skip if externally managed - already updated by RenderGraph)
602
- if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged) {
603
- mesh.skin.update(dt)
619
+ // Update skin animation using absolute time (skip if externally managed or already updated)
620
+ if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged && !updatedSkins.has(mesh.skin)) {
621
+ // Track animation start time per skin for absolute timing
622
+ if (mesh.skin._animStartTime === undefined) {
623
+ mesh.skin._animStartTime = globalAnimTime
624
+ }
625
+ const skinAnimTime = globalAnimTime - mesh.skin._animStartTime
626
+ mesh.skin.updateAtTime(skinAnimTime)
627
+ updatedSkins.add(mesh.skin)
604
628
  }
605
629
 
606
630
  // Ensure pipeline geometry matches mesh geometry
@@ -84,6 +84,10 @@ class HiZPass extends BasePass {
84
84
 
85
85
  // Flag to prevent operations during destruction
86
86
  this._destroyed = false
87
+
88
+ // Warmup frames - occlusion is disabled until scene has rendered for a few frames
89
+ // This prevents false occlusion on engine creation when depth buffer is not yet populated
90
+ this._warmupFramesRemaining = 5
87
91
  }
88
92
 
89
93
  /**
@@ -94,6 +98,20 @@ class HiZPass extends BasePass {
94
98
  this.depthTexture = depth
95
99
  }
96
100
 
101
+ /**
102
+ * Invalidate occlusion culling data and reset warmup period.
103
+ * Call this after engine creation, scene loading, or major camera changes
104
+ * to prevent incorrect occlusion culling with stale data.
105
+ */
106
+ invalidate() {
107
+ this.hasValidHistory = false
108
+ this.hizDataReady = false
109
+ this._warmupFramesRemaining = 5 // Wait 5 frames before enabling occlusion
110
+ // Reset camera tracking to avoid false invalidations
111
+ vec3.set(this.lastCameraPosition, 0, 0, 0)
112
+ vec3.set(this.lastCameraDirection, 0, 0, 0)
113
+ }
114
+
97
115
  async _init() {
98
116
  const { device, canvas } = this.engine
99
117
  await this._createResources(canvas.width, canvas.height)
@@ -207,6 +225,7 @@ class HiZPass extends BasePass {
207
225
  this._destroyed = false
208
226
  this.hizDataReady = false
209
227
  this.pendingReadback = null
228
+ this._warmupFramesRemaining = 5 // Wait a few frames after resize before enabling occlusion
210
229
  }
211
230
 
212
231
  /**
@@ -263,6 +282,11 @@ class HiZPass extends BasePass {
263
282
  // Increment frame counter
264
283
  this._frameCounter++
265
284
 
285
+ // Decrement warmup counter - occlusion is disabled until this reaches 0
286
+ if (this._warmupFramesRemaining > 0) {
287
+ this._warmupFramesRemaining--
288
+ }
289
+
266
290
  // Check if occlusion culling is enabled
267
291
  if (!this.settings?.occlusionCulling?.enabled) {
268
292
  return
@@ -468,6 +492,12 @@ class HiZPass extends BasePass {
468
492
  testSphereOcclusion(bsphere, viewProj, near, far, cameraPos) {
469
493
  this.debugStats.tested++
470
494
 
495
+ // Warmup period - disable occlusion for first few frames after creation/reset
496
+ // This ensures the depth buffer has valid geometry before we use it for culling
497
+ if (this._warmupFramesRemaining > 0) {
498
+ return false // Still warming up - assume visible
499
+ }
500
+
471
501
  // Safety checks
472
502
  if (!this.hizDataReady || !this.hasValidHistory) {
473
503
  return false // No valid data - assume visible
@@ -734,6 +734,20 @@ class LightingPass extends BasePass {
734
734
  getOutputTexture() {
735
735
  return this.outputTexture
736
736
  }
737
+
738
+ /**
739
+ * Get the light buffer for volumetric fog
740
+ */
741
+ getLightBuffer() {
742
+ return this.lightBuffer
743
+ }
744
+
745
+ /**
746
+ * Get the current light count
747
+ */
748
+ getLightCount() {
749
+ return this.lights?.length ?? 0
750
+ }
737
751
  }
738
752
 
739
753
  export { LightingPass }
@@ -718,10 +718,10 @@ class ParticlePass extends BasePass {
718
718
  uniformData[85] = fogAlphas[1]
719
719
  uniformData[86] = fogAlphas[2]
720
720
  uniformData[87] = 0.0 // padding
721
- // fogHeightFade (vec2f) + fogPad2 (vec2f) - floats 88-91
721
+ // fogHeightFade (vec2f) + fogDebug (f32) + fogPad2 (f32) - floats 88-91
722
722
  uniformData[88] = fogHeightFade[0] // bottomY
723
723
  uniformData[89] = fogHeightFade[1] // topY
724
- uniformData[90] = 0.0 // padding
724
+ uniformData[90] = fogSettings.debug ?? 0 // fogDebug (0=off, 2=show distance)
725
725
  uniformData[91] = 0.0 // padding
726
726
 
727
727
  // Update emitter render settings buffer (lit, emissive, softness, zOffset per emitter)
@@ -774,11 +774,14 @@ class ParticlePass extends BasePass {
774
774
  const hasAdditive = emitters.some(e => e.blendMode === 'additive')
775
775
 
776
776
  // Draw alpha-blended particles first (back-to-front would be ideal, but we draw all)
777
+ // NOTE: Must submit each pass separately because writeBuffer is immediate
778
+ // but render passes execute when command buffer is submitted
777
779
  if (hasAlpha) {
778
780
  uniformData[48] = 0.0 // blendMode = alpha
779
781
  device.queue.writeBuffer(this.renderUniformBuffer, 0, uniformData)
780
782
 
781
- const renderPass = commandEncoder.beginRenderPass({
783
+ const alphaEncoder = device.createCommandEncoder({ label: 'Particle Alpha Pass' })
784
+ const renderPass = alphaEncoder.beginRenderPass({
782
785
  colorAttachments: [{
783
786
  view: this.outputTexture.view,
784
787
  loadOp: 'load',
@@ -795,6 +798,7 @@ class ParticlePass extends BasePass {
795
798
  renderPass.setBindGroup(0, renderBindGroup)
796
799
  renderPass.draw(6, maxParticles, 0, 0)
797
800
  renderPass.end()
801
+ device.queue.submit([alphaEncoder.finish()])
798
802
  }
799
803
 
800
804
  // Draw additive particles (order doesn't matter for additive)
@@ -802,7 +806,8 @@ class ParticlePass extends BasePass {
802
806
  uniformData[48] = 1.0 // blendMode = additive
803
807
  device.queue.writeBuffer(this.renderUniformBuffer, 0, uniformData)
804
808
 
805
- const renderPass = commandEncoder.beginRenderPass({
809
+ const additiveEncoder = device.createCommandEncoder({ label: 'Particle Additive Pass' })
810
+ const renderPass = additiveEncoder.beginRenderPass({
806
811
  colorAttachments: [{
807
812
  view: this.outputTexture.view,
808
813
  loadOp: 'load',
@@ -819,6 +824,7 @@ class ParticlePass extends BasePass {
819
824
  renderPass.setBindGroup(0, renderBindGroup)
820
825
  renderPass.draw(6, maxParticles, 0, 0)
821
826
  renderPass.end()
827
+ device.queue.submit([additiveEncoder.finish()])
822
828
  }
823
829
  }
824
830
 
@@ -26,6 +26,16 @@ class PostProcessPass extends BasePass {
26
26
  this.guiCanvas = null // 2D canvas for GUI overlay
27
27
  this.guiTexture = null // GPU texture for GUI
28
28
  this.guiSampler = null
29
+
30
+ // CRT support: intermediate texture when CRT is enabled
31
+ this.intermediateTexture = null
32
+ this._outputWidth = 0
33
+ this._outputHeight = 0
34
+ this._lastOutputToTexture = false // Track output mode changes
35
+
36
+ // Store resize dimensions for runtime texture creation
37
+ this._resizeWidth = 0
38
+ this._resizeHeight = 0
29
39
  }
30
40
 
31
41
  // Convenience getter for exposure setting
@@ -38,11 +48,19 @@ class PostProcessPass extends BasePass {
38
48
  get ditheringEnabled() { return this.settings?.dithering?.enabled ?? true }
39
49
  get colorLevels() { return this.settings?.dithering?.colorLevels ?? 32 }
40
50
 
51
+ // Tonemap mode: 0=ACES, 1=Reinhard, 2=None/Linear
52
+ get tonemapMode() { return this.settings?.rendering?.tonemapMode ?? 0 }
53
+
41
54
  // Convenience getters for bloom settings
42
55
  get bloomEnabled() { return this.settings?.bloom?.enabled ?? true }
43
56
  get bloomIntensity() { return this.settings?.bloom?.intensity ?? 1.0 }
44
57
  get bloomRadius() { return this.settings?.bloom?.radius ?? 5 }
45
58
 
59
+ // CRT settings (determines if we output to intermediate texture)
60
+ get crtEnabled() { return this.settings?.crt?.enabled ?? false }
61
+ get crtUpscaleEnabled() { return this.settings?.crt?.upscaleEnabled ?? false }
62
+ get shouldOutputToTexture() { return this.crtEnabled || this.crtUpscaleEnabled }
63
+
46
64
  /**
47
65
  * Set the input texture (HDR image from LightingPass)
48
66
  * @param {Texture} texture - Input HDR texture
@@ -86,6 +104,71 @@ class PostProcessPass extends BasePass {
86
104
  this.guiCanvas = canvas
87
105
  }
88
106
 
107
+ /**
108
+ * Get the output texture (for CRT pass to use)
109
+ * Returns null if outputting directly to canvas
110
+ */
111
+ getOutputTexture() {
112
+ return this.shouldOutputToTexture ? this.intermediateTexture : null
113
+ }
114
+
115
+ /**
116
+ * Create or resize the intermediate texture for CRT
117
+ */
118
+ async _createIntermediateTexture(width, height) {
119
+ if (!this.shouldOutputToTexture) {
120
+ // Destroy if no longer needed
121
+ if (this.intermediateTexture?.texture) {
122
+ this.intermediateTexture.texture.destroy()
123
+ this.intermediateTexture = null
124
+ }
125
+ return
126
+ }
127
+
128
+ // Skip if size hasn't changed
129
+ if (this._outputWidth === width && this._outputHeight === height && this.intermediateTexture) {
130
+ return
131
+ }
132
+
133
+ const { device } = this.engine
134
+
135
+ // Destroy old texture
136
+ if (this.intermediateTexture?.texture) {
137
+ this.intermediateTexture.texture.destroy()
138
+ }
139
+
140
+ // Create intermediate texture (SDR format for CRT input)
141
+ const texture = device.createTexture({
142
+ label: 'PostProcess Intermediate',
143
+ size: [width, height, 1],
144
+ format: 'rgba8unorm',
145
+ usage: GPUTextureUsage.TEXTURE_BINDING |
146
+ GPUTextureUsage.RENDER_ATTACHMENT |
147
+ GPUTextureUsage.COPY_SRC,
148
+ })
149
+
150
+ const sampler = device.createSampler({
151
+ label: 'PostProcess Intermediate Sampler',
152
+ minFilter: 'nearest',
153
+ magFilter: 'nearest',
154
+ })
155
+
156
+ this.intermediateTexture = {
157
+ texture,
158
+ view: texture.createView(),
159
+ sampler,
160
+ width,
161
+ height,
162
+ format: 'rgba8unorm', // Required by Pipeline.create()
163
+ }
164
+
165
+ this._outputWidth = width
166
+ this._outputHeight = height
167
+ this._needsRebuild = true
168
+
169
+ console.log(`PostProcessPass: Created intermediate texture ${width}x${height} for CRT`)
170
+ }
171
+
89
172
  async _init() {
90
173
  // Create dummy 1x1 black bloom texture for when bloom is disabled
91
174
  // This ensures shader bindings are always valid
@@ -169,6 +252,11 @@ class PostProcessPass extends BasePass {
169
252
 
170
253
  const hasBloom = this.bloomTexture && this.bloomEnabled
171
254
 
255
+ // Determine render target: intermediate texture (for CRT) or canvas
256
+ const renderTarget = this.shouldOutputToTexture && this.intermediateTexture
257
+ ? this.intermediateTexture
258
+ : null
259
+
172
260
  this.pipeline = await Pipeline.create(this.engine, {
173
261
  label: 'postProcess',
174
262
  wgslSource: postProcessingWGSL,
@@ -176,17 +264,41 @@ class PostProcessPass extends BasePass {
176
264
  textures: textures,
177
265
  uniforms: () => ({
178
266
  noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1.0 : 0.0],
179
- ditherParams: [this.ditheringEnabled ? 1.0 : 0.0, this.colorLevels, 0, 0],
267
+ ditherParams: [this.ditheringEnabled ? 1.0 : 0.0, this.colorLevels, this.tonemapMode, 0],
180
268
  bloomParams: [hasBloom ? 1.0 : 0.0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
181
269
  }),
182
- // No renderTarget = output to canvas
270
+ renderTarget: renderTarget,
183
271
  })
184
272
 
185
273
  this._needsRebuild = false
186
274
  }
187
275
 
188
276
  async _execute(context) {
189
- const { device } = this.engine
277
+ const { device, canvas } = this.engine
278
+
279
+ // Check if CRT state changed (need to switch between canvas/texture output)
280
+ const needsOutputToTexture = this.shouldOutputToTexture
281
+ const hasIntermediateTexture = !!this.intermediateTexture
282
+
283
+ // Detect output mode change
284
+ if (needsOutputToTexture !== this._lastOutputToTexture) {
285
+ this._lastOutputToTexture = needsOutputToTexture
286
+ this._needsRebuild = true
287
+
288
+ if (needsOutputToTexture && !hasIntermediateTexture) {
289
+ // CRT was just enabled - create intermediate texture at stored resize dimensions
290
+ // Use resize dimensions (render-scaled), not canvas dimensions (full resolution)
291
+ const w = this._resizeWidth || canvas.width
292
+ const h = this._resizeHeight || canvas.height
293
+ await this._createIntermediateTexture(w, h)
294
+ } else if (!needsOutputToTexture && hasIntermediateTexture) {
295
+ // CRT was just disabled - destroy intermediate texture
296
+ if (this.intermediateTexture?.texture) {
297
+ this.intermediateTexture.texture.destroy()
298
+ this.intermediateTexture = null
299
+ }
300
+ }
301
+ }
190
302
 
191
303
  // Update GUI texture from canvas if available
192
304
  if (this.guiCanvas && this.guiCanvas.width > 0 && this.guiCanvas.height > 0) {
@@ -249,7 +361,7 @@ class PostProcessPass extends BasePass {
249
361
  // Update uniforms each frame
250
362
  this.pipeline.uniformValues.set({
251
363
  noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1.0 : 0.0],
252
- ditherParams: [this.ditheringEnabled ? 1.0 : 0.0, this.colorLevels, 0, 0],
364
+ ditherParams: [this.ditheringEnabled ? 1.0 : 0.0, this.colorLevels, this.tonemapMode, 0],
253
365
  bloomParams: [hasBloom ? 1.0 : 0.0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
254
366
  })
255
367
 
@@ -258,6 +370,13 @@ class PostProcessPass extends BasePass {
258
370
  }
259
371
 
260
372
  async _resize(width, height) {
373
+ // Store resize dimensions for runtime texture creation
374
+ this._resizeWidth = width
375
+ this._resizeHeight = height
376
+
377
+ // Create intermediate texture for CRT if enabled
378
+ await this._createIntermediateTexture(width, height)
379
+
261
380
  // Pipeline needs rebuild since canvas size changed
262
381
  this._needsRebuild = true
263
382
  }
@@ -276,6 +395,10 @@ class PostProcessPass extends BasePass {
276
395
  this.dummyGuiTexture.texture.destroy()
277
396
  this.dummyGuiTexture = null
278
397
  }
398
+ if (this.intermediateTexture?.texture) {
399
+ this.intermediateTexture.texture.destroy()
400
+ this.intermediateTexture = null
401
+ }
279
402
  }
280
403
  }
281
404
 
@@ -148,8 +148,9 @@ class SSGIPass extends BasePass {
148
148
  return
149
149
  }
150
150
 
151
- // Update uniforms
152
- this._updateUniforms(ssgiSettings, canvas.width, canvas.height)
151
+ // Update uniforms - use render dimensions (this.width/height are half-res)
152
+ // Don't use canvas dimensions as they may differ from render dimensions
153
+ this._updateUniforms(ssgiSettings, this.width * 2, this.height * 2)
153
154
 
154
155
  // Create bind group
155
156
  const bindGroup = device.createBindGroup({
@@ -35,6 +35,10 @@ class SSGITilePass extends BasePass {
35
35
  this.tileCountX = 0
36
36
  this.tileCountY = 0
37
37
 
38
+ // Stored render dimensions (from resize)
39
+ this.renderWidth = 0
40
+ this.renderHeight = 0
41
+
38
42
  // Input textures
39
43
  this.prevHDRTexture = null
40
44
  this.emissiveTexture = null
@@ -75,6 +79,10 @@ class SSGITilePass extends BasePass {
75
79
  async _createResources(width, height) {
76
80
  const { device } = this.engine
77
81
 
82
+ // Store render dimensions for use in _execute
83
+ this.renderWidth = width
84
+ this.renderHeight = height
85
+
78
86
  // Calculate tile grid dimensions
79
87
  this.tileCountX = Math.ceil(width / TILE_SIZE)
80
88
  this.tileCountY = Math.ceil(height / TILE_SIZE)
@@ -160,7 +168,7 @@ class SSGITilePass extends BasePass {
160
168
  }
161
169
 
162
170
  async _execute(context) {
163
- const { device, canvas } = this.engine
171
+ const { device } = this.engine
164
172
 
165
173
  // Check if SSGI is enabled
166
174
  const ssgiSettings = this.settings?.ssgi
@@ -168,9 +176,9 @@ class SSGITilePass extends BasePass {
168
176
  return
169
177
  }
170
178
 
171
- // Rebuild if needed
179
+ // Rebuild if needed (use stored render dimensions, not canvas)
172
180
  if (this._needsRebuild) {
173
- await this._createResources(canvas.width, canvas.height)
181
+ await this._createResources(this.renderWidth, this.renderHeight)
174
182
  }
175
183
 
176
184
  // Check required textures
@@ -182,8 +190,9 @@ class SSGITilePass extends BasePass {
182
190
  return
183
191
  }
184
192
 
185
- const width = canvas.width
186
- const height = canvas.height
193
+ // Use stored render dimensions, not canvas dimensions
194
+ const width = this.renderWidth
195
+ const height = this.renderHeight
187
196
  const emissiveBoost = ssgiSettings.emissiveBoost ?? 2.0
188
197
  const maxBrightness = ssgiSettings.maxBrightness ?? 4.0
189
198