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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "topazcube",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "TopazCube is a real-time collaborative document editing, multiplayer game library, and WebGPU renderer.",
5
5
  "author": "László Matuska @BitOfGold",
6
6
  "license": "Apache-2.0",
@@ -42,19 +42,20 @@ class DebugUI {
42
42
 
43
43
  // Create settings folders
44
44
  this._createRenderingFolder()
45
- this._createAOFolder()
46
- this._createShadowFolder()
47
- this._createMainLightFolder()
45
+ this._createCameraFolder()
48
46
  this._createEnvironmentFolder()
49
47
  this._createLightingFolder()
50
- this._createCullingFolder()
51
- this._createSSGIFolder()
52
- this._createBloomFolder()
48
+ this._createMainLightFolder()
49
+ this._createShadowFolder()
53
50
  this._createPlanarReflectionFolder()
51
+ this._createAOFolder()
54
52
  this._createAmbientCaptureFolder()
55
- this._createNoiseFolder()
53
+ this._createSSGIFolder()
54
+ this._createVolumetricFogFolder()
55
+ this._createBloomFolder()
56
+ this._createTonemapFolder()
56
57
  this._createDitheringFolder()
57
- this._createCameraFolder()
58
+ this._createCRTFolder()
58
59
  this._createDebugFolder()
59
60
 
60
61
  // Close all folders by default
@@ -244,6 +245,42 @@ class DebugUI {
244
245
  this.gui.domElement.style.display = 'none'
245
246
  }
246
247
  })
248
+
249
+ // Culling subfolder
250
+ if (s.culling) {
251
+ const cullFolder = folder.addFolder('Culling')
252
+ cullFolder.add(s.culling, 'frustumEnabled').name('Frustum Culling')
253
+
254
+ if (s.occlusionCulling) {
255
+ cullFolder.add(s.occlusionCulling, 'enabled').name('Occlusion Culling')
256
+ cullFolder.add(s.occlusionCulling, 'threshold', 0.1, 2.0, 0.1).name('Occlusion Threshold')
257
+ }
258
+
259
+ // Planar reflection culling sub-folder
260
+ if (s.culling.planarReflection) {
261
+ const prFolder = cullFolder.addFolder('Planar Reflection')
262
+ prFolder.add(s.culling.planarReflection, 'frustum').name('Frustum Culling')
263
+ prFolder.add(s.culling.planarReflection, 'maxDistance', 10, 200, 10).name('Max Distance')
264
+ prFolder.add(s.culling.planarReflection, 'maxSkinned', 0, 100, 1).name('Max Skinned')
265
+ prFolder.add(s.culling.planarReflection, 'minPixelSize', 0, 16, 1).name('Min Pixel Size')
266
+ prFolder.close()
267
+ }
268
+ cullFolder.close()
269
+ }
270
+
271
+ // Noise subfolder
272
+ if (s.noise) {
273
+ const noiseFolder = folder.addFolder('Noise')
274
+ noiseFolder.add(s.noise, 'type', ['bluenoise', 'bayer8']).name('Type')
275
+ .onChange(() => {
276
+ // Reload noise texture when type changes
277
+ if (this.engine.renderer?.renderGraph) {
278
+ this.engine.renderer.renderGraph.reloadNoiseTexture()
279
+ }
280
+ })
281
+ noiseFolder.add(s.noise, 'animated').name('Animated')
282
+ noiseFolder.close()
283
+ }
247
284
  }
248
285
 
249
286
  _createAOFolder() {
@@ -344,6 +381,9 @@ class DebugUI {
344
381
  fogFolder.add(s.environment.fog.heightFade, '0', -100, 100, 1).name('Bottom Y')
345
382
  fogFolder.add(s.environment.fog.heightFade, '1', -50, 200, 5).name('Top Y')
346
383
  fogFolder.add(s.environment.fog, 'brightResist', 0, 1, 0.05).name('Bright Resist')
384
+ // Initialize debug if not present
385
+ if (s.environment.fog.debug === undefined) s.environment.fog.debug = 0
386
+ fogFolder.add(s.environment.fog, 'debug', 0, 10, 1).name('Debug Mode')
347
387
  }
348
388
  }
349
389
 
@@ -364,31 +404,6 @@ class DebugUI {
364
404
  }
365
405
  }
366
406
 
367
- _createCullingFolder() {
368
- const s = this.engine.settings
369
- if (!s.culling) return
370
-
371
- const folder = this.gui.addFolder('Culling')
372
- this.folders.culling = folder
373
-
374
- folder.add(s.culling, 'frustumEnabled').name('Frustum Culling')
375
-
376
- if (s.occlusionCulling) {
377
- folder.add(s.occlusionCulling, 'enabled').name('Occlusion Culling')
378
- folder.add(s.occlusionCulling, 'threshold', 0.1, 2.0, 0.1).name('Occlusion Threshold')
379
- }
380
-
381
- // Planar reflection culling sub-folder
382
- if (s.culling.planarReflection) {
383
- const prFolder = folder.addFolder('Planar Reflection')
384
- prFolder.add(s.culling.planarReflection, 'frustum').name('Frustum Culling')
385
- prFolder.add(s.culling.planarReflection, 'maxDistance', 10, 200, 10).name('Max Distance')
386
- prFolder.add(s.culling.planarReflection, 'maxSkinned', 0, 100, 1).name('Max Skinned')
387
- prFolder.add(s.culling.planarReflection, 'minPixelSize', 0, 16, 1).name('Min Pixel Size')
388
- prFolder.close()
389
- }
390
- }
391
-
392
407
  _createSSGIFolder() {
393
408
  const s = this.engine.settings
394
409
  if (!s.ssgi) return
@@ -404,6 +419,71 @@ class DebugUI {
404
419
  folder.add(s.ssgi, 'saturateLevel', 0.1, 2.0, 0.1).name('Saturate Level')
405
420
  }
406
421
 
422
+ _createVolumetricFogFolder() {
423
+ const s = this.engine.settings
424
+ if (!s.volumetricFog) return
425
+
426
+ // Initialize defaults for missing properties
427
+ const vf = s.volumetricFog
428
+ if (vf.density === undefined && vf.densityMultiplier === undefined) vf.density = 0.5
429
+ if (vf.scatterStrength === undefined) vf.scatterStrength = 1.0
430
+ if (!vf.heightRange) vf.heightRange = [-5, 20]
431
+ if (vf.resolution === undefined) vf.resolution = 0.25
432
+ if (vf.maxSamples === undefined) vf.maxSamples = 32
433
+ if (vf.blurRadius === undefined) vf.blurRadius = 4
434
+ if (vf.noiseStrength === undefined) vf.noiseStrength = 1.0
435
+ if (vf.noiseScale === undefined) vf.noiseScale = 0.25
436
+ if (vf.noiseAnimated === undefined) vf.noiseAnimated = true
437
+ if (vf.shadowsEnabled === undefined) vf.shadowsEnabled = true
438
+ if (vf.mainLightScatter === undefined) vf.mainLightScatter = 1.0
439
+ if (vf.mainLightScatterDark === undefined) vf.mainLightScatterDark = 3.0
440
+ if (vf.mainLightSaturation === undefined) vf.mainLightSaturation = 1.0
441
+ if (vf.brightnessThreshold === undefined) vf.brightnessThreshold = 1.0
442
+ if (vf.minVisibility === undefined) vf.minVisibility = 0.15
443
+ if (vf.skyBrightness === undefined) vf.skyBrightness = 5.0
444
+ if (vf.debug === undefined) vf.debug = 0
445
+
446
+ const folder = this.gui.addFolder('Volumetric Fog')
447
+ this.folders.volumetricFog = folder
448
+
449
+ folder.add(vf, 'enabled').name('Enabled')
450
+
451
+ // Density - use whichever property exists
452
+ if (vf.density !== undefined) {
453
+ folder.add(vf, 'density', 0.0, 2.0, 0.05).name('Density')
454
+ } else if (vf.densityMultiplier !== undefined) {
455
+ folder.add(vf, 'densityMultiplier', 0.0, 2.0, 0.05).name('Density')
456
+ }
457
+
458
+ folder.add(vf, 'scatterStrength', 0.0, 10.0, 0.1).name('Scatter (Lights)')
459
+ folder.add(vf, 'mainLightScatter', 0.0, 5.0, 0.1).name('Sun Scatter (Light)')
460
+ folder.add(vf, 'mainLightScatterDark', 0.0, 10.0, 0.1).name('Sun Scatter (Dark)')
461
+ folder.add(vf, 'mainLightSaturation', 0.0, 1.0, 0.01).name('Sun Saturation')
462
+ folder.add(vf, 'brightnessThreshold', 0.1, 5.0, 0.1).name('Bright Threshold')
463
+ folder.add(vf, 'minVisibility', 0.0, 1.0, 0.05).name('Min Visibility')
464
+ folder.add(vf, 'skyBrightness', 0.0, 10.0, 0.5).name('Sky Brightness')
465
+ folder.add(vf.heightRange, '0', -50, 50, 1).name('Height Bottom')
466
+ folder.add(vf.heightRange, '1', -10, 100, 1).name('Height Top')
467
+
468
+ // Quality sub-folder
469
+ const qualityFolder = folder.addFolder('Quality')
470
+ qualityFolder.add(vf, 'resolution', 0.125, 0.5, 0.125).name('Resolution')
471
+ qualityFolder.add(vf, 'maxSamples', 16, 128, 8).name('Max Samples')
472
+ qualityFolder.add(vf, 'blurRadius', 0, 8, 1).name('Blur Radius')
473
+ qualityFolder.close()
474
+
475
+ // Noise sub-folder
476
+ const noiseFolder = folder.addFolder('Noise')
477
+ noiseFolder.add(vf, 'noiseStrength', 0.0, 1.0, 0.1).name('Strength')
478
+ noiseFolder.add(vf, 'noiseScale', 0.05, 1.0, 0.05).name('Scale (Detail)')
479
+ noiseFolder.add(vf, 'noiseAnimated').name('Animated')
480
+ noiseFolder.close()
481
+
482
+ // Shadows & Debug
483
+ folder.add(vf, 'shadowsEnabled').name('Shadows')
484
+ folder.add(vf, 'debug', 0, 12, 1).name('Debug Mode')
485
+ }
486
+
407
487
  _createBloomFolder() {
408
488
  const s = this.engine.settings
409
489
  if (!s.bloom) return
@@ -423,6 +503,19 @@ class DebugUI {
423
503
  }
424
504
  }
425
505
 
506
+ _createTonemapFolder() {
507
+ const s = this.engine.settings
508
+ if (!s.rendering) return
509
+
510
+ // Ensure tonemapMode exists
511
+ if (s.rendering.tonemapMode === undefined) s.rendering.tonemapMode = 0
512
+
513
+ const folder = this.gui.addFolder('Tone Mapping')
514
+ this.folders.tonemap = folder
515
+
516
+ folder.add(s.rendering, 'tonemapMode', { 'ACES': 0, 'Reinhard': 1, 'None (Linear)': 2 }).name('Mode')
517
+ }
518
+
426
519
  _createPlanarReflectionFolder() {
427
520
  const s = this.engine.settings
428
521
  if (!s.planarReflection) return
@@ -442,7 +535,7 @@ class DebugUI {
442
535
  const s = this.engine.settings
443
536
  if (!s.ambientCapture) return
444
537
 
445
- const folder = this.gui.addFolder('Ambient Capture')
538
+ const folder = this.gui.addFolder('Probe GI (Ambient Capture)')
446
539
  this.folders.ambientCapture = folder
447
540
 
448
541
  folder.add(s.ambientCapture, 'enabled').name('Enabled')
@@ -452,17 +545,6 @@ class DebugUI {
452
545
  folder.add(s.ambientCapture, 'saturateLevel', 0.0, 2.0, 0.05).name('Saturate Level')
453
546
  }
454
547
 
455
- _createNoiseFolder() {
456
- const s = this.engine.settings
457
- if (!s.noise) return
458
-
459
- const folder = this.gui.addFolder('Noise')
460
- this.folders.noise = folder
461
-
462
- folder.add(s.noise, 'type', ['bluenoise', 'bayer8']).name('Type')
463
- folder.add(s.noise, 'animated').name('Animated')
464
- }
465
-
466
548
  _createDitheringFolder() {
467
549
  const s = this.engine.settings
468
550
  if (!s.dithering) return
@@ -474,6 +556,55 @@ class DebugUI {
474
556
  folder.add(s.dithering, 'colorLevels', 4, 256, 1).name('Color Levels')
475
557
  }
476
558
 
559
+ _createCRTFolder() {
560
+ const s = this.engine.settings
561
+ if (!s.crt) return
562
+
563
+ const folder = this.gui.addFolder('CRT Effect')
564
+ this.folders.crt = folder
565
+
566
+ folder.add(s.crt, 'enabled').name('CRT Enabled')
567
+ folder.add(s.crt, 'upscaleEnabled').name('Upscale Only')
568
+ folder.add(s.crt, 'upscaleTarget', 1, 8, 1).name('Upscale Target')
569
+
570
+ // Geometry sub-folder
571
+ const geomFolder = folder.addFolder('Geometry')
572
+ geomFolder.add(s.crt, 'curvature', 0, 0.25, 0.005).name('Curvature')
573
+ geomFolder.add(s.crt, 'cornerRadius', 0, 0.2, 0.005).name('Corner Radius')
574
+ geomFolder.add(s.crt, 'zoom', 1.0, 1.25, 0.005).name('Zoom')
575
+ geomFolder.close()
576
+
577
+ // Scanlines sub-folder
578
+ const scanFolder = folder.addFolder('Scanlines')
579
+ scanFolder.add(s.crt, 'scanlineIntensity', 0, 1, 0.05).name('Intensity')
580
+ scanFolder.add(s.crt, 'scanlineWidth', 0, 1, 0.05).name('Width')
581
+ scanFolder.add(s.crt, 'scanlineBrightBoost', 0, 2, 0.05).name('Bright Boost')
582
+ scanFolder.add(s.crt, 'scanlineHeight', 1, 10, 1).name('Height (px)')
583
+ scanFolder.close()
584
+
585
+ // Convergence sub-folder
586
+ const convFolder = folder.addFolder('RGB Convergence')
587
+ convFolder.add(s.crt.convergence, '0', -3, 3, 0.1).name('Red X Offset')
588
+ convFolder.add(s.crt.convergence, '1', -3, 3, 0.1).name('Green X Offset')
589
+ convFolder.add(s.crt.convergence, '2', -3, 3, 0.1).name('Blue X Offset')
590
+ convFolder.close()
591
+
592
+ // Phosphor mask sub-folder
593
+ const maskFolder = folder.addFolder('Phosphor Mask')
594
+ maskFolder.add(s.crt, 'maskType', ['none', 'aperture', 'slot', 'shadow']).name('Type')
595
+ maskFolder.add(s.crt, 'maskIntensity', 0, 1, 0.05).name('Intensity')
596
+ maskFolder.close()
597
+
598
+ // Vignette sub-folder
599
+ const vigFolder = folder.addFolder('Vignette')
600
+ vigFolder.add(s.crt, 'vignetteIntensity', 0, 1, 0.05).name('Intensity')
601
+ vigFolder.add(s.crt, 'vignetteSize', 0.1, 1, 0.05).name('Size')
602
+ vigFolder.close()
603
+
604
+ // Blur (top level, not in subfolder)
605
+ folder.add(s.crt, 'blurSize', 0, 8, 0.1).name('H-Blur (px)')
606
+ }
607
+
477
608
  _createCameraFolder() {
478
609
  const s = this.engine.settings
479
610
  if (!s.camera) return
@@ -34,6 +34,9 @@ class Material {
34
34
 
35
35
  // Specular boost: enables 3 additional specular lights for shiny materials (0-1, default 0 = disabled)
36
36
  this.specularBoost = 0
37
+
38
+ // Double-sided rendering: disable backface culling
39
+ this.doubleSided = false
37
40
  }
38
41
 
39
42
  /**
@@ -88,6 +88,7 @@ class Pipeline {
88
88
  tileLightBuffer = null, // Optional tile light indices buffer for tiled lighting
89
89
  lightBuffer = null, // Optional light storage buffer for tiled lighting
90
90
  noiseTexture = null, // Optional noise texture for alpha hashing
91
+ doubleSided = false, // Optional: disable backface culling for double-sided materials
91
92
  }) {
92
93
  let texture = textures[0]
93
94
  const { canvas, device, canvasFormat, options } = engine
@@ -154,7 +155,7 @@ class Pipeline {
154
155
  geometry.vertexBufferLayout,
155
156
  geometry.instanceBufferLayout
156
157
  ];
157
- pipelineDescriptor.primitive.cullMode = "back";
158
+ pipelineDescriptor.primitive.cullMode = doubleSided ? "none" : "back";
158
159
  pipelineDescriptor.depthStencil = {
159
160
  depthWriteEnabled: true,
160
161
  depthCompare: "less",
@@ -420,6 +421,7 @@ class Pipeline {
420
421
  p.tileLightBuffer = tileLightBuffer
421
422
  p.lightBuffer = lightBuffer
422
423
  p.noiseTexture = noiseTexture
424
+ p.doubleSided = doubleSided
423
425
  return p
424
426
  }
425
427
 
@@ -13,6 +13,7 @@ import { InstanceManager } from "./core/InstanceManager.js"
13
13
  import { ParticleSystem } from "./core/ParticleSystem.js"
14
14
  import { ParticleEmitter } from "./core/ParticleEmitter.js"
15
15
  import { DebugUI } from "./DebugUI.js"
16
+ import { Raycaster } from "./utils/Raycaster.js"
16
17
 
17
18
 
18
19
  // Display a failure message and stop rendering
@@ -77,7 +78,7 @@ const DEFAULT_SETTINGS = {
77
78
  fxaa: false, // Fast approximate anti-aliasing
78
79
  renderScale: 1, // Render resolution multiplier (1.5-2.0 for supersampling AA)
79
80
  autoScale: {
80
- enabled: false, // Auto-reduce renderScale for high resolutions
81
+ enabled: true, // Auto-reduce renderScale for high resolutions
81
82
  enabledForEffects: true,// Auto scale effects at high resolutions (when main autoScale disabled)
82
83
  maxHeight: 1536, // Height threshold (above this, scale is reduced)
83
84
  scaleFactor: 0.5, // Factor to apply when above threshold
@@ -91,6 +92,7 @@ const DEFAULT_SETTINGS = {
91
92
  alphaHash: false, // Enable alpha hashing/dithering for cutout transparency (global default)
92
93
  alphaHashScale: 1.0, // Scale factor for alpha hash threshold (higher = more opaque)
93
94
  luminanceToAlpha: false, // Derive alpha from color luminance (for old game assets where black=transparent)
95
+ tonemapMode: 0, // 0=ACES, 1=Reinhard, 2=None (linear clamp)
94
96
  },
95
97
 
96
98
  // Noise settings for dithering, jittering, etc.
@@ -128,11 +130,12 @@ const DEFAULT_SETTINGS = {
128
130
  exposure: 1.6,
129
131
  fog: {
130
132
  enabled: true,
131
- color: [0.8, 0.85, 0.9],
132
- distances: [6, 15, 50],
133
+ color: [100/255.0, 135/255.0, 170/255.0],
134
+ distances: [0, 15, 50],
133
135
  alpha: [0.0, 0.5, 0.9],
134
136
  heightFade: [-2, 185], // [bottomY, topY] - full fog at bottomY, zero at topY
135
- brightResist: 0.2 // How much bright/emissive colors resist fog (0-1)
137
+ brightResist: 0.0, // How much bright/emissive colors resist fog (0-1)
138
+ debug: 0,
136
139
  }
137
140
  },
138
141
 
@@ -158,6 +161,8 @@ const DEFAULT_SETTINGS = {
158
161
  normalBias: 0.015, // ~2-3 texels for shadow acne
159
162
  surfaceBias: 0, // Scale shadow projection larger (0.01 = 1% larger)
160
163
  strength: 1.0,
164
+ //frustum: false,
165
+ //hiZ: false,
161
166
  },
162
167
 
163
168
  // Ambient Occlusion settings
@@ -242,6 +247,33 @@ const DEFAULT_SETTINGS = {
242
247
  saturateLevel: 0.5, // Logarithmic saturation level for indirect light
243
248
  },
244
249
 
250
+ // Volumetric Fog (light scattering through particles)
251
+ volumetricFog: {
252
+ enabled: false, // Disabled by default (performance impact)
253
+ resolution: 0.125, // 1/4 render resolution for ray marching
254
+ maxSamples: 32, // Ray march samples (8-32)
255
+ blurRadius: 8.0, // Gaussian blur radius
256
+ densityMultiplier: 1.0, // Multiplies base fog density
257
+ scatterStrength: 0.35, // Light scattering intensity
258
+ mainLightScatter: 1.4, // Main directional light scattering boost
259
+ mainLightScatterDark: 5.0, // Main directional light scattering boost
260
+ mainLightSaturation: 0.15, // Main light color saturation in fog
261
+ maxFogOpacity: 0.3, // Maximum fog opacity (0-1)
262
+ heightRange: [-2, 8], // [bottom, top] Y bounds for fog (low ground fog)
263
+ windDirection: [1, 0, 0.2], // Wind direction for fog animation
264
+ windSpeed: 0.5, // Wind speed multiplier
265
+ noiseScale: 0.9, // 3D noise frequency (higher = finer detail)
266
+ noiseStrength: 0.8, // Noise intensity (0 = uniform, 1 = full variation)
267
+ noiseOctaves: 6, // Noise detail layers
268
+ noiseEnabled: true, // Enable 3D noise (disable for debug)
269
+ lightingEnabled: true, // Light fog from scene lights
270
+ shadowsEnabled: true, // Apply shadows to fog
271
+ brightnessThreshold: 0.8, // Scene luminance where fog starts fading (like bloom)
272
+ minVisibility: 0.15, // Minimum fog visibility over bright surfaces (0-1)
273
+ skyBrightness: 1.2, // Virtual brightness for sky pixels (depth at far plane)
274
+ //debugSkyCheck: true
275
+ },
276
+
245
277
  // Planar Reflections (alternative to SSR for water/floor)
246
278
  planarReflection: {
247
279
  enabled: true, // Disabled by default (use SSR instead)
@@ -279,6 +311,40 @@ const DEFAULT_SETTINGS = {
279
311
  fpsThreshold: 60, // FPS threshold for auto-disable
280
312
  disableDelay: 3.0, // Seconds below threshold before disabling
281
313
  },
314
+
315
+ // CRT effect (retro monitor simulation)
316
+ crt: {
317
+ enabled: false, // Enable CRT effect (geometry, scanlines, etc.)
318
+ upscaleEnabled: false, // Enable upscaling (pixelated look) even when CRT disabled
319
+ upscaleTarget: 4, // Target upscale multiplier (4x render resolution)
320
+ maxTextureSize: 4096, // Max upscaled texture dimension
321
+
322
+ // Geometry distortion
323
+ curvature: 0.14, // Screen curvature amount (0-0.15)
324
+ cornerRadius: 0.055, // Rounded corner radius (0-0.1)
325
+ zoom: 1.06, // Zoom to compensate for curvature shrinkage
326
+
327
+ // Scanlines (electron beam simulation - Gaussian profile)
328
+ scanlineIntensity: 0.4, // Scanline effect strength (0-1)
329
+ scanlineWidth: 0.0, // Beam width (0=thin/center only, 1=no gap)
330
+ scanlineBrightBoost: 0.8, // Bright pixels widen beam to fill gaps (0-1)
331
+ scanlineHeight: 5, // Scanline height in canvas pixels
332
+
333
+ // RGB convergence error (color channel misalignment)
334
+ convergence: [0.79, 0.0, -0.77], // RGB X offset in source pixels
335
+
336
+ // Phosphor mask
337
+ maskType: 'aperture', // 'aperture', 'slot', 'shadow', 'none'
338
+ maskIntensity: 0.25, // Mask strength (0-1)
339
+ maskScale: 1.0, // Mask size multiplier
340
+
341
+ // Vignette (edge darkening)
342
+ vignetteIntensity: 0.54, // Edge darkening strength (0-1)
343
+ vignetteSize: 0.85, // Vignette size (larger = more visible)
344
+
345
+ // Horizontal blur (beam softness)
346
+ blurSize: 0.79, // Horizontal blur in pixels (0-2)
347
+ },
282
348
  }
283
349
 
284
350
  // Function to create WebGPU context
@@ -377,10 +443,27 @@ async function createWebGPUContext(engine, canvasId) {
377
443
  engine.rendering = true
378
444
 
379
445
  function configureContext() {
380
- const devicePixelRatio = window.devicePixelRatio || 1
381
- const renderScale = engine.renderScale || 1.0
382
- canvas.width = Math.floor(canvas.clientWidth * devicePixelRatio * renderScale) | 0
383
- canvas.height = Math.floor(canvas.clientHeight * devicePixelRatio * renderScale) | 0
446
+ // Use exact device pixel size if available (from ResizeObserver)
447
+ // This ensures pixel-perfect rendering for CRT effects
448
+ let pixelWidth, pixelHeight
449
+ if (engine._devicePixelSize) {
450
+ pixelWidth = engine._devicePixelSize.width
451
+ pixelHeight = engine._devicePixelSize.height
452
+ } else {
453
+ // Fallback to clientWidth * devicePixelRatio
454
+ const devicePixelRatio = window.devicePixelRatio || 1
455
+ pixelWidth = Math.round(canvas.clientWidth * devicePixelRatio)
456
+ pixelHeight = Math.round(canvas.clientHeight * devicePixelRatio)
457
+ }
458
+
459
+ // Canvas is ALWAYS at full device pixel resolution for pixel-perfect CRT
460
+ // Render scale only affects internal render passes, not the final canvas
461
+ canvas.width = pixelWidth
462
+ canvas.height = pixelHeight
463
+
464
+ // Store device pixel size for CRT pass
465
+ engine._canvasPixelSize = { width: pixelWidth, height: pixelHeight }
466
+
384
467
  context.configure({
385
468
  device: device,
386
469
  format: canvasFormat,
@@ -507,9 +590,40 @@ class Engine {
507
590
  this.stats.avg_dt_render = 0.1
508
591
 
509
592
  requestAnimationFrame(() => this._frame())
510
- window.addEventListener("resize", () => {
511
- this.needsResize = true
512
- })
593
+
594
+ // Use ResizeObserver with devicePixelContentBoxSize for pixel-perfect sizing
595
+ this._devicePixelSize = null
596
+ try {
597
+ const resizeObserver = new ResizeObserver((entries) => {
598
+ for (const entry of entries) {
599
+ // Prefer devicePixelContentBoxSize for exact device pixels
600
+ if (entry.devicePixelContentBoxSize) {
601
+ const size = entry.devicePixelContentBoxSize[0]
602
+ this._devicePixelSize = {
603
+ width: size.inlineSize,
604
+ height: size.blockSize
605
+ }
606
+ } else if (entry.contentBoxSize) {
607
+ // Fallback to contentBoxSize * devicePixelRatio
608
+ const size = entry.contentBoxSize[0]
609
+ const dpr = window.devicePixelRatio || 1
610
+ this._devicePixelSize = {
611
+ width: Math.round(size.inlineSize * dpr),
612
+ height: Math.round(size.blockSize * dpr)
613
+ }
614
+ }
615
+ this.needsResize = true
616
+ }
617
+ })
618
+ resizeObserver.observe(this.canvas, { box: 'device-pixel-content-box' })
619
+ } catch (e) {
620
+ // Fallback if device-pixel-content-box not supported
621
+ console.log('ResizeObserver device-pixel-content-box not supported, falling back to window resize')
622
+ window.addEventListener("resize", () => {
623
+ this.needsResize = true
624
+ })
625
+ }
626
+
513
627
  setInterval(() => {
514
628
  if (this.needsResize && !this._resizing) {
515
629
  this.needsResize = false
@@ -652,12 +766,22 @@ class Engine {
652
766
  * @param {Array} options.position - Optional position offset [x, y, z]
653
767
  * @param {Array} options.rotation - Optional rotation offset [x, y, z] in radians
654
768
  * @param {number} options.scale - Optional uniform scale multiplier
769
+ * @param {boolean} options.doubleSided - Optional: force all materials to be double-sided
655
770
  * @returns {Promise<Object>} Object containing { meshes, nodes, skins, animations }
656
771
  */
657
772
  async loadScene(url, options = {}) {
658
773
  const result = await loadGltf(this, url, options)
659
774
  const { meshes, nodes } = result
660
775
 
776
+ // Apply scene-wide doubleSided option if specified
777
+ if (options.doubleSided) {
778
+ for (const mesh of Object.values(meshes)) {
779
+ if (mesh.material) {
780
+ mesh.material.doubleSided = true
781
+ }
782
+ }
783
+ }
784
+
661
785
  // Update node world matrices from their hierarchy
662
786
  // This handles Blender's Z-up to Y-up rotation in parent nodes
663
787
  for (const node of nodes) {
@@ -685,6 +809,30 @@ class Engine {
685
809
  )
686
810
  }
687
811
 
812
+ // For skinned models with multiple submeshes, compute a combined bounding sphere
813
+ // This ensures all submeshes are culled together as a unit (especially for shadows)
814
+ let combinedBsphere = null
815
+ const hasAnySkin = Object.values(meshes).some(m => m.hasSkin)
816
+
817
+ if (hasAnySkin) {
818
+ // Collect all vertex positions from ALL meshes
819
+ const allPositions = []
820
+ for (const mesh of Object.values(meshes)) {
821
+ const positions = mesh.geometry?.attributes?.position
822
+ if (positions) {
823
+ for (let i = 0; i < positions.length; i += 3) {
824
+ allPositions.push(positions[i], positions[i + 1], positions[i + 2])
825
+ }
826
+ }
827
+ }
828
+
829
+ if (allPositions.length > 0) {
830
+ // Calculate combined bounding sphere
831
+ const { calculateBoundingSphere } = await import('./utils/BoundingSphere.js')
832
+ combinedBsphere = calculateBoundingSphere(new Float32Array(allPositions))
833
+ }
834
+ }
835
+
688
836
  // For each mesh, find its node and compute world transform
689
837
  for (const [name, mesh] of Object.entries(meshes)) {
690
838
  // Find the node that references this mesh by nodeIndex
@@ -705,7 +853,8 @@ class Engine {
705
853
  }
706
854
 
707
855
  // Compute world bounding sphere from geometry bsphere + world transform
708
- const localBsphere = mesh.geometry.getBoundingSphere?.()
856
+ // For skinned models, use the combined bsphere so all submeshes are culled together
857
+ const localBsphere = (hasAnySkin && combinedBsphere) ? combinedBsphere : mesh.geometry.getBoundingSphere?.()
709
858
  let worldCenter = [0, 0, 0]
710
859
  let worldRadius = 1
711
860
 
@@ -724,6 +873,11 @@ class Engine {
724
873
  worldRadius = localBsphere.radius * Math.max(scaleX, scaleY, scaleZ)
725
874
  }
726
875
 
876
+ // Store combined bsphere on mesh for shadow pass culling
877
+ if (hasAnySkin && combinedBsphere) {
878
+ mesh.combinedBsphere = combinedBsphere
879
+ }
880
+
727
881
  // Add instance with world bounding sphere
728
882
  mesh.addInstance(worldCenter, worldRadius)
729
883
  mesh.updateInstance(0, worldMatrix)
@@ -816,6 +970,17 @@ class Engine {
816
970
  return this.entityManager.get(id)
817
971
  }
818
972
 
973
+ /**
974
+ * Invalidate occlusion culling data and reset warmup period.
975
+ * Call this after scene loading or major camera teleportation to prevent
976
+ * incorrect occlusion culling with stale depth buffer data.
977
+ */
978
+ invalidateOcclusionCulling() {
979
+ if (this.renderer) {
980
+ this.renderer.invalidateOcclusionCulling()
981
+ }
982
+ }
983
+
819
984
  async _create() {
820
985
  let camera = new Camera(this) // Pass engine reference
821
986
  camera.updateMatrix()
@@ -848,6 +1013,10 @@ class Engine {
848
1013
 
849
1014
  async _after_create() {
850
1015
  this.renderer = await RenderGraph.create(this, this.environment, this.environmentEncoding)
1016
+
1017
+ // Initialize raycaster for async ray intersection tests
1018
+ this.raycaster = new Raycaster(this)
1019
+ await this.raycaster.initialize()
851
1020
  }
852
1021
 
853
1022
  _update(dt) {
@@ -971,7 +1140,9 @@ class Engine {
971
1140
  this.guiCtx.clearRect(0, 0, canvas.width, canvas.height)
972
1141
  }
973
1142
 
974
- await this.renderer.resize(canvas.width, canvas.height)
1143
+ // Pass render scale to RenderGraph - internal passes use scaled dimensions
1144
+ // CRT pass will still output at full canvas resolution
1145
+ await this.renderer.resize(canvas.width, canvas.height, this.renderScale)
975
1146
  this.resize()
976
1147
 
977
1148
  // Small delay before allowing renders to ensure all GPU resources are ready
@@ -1321,4 +1492,5 @@ export {
1321
1492
  ParticleSystem,
1322
1493
  ParticleEmitter,
1323
1494
  DebugUI,
1495
+ Raycaster,
1324
1496
  }