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.
Files changed (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. package/src/utils.ts +0 -403
@@ -0,0 +1,1496 @@
1
+ import "./math.js"
2
+ import { Texture } from "./Texture.js"
3
+ import { Geometry } from "./Geometry.js";
4
+ import { Material } from "./Material.js";
5
+ import { Mesh } from "./Mesh.js";
6
+ import { Camera } from "./Camera.js"
7
+ import { RenderGraph } from "./rendering/RenderGraph.js"
8
+ import { loadGltf } from "./gltf.js"
9
+ import { EntityManager } from "./core/EntityManager.js"
10
+ import { AssetManager } from "./core/AssetManager.js"
11
+ import { CullingSystem } from "./core/CullingSystem.js"
12
+ import { InstanceManager } from "./core/InstanceManager.js"
13
+ import { ParticleSystem } from "./core/ParticleSystem.js"
14
+ import { ParticleEmitter } from "./core/ParticleEmitter.js"
15
+ import { DebugUI } from "./DebugUI.js"
16
+ import { Raycaster } from "./utils/Raycaster.js"
17
+
18
+
19
+ // Display a failure message and stop rendering
20
+ function fail(engine, msg, data) {
21
+ if (engine?.canvas) {
22
+ engine.canvas.style.display = "none"
23
+ }
24
+ console.error(msg, data)
25
+
26
+ if (typeof document !== 'undefined') {
27
+ let ecanvas = document.createElement("canvas")
28
+ document.body.appendChild(ecanvas)
29
+ if (ecanvas) {
30
+ ecanvas.width = window.innerWidth
31
+ ecanvas.height = window.innerHeight
32
+ let ctx = ecanvas.getContext("2d")
33
+ ctx.clearRect(0, 0, ecanvas.width, ecanvas.height)
34
+ ctx.fillStyle = "rgba(255, 128, 128, 0.5)"
35
+ ctx.fillRect(0, 0, ecanvas.width, ecanvas.height)
36
+ ctx.fillStyle = "#ffffff"
37
+ ctx.textAlign = "center"
38
+ ctx.textBaseline = "middle"
39
+ ctx.font = "64px Arial"
40
+ ctx.fillText("😒", ecanvas.width / 2, ecanvas.height / 2 - 72)
41
+ ctx.font = "bold 20px Arial"
42
+ ctx.fillText(msg, ecanvas.width / 2, ecanvas.height / 2)
43
+ if (data) {
44
+ ctx.font = "10px Arial"
45
+ ctx.fillText(data, ecanvas.width / 2, ecanvas.height / 2 + 20)
46
+ }
47
+ } else {
48
+ alert(msg, data)
49
+ }
50
+ }
51
+ if (engine) {
52
+ engine.rendering = false
53
+ }
54
+ }
55
+
56
+
57
+ // Default settings for the entire engine - consolidated from all subsystems
58
+ const DEFAULT_SETTINGS = {
59
+ // Engine/Runtime settings
60
+ engine: {
61
+ debugMode: false, // F10 toggle: false=character controller, true=fly camera
62
+ mouseSmoothing: 0.2, // Lower = more smoothing
63
+ mouseIdleThreshold: 0.1, // Seconds before stopping mouse callbacks
64
+ },
65
+
66
+ // Camera defaults
67
+ camera: {
68
+ fov: 70, // Field of view in degrees
69
+ near: 0.05, // Near plane
70
+ far: 5000, // Far plane
71
+ },
72
+
73
+ // Rendering options
74
+ rendering: {
75
+ debug: false,
76
+ nearestFiltering: false, // Use linear filtering by default
77
+ mipBias: 0, // MIP map bias
78
+ fxaa: false, // Fast approximate anti-aliasing
79
+ renderScale: 1, // Render resolution multiplier (1.5-2.0 for supersampling AA)
80
+ autoScale: {
81
+ enabled: true, // Auto-reduce renderScale for high resolutions
82
+ enabledForEffects: true,// Auto scale effects at high resolutions (when main autoScale disabled)
83
+ maxHeight: 1536, // Height threshold (above this, scale is reduced)
84
+ scaleFactor: 0.5, // Factor to apply when above threshold
85
+ },
86
+ jitter: false, // TAA-like sub-pixel jitter
87
+ jitterAmount: 0.37, // Jitter amplitude in pixels
88
+ jitterFadeDistance: 25.0, // Distance at which jitter fades to 0
89
+ pixelRounding: 0, // Pixel grid size for vertex snapping (0=off, 1=every pixel, 2=every 2px, etc.)
90
+ pixelExpansion: 0, // Sub-pixel expansion to convert gaps to overlaps (0=off, 0.05=default)
91
+ positionRounding: 0, // Round view-space position to this precision (0 = disabled, simulates fixed-point)
92
+ alphaHash: false, // Enable alpha hashing/dithering for cutout transparency (global default)
93
+ alphaHashScale: 1.0, // Scale factor for alpha hash threshold (higher = more opaque)
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)
96
+ },
97
+
98
+ // Noise settings for dithering, jittering, etc.
99
+ noise: {
100
+ type: 'bluenoise', // 'bluenoise', 'bayer8' (8x8 ordered dither)
101
+ animated: false, // Animate noise offset each frame (temporal variation)
102
+ },
103
+
104
+ // Dithering settings (PS1-style color quantization)
105
+ dithering: {
106
+ enabled: false, // Enable/disable dithering
107
+ colorLevels: 32, // Color levels per channel (32 = 5-bit like PS1, 256 = 8-bit, 16 = 4-bit)
108
+ },
109
+
110
+ // Bloom/Glare settings (HDR glow effect)
111
+ bloom: {
112
+ enabled: true, // Enable/disable bloom
113
+ intensity: 0.12, // Overall bloom intensity
114
+ threshold: 0.98, // Brightness threshold (pixels below this contribute exponentially less)
115
+ softThreshold: 0.5, // Soft knee for threshold (0 = hard, 1 = very soft)
116
+ radius: 64, // Blur radius in pixels (scaled by renderScale)
117
+ emissiveBoost: 6.0, // Extra boost for emissive pixels
118
+ maxBrightness: 6.0, // Clamp input brightness (prevents specular halos)
119
+ scale: 0.5, // Resolution scale (0.5 = half res for performance, 1.0 = full)
120
+ },
121
+
122
+ // Environment/IBL settings
123
+ environment: {
124
+ texture: "alps_field.jpg", // .jpg = octahedral RGBM pair, .hdr = equirectangular
125
+ //texture: "alps_field_8k.hdr",
126
+ diffuse: 3.0,
127
+ specular: 1.0,
128
+ emissionFactor: [1.0, 1.0, 1.0, 1.0],
129
+ ambientColor: [0.7, 0.75, 0.9, 0.1],
130
+ exposure: 1.6,
131
+ fog: {
132
+ enabled: true,
133
+ color: [100/255.0, 135/255.0, 170/255.0],
134
+ distances: [0, 15, 50],
135
+ alpha: [0.0, 0.5, 0.9],
136
+ heightFade: [-2, 185], // [bottomY, topY] - full fog at bottomY, zero at topY
137
+ brightResist: 0.0, // How much bright/emissive colors resist fog (0-1)
138
+ debug: 0,
139
+ }
140
+ },
141
+
142
+ // Main directional light
143
+ mainLight: {
144
+ enabled: true,
145
+ intensity: 1,
146
+ color: [1.0, 0.78, 0.47], // Mid-morning / mid-afternoon (solar elevation ~20°–40°, less red) - soft warm white
147
+ direction: [-0.4, 0.45, 0.35],
148
+ },
149
+
150
+ // Shadow settings
151
+ shadow: {
152
+ mapSize: 2048,
153
+ cascadeCount: 3,
154
+ cascadeSizes: [10, 25, 125], // Half-widths in meters
155
+ maxSpotShadows: 16,
156
+ spotTileSize: 512,
157
+ spotAtlasSize: 2048,
158
+ spotMaxDistance: 60, // No spot shadow beyond this distance
159
+ spotFadeStart: 55, // Spot shadow fade out starts here
160
+ bias: 0.0005,
161
+ normalBias: 0.015, // ~2-3 texels for shadow acne
162
+ surfaceBias: 0, // Scale shadow projection larger (0.01 = 1% larger)
163
+ strength: 1.0,
164
+ //frustum: false,
165
+ //hiZ: false,
166
+ },
167
+
168
+ // Ambient Occlusion settings
169
+ ao: {
170
+ enabled: true, // Enable/disable AO
171
+ intensity: 1.6, // Overall AO strength
172
+ radius: 64.0, // Sample radius in pixels
173
+ fadeDistance: 40.0, // Distance at which AO fades to 0
174
+ bias: 0.005, // Depth bias to avoid self-occlusion
175
+ sampleCount: 16, // Number of samples
176
+ level: 1, // AO level multiplier
177
+ },
178
+
179
+ // Lighting pass settings
180
+ lighting: {
181
+ maxLights: 768,
182
+ tileSize: 16, // Tile size for tiled deferred
183
+ maxLightsPerTile: 256,
184
+ maxDistance: 250, // Max distance for point lights from camera
185
+ cullingEnabled: true,
186
+ directSpecularMultiplier: 3.0, // Multiplier for direct light specular highlights
187
+ specularBoost: 64.0, // Extra specular from 3 fake lights (0 = disabled)
188
+ specularBoostRoughnessCutoff: 0.70, // Only boost materials with roughness < this
189
+ },
190
+
191
+ // Culling configuration per pass type
192
+ culling: {
193
+ frustumEnabled: true, // Enable frustum culling
194
+ shadow: {
195
+ frustum: true, // Enable frustum culling using shadow bounding spheres
196
+ hiZ: true, // Enable HiZ occlusion culling using shadow bounding spheres
197
+ cascadeFilter: true, // Enable per-cascade instance filtering
198
+ maxDistance: 250,
199
+ maxSkinned: 32,
200
+ minPixelSize: 1,
201
+ fadeStart: 0.8, // Distance fade starts at 80% of maxDistance
202
+ },
203
+ reflection: {
204
+ frustum: true,
205
+ maxDistance: 50,
206
+ maxSkinned: 0,
207
+ minPixelSize: 4,
208
+ fadeStart: 0.8,
209
+ },
210
+ planarReflection: {
211
+ frustum: true,
212
+ maxDistance: 40,
213
+ maxSkinned: 32,
214
+ minPixelSize: 4,
215
+ fadeStart: 0.7, // Earlier fade for reflections (70%)
216
+ },
217
+ main: {
218
+ frustum: true,
219
+ maxDistance: 250,
220
+ maxSkinned: 500,
221
+ minPixelSize: 2,
222
+ fadeStart: 0.8, // Distance fade starts at 90% of maxDistance
223
+ },
224
+ },
225
+
226
+ // HiZ Occlusion Culling - uses previous frame's depth buffer
227
+ occlusionCulling: {
228
+ enabled: true, // Enable HiZ occlusion culling
229
+ threshold: 0.7, // Depth threshold multiplier (0.5 = 50% of maxZ, 1.0 = 100%)
230
+ positionThreshold: 1.0, // Camera movement (units) before invalidation
231
+ rotationThreshold: 0.1, // Camera rotation (radians, ~1 deg) before invalidation
232
+ maxTileSpan: 16, // Max tiles a bounding sphere can span for occlusion test
233
+ },
234
+
235
+ // Skinned mesh rendering
236
+ skinning: {
237
+ individualRenderDistance: 20.0, // Proximity threshold for individual rendering
238
+ },
239
+
240
+ // Screen Space Global Illumination (tile-based light propagation)
241
+ ssgi: {
242
+ enabled: true,
243
+ intensity: 1.0, // GI intensity multiplier
244
+ emissiveBoost: 2.0, // Boost factor for emissive surfaces
245
+ maxBrightness: 4.0, // Clamp luminance (excludes specular highlights)
246
+ sampleRadius: 3, // Vogel disk sample radius in tiles
247
+ saturateLevel: 0.5, // Logarithmic saturation level for indirect light
248
+ },
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
+
277
+ // Planar Reflections (alternative to SSR for water/floor)
278
+ planarReflection: {
279
+ enabled: true, // Disabled by default (use SSR instead)
280
+ groundLevel: 0.1, // Y coordinate of reflection plane (real-time adjustable)
281
+ resolution: 1, // Resolution multiplier (0.5 = half res)
282
+ roughnessCutoff: 0.4, // Only reflect on surfaces with roughness < this
283
+ normalPerturbation: 0.25, // Amount of normal-based distortion (for water)
284
+ blurSamples: 4, // Blur samples based on roughness
285
+ intensity: 1.0, // Reflection brightness (0.9 = 90% for realism)
286
+ distanceFade: 0.5, // Distance from ground for full reflection (meters)
287
+ },
288
+
289
+ // Ambient Capture (6-directional sky-aware GI)
290
+ ambientCapture: {
291
+ enabled: true, // Enable 6-directional ambient capture
292
+ intensity: 1.0, // Output intensity multiplier (subtle effect)
293
+ maxDistance: 50, // Distance fade & culling in meters
294
+ resolution: 64, // Capture resolution per face (default 64)
295
+ emissiveBoost: 10.0, // Boost for emissive surfaces in capture
296
+ smoothingTime: 0.3, // Temporal smoothing duration in seconds
297
+ saturateLevel: 0.2, // Logarithmic saturation level (0 = disabled)
298
+ },
299
+
300
+ // Temporal accumulation settings
301
+ temporal: {
302
+ blendFactor: 0.5, // Default history blend (conservative)
303
+ motionScale: 10.0, // Motion rejection sensitivity
304
+ depthThreshold: 0.1, // Depth rejection threshold
305
+ normalThreshold: 0.9, // Normal rejection threshold (dot product)
306
+ },
307
+
308
+ // Performance auto-tuning
309
+ performance: {
310
+ autoDisable: true, // Auto-disable SSR/SSGI on low FPS
311
+ fpsThreshold: 60, // FPS threshold for auto-disable
312
+ disableDelay: 3.0, // Seconds below threshold before disabling
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
+ },
348
+ }
349
+
350
+ // Function to create WebGPU context
351
+ async function createWebGPUContext(engine, canvasId) {
352
+ try {
353
+ const canvas = document.getElementById(canvasId)
354
+ if (!canvas) throw new Error(`Canvas with id ${canvasId} not found`)
355
+ engine.canvas = canvas
356
+
357
+ // Detailed WebGPU availability check
358
+ console.log("WebGPU check:", {
359
+ hasNavigatorGpu: !!navigator.gpu,
360
+ isSecureContext: window.isSecureContext,
361
+ protocol: window.location.protocol,
362
+ hostname: window.location.hostname
363
+ })
364
+
365
+ if (!navigator.gpu) {
366
+ if (!window.isSecureContext) {
367
+ throw new Error("WebGPU requires HTTPS or localhost (secure context)")
368
+ }
369
+ throw new Error("WebGPU not supported on this browser.")
370
+ }
371
+
372
+ // Try high-performance adapter first, fall back to any adapter
373
+ let adapter = await navigator.gpu.requestAdapter({
374
+ powerPreference: 'high-performance',
375
+ })
376
+ if (!adapter) {
377
+ console.warn("High-performance adapter not found, trying default...")
378
+ adapter = await navigator.gpu.requestAdapter()
379
+ }
380
+ if (!adapter) throw new Error("No appropriate GPUAdapter found.")
381
+
382
+ // Log adapter info for debugging
383
+ const adapterInfo = await adapter.requestAdapterInfo?.() || {}
384
+ console.log("WebGPU Adapter:", adapterInfo.vendor, adapterInfo.device, adapterInfo.description)
385
+ console.log("Adapter limits:", {
386
+ maxColorAttachmentBytesPerSample: adapter.limits.maxColorAttachmentBytesPerSample,
387
+ maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
388
+ maxBufferSize: adapter.limits.maxBufferSize
389
+ })
390
+
391
+ const canTimestamp = adapter.features.has('timestamp-query');
392
+ const requiredFeatures = []
393
+ if (canTimestamp) {
394
+ requiredFeatures.push('timestamp-query')
395
+ engine.canTimestamp = true
396
+ } else {
397
+ engine.canTimestamp = false
398
+ }
399
+
400
+ // Request higher limits for GBuffer with multiple render targets
401
+ // Default is 32 bytes, but we need 36+ for albedo + normal + ARM + emission + velocity
402
+ const requiredLimits = {}
403
+ const adapterLimits = adapter.limits
404
+ if (adapterLimits.maxColorAttachmentBytesPerSample >= 64) {
405
+ requiredLimits.maxColorAttachmentBytesPerSample = 64
406
+ } else if (adapterLimits.maxColorAttachmentBytesPerSample >= 48) {
407
+ requiredLimits.maxColorAttachmentBytesPerSample = 48
408
+ }
409
+
410
+ // Try to create device with requested features/limits, fall back if it fails
411
+ let device
412
+ try {
413
+ device = await adapter.requestDevice({
414
+ requiredFeatures: requiredFeatures,
415
+ requiredLimits: requiredLimits
416
+ })
417
+ } catch (deviceError) {
418
+ console.warn("Device creation failed with custom limits, trying defaults...", deviceError)
419
+ // Try without custom limits
420
+ try {
421
+ device = await adapter.requestDevice({
422
+ requiredFeatures: requiredFeatures
423
+ })
424
+ } catch (deviceError2) {
425
+ console.warn("Device creation failed with features, trying minimal...", deviceError2)
426
+ // Try with no features at all
427
+ device = await adapter.requestDevice()
428
+ engine.canTimestamp = false
429
+ }
430
+ }
431
+
432
+ if (!device) throw new Error("Failed to create GPU device")
433
+
434
+ const context = canvas.getContext("webgpu")
435
+ if (!context) throw new Error("Failed to get WebGPU context from canvas")
436
+
437
+ const canvasFormat = navigator.gpu.getPreferredCanvasFormat()
438
+
439
+ engine.adapter = adapter
440
+ engine.device = device
441
+ engine.context = context
442
+ engine.canvasFormat = canvasFormat
443
+ engine.rendering = true
444
+
445
+ function configureContext() {
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
+
467
+ context.configure({
468
+ device: device,
469
+ format: canvasFormat,
470
+ alphaMode: "opaque",
471
+ })
472
+ }
473
+
474
+ configureContext()
475
+ engine.configureContext = configureContext
476
+
477
+ // Make available globally for debugging
478
+ if (typeof window !== 'undefined') {
479
+ window.engine = engine
480
+ }
481
+ } catch (error) {
482
+ console.error("WebGPU initialization failed:", error)
483
+ // Provide more specific error message
484
+ let errorTitle = "WebGPU Error"
485
+ let errorDetail = error.message
486
+ if (error.message.includes("not supported")) {
487
+ errorTitle = "WebGPU Not Available"
488
+ errorDetail = "Check if WebGPU is enabled in browser flags"
489
+ } else if (error.message.includes("Adapter")) {
490
+ errorTitle = "GPU Not Found"
491
+ errorDetail = "No compatible GPU adapter found"
492
+ } else if (error.message.includes("device")) {
493
+ errorTitle = "Device Creation Failed"
494
+ errorDetail = error.message + " - Try updating GPU drivers"
495
+ }
496
+ fail(engine, errorTitle, errorDetail)
497
+ }
498
+ return engine
499
+ }
500
+
501
+ /**
502
+ * Deep merge source into target (mutates target)
503
+ * Arrays are replaced, not merged
504
+ */
505
+ function deepMerge(target, source) {
506
+ if (!source) return target
507
+ for (const key in source) {
508
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
509
+ if (!target[key] || typeof target[key] !== 'object') {
510
+ target[key] = {}
511
+ }
512
+ deepMerge(target[key], source[key])
513
+ } else {
514
+ target[key] = source[key]
515
+ }
516
+ }
517
+ return target
518
+ }
519
+
520
+ class Engine {
521
+
522
+ constructor(settings = {}) {
523
+ this.lastTime = performance.now()
524
+ // Deep clone DEFAULT_SETTINGS and merge with provided settings
525
+ this.settings = deepMerge(
526
+ JSON.parse(JSON.stringify(DEFAULT_SETTINGS)),
527
+ settings
528
+ )
529
+
530
+ // GPU state properties (populated by createWebGPUContext)
531
+ this.device = null
532
+ this.context = null
533
+ this.canvas = null
534
+ this.canvasFormat = null
535
+ this.canTimestamp = false
536
+ this.configureContext = null
537
+
538
+ // Runtime state
539
+ this.rendering = true
540
+ this._renderInProgress = false // Prevents frame pileup when GPU is slow
541
+ this.renderTextures = []
542
+ this.renderScale = 1
543
+ this.options = {
544
+ debug: false,
545
+ nearestFiltering: false,
546
+ mipBias: 0,
547
+ }
548
+ this.stats = {
549
+ fps: 0,
550
+ ms: 0,
551
+ drawCalls: 0,
552
+ triangles: 0,
553
+ }
554
+
555
+ // Debug UI (lazy initialization - created on first debug mode)
556
+ this.debugUI = new DebugUI(this)
557
+
558
+ this.init()
559
+ }
560
+
561
+ // Convenience getter/setter for debugMode (used frequently)
562
+ get debugMode() { return this.settings.engine.debugMode }
563
+ set debugMode(value) { this.settings.engine.debugMode = value }
564
+
565
+ async init() {
566
+ try {
567
+ await createWebGPUContext(this, "webgpu-canvas")
568
+ if (!this.rendering) return
569
+
570
+ // Legacy mesh storage (for backward compatibility)
571
+ this.meshes = {}
572
+
573
+ // New data-oriented systems
574
+ this.entityManager = new EntityManager()
575
+ this.assetManager = new AssetManager(this)
576
+
577
+ // Expose for convenience
578
+ this.entities = this.entityManager.entities
579
+ this.assets = this.assetManager.assets
580
+
581
+ await this._create()
582
+ await this.create()
583
+ await this._after_create()
584
+
585
+ this._lastTime = performance.now()
586
+ this.time = 0.0
587
+ this.frame = 0
588
+ this.stats.avg_dt = 17
589
+ this.stats.avg_fps = 60
590
+ this.stats.avg_dt_render = 0.1
591
+
592
+ requestAnimationFrame(() => this._frame())
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
+
627
+ setInterval(() => {
628
+ if (this.needsResize && !this._resizing) {
629
+ this.needsResize = false
630
+ this._resize()
631
+ }
632
+ }, 100)
633
+ this._resize()
634
+ } catch (error) {
635
+ fail(this, "Error", error.message)
636
+ console.error(error)
637
+ }
638
+ }
639
+
640
+ _frame() {
641
+ // Skip if previous frame is still rendering (prevents GPU command queue backup)
642
+ if (this._renderInProgress) {
643
+ requestAnimationFrame(() => this._frame())
644
+ return
645
+ }
646
+
647
+ let t1 = performance.now()
648
+ let dt = t1 - this._lastTime
649
+ this._lastTime = t1
650
+ if (this.rendering && dt > 0 && dt < 100 && !this._resizing) {
651
+ this.stats.dt = dt
652
+ this.stats.fps = 1000 / dt
653
+ this.stats.avg_dt = this.stats.avg_dt * 0.98 + this.stats.dt * 0.02
654
+ this.stats.avg_fps = this.stats.avg_fps * 0.98 + this.stats.fps * 0.02
655
+ let dtt = dt / 1000.0
656
+ this.time += dtt
657
+ this._update(dtt)
658
+ this.update(dtt)
659
+ let t2 = performance.now()
660
+
661
+ // Mark render in progress to prevent frame pileup
662
+ this._renderInProgress = true
663
+ this._render().finally(() => {
664
+ this._renderInProgress = false
665
+ })
666
+
667
+ let t3 = performance.now()
668
+ let dtr = t3 - t2
669
+ this.stats.dt_render = dtr
670
+ this.stats.avg_dt_render = this.stats.avg_dt_render * 0.98 + dtr * 0.02
671
+
672
+ // Calculate totals including all passes
673
+ const shadowDC = this.stats.shadowDrawCalls || 0
674
+ const shadowTri = this.stats.shadowTriangles || 0
675
+ const planarDC = this.stats.planarDrawCalls || 0
676
+ const planarTri = this.stats.planarTriangles || 0
677
+ const transparentDC = this.stats.transparentDrawCalls || 0
678
+ const transparentTri = this.stats.transparentTriangles || 0
679
+ const totalDC = this.stats.drawCalls + shadowDC + planarDC + transparentDC
680
+ const totalTri = this.stats.triangles + shadowTri + planarTri + transparentTri
681
+ this.stats.shadowDC = shadowDC
682
+ this.stats.shadowTri = shadowTri
683
+ this.stats.planarDC = planarDC
684
+ this.stats.planarTri = planarTri
685
+ this.stats.transparentDC = transparentDC
686
+ this.stats.transparentTri = transparentTri
687
+ this.stats.totalDC = totalDC
688
+ this.stats.totalTri = totalTri
689
+ this.frame++
690
+
691
+ // Update debug UI (checks debug mode internally, lazy init)
692
+ if (this.debugUI) {
693
+ this.debugUI.update()
694
+ }
695
+ }
696
+ requestAnimationFrame(() => this._frame())
697
+ }
698
+
699
+ async _render() {
700
+ // Pass delta time to renderer for animation updates
701
+ const dt = this.stats.dt ? this.stats.dt / 1000.0 : 0.016
702
+
703
+ await this.renderer.renderEntities({
704
+ entityManager: this.entityManager,
705
+ assetManager: this.assetManager,
706
+ camera: this.camera,
707
+ meshes: this.meshes,
708
+ dt
709
+ })
710
+ }
711
+
712
+ async loadGltf(url, options = {}) {
713
+ const result = await loadGltf(this, url, options)
714
+ // Handle both old format (just meshes) and new format (with skins, animations)
715
+ const meshes = result.meshes || result
716
+ for (const [name, mesh] of Object.entries(meshes)) {
717
+ this.meshes[name] = mesh
718
+ }
719
+ // Store skins and animations for access
720
+ if (result.skins) {
721
+ this.skins = this.skins || []
722
+ this.skins.push(...result.skins)
723
+ }
724
+ if (result.animations) {
725
+ this.animations = this.animations || []
726
+ this.animations.push(...result.animations)
727
+ }
728
+ return result
729
+ }
730
+
731
+ /**
732
+ * Load a GLTF file and register with asset manager (new data-oriented API)
733
+ * @param {string} url - Path to the GLTF file
734
+ * @param {Object} options - Loading options
735
+ * @returns {Promise<Object>} Asset manager entry
736
+ */
737
+ async loadAsset(url, options = {}) {
738
+ const result = await this.assetManager.loadGltfFile(url, options)
739
+
740
+ // Also store in legacy meshes for backward compatibility
741
+ if (result.meshes) {
742
+ for (const [name, mesh] of Object.entries(result.meshes)) {
743
+ this.meshes[name] = mesh
744
+ // Ensure at least one instance exists for rendering
745
+ if (mesh.geometry.instanceCount === 0) {
746
+ // Use geometry bounding sphere for correct shadow culling
747
+ const localBsphere = mesh.geometry.getBoundingSphere?.()
748
+ const center = localBsphere?.center || [0, 0, 0]
749
+ const radius = localBsphere?.radius || 1
750
+ mesh.addInstance(center, radius)
751
+ mesh.updateInstance(0, mat4.create())
752
+ }
753
+ }
754
+ }
755
+
756
+ return result
757
+ }
758
+
759
+ /**
760
+ * Load a GLTF/GLB file and render meshes directly in the scene at their original positions.
761
+ * Unlike loadAsset, this doesn't hide meshes - they render immediately with their transforms.
762
+ * Handles Blender's Z-up coordinate system by respecting the full node hierarchy.
763
+ *
764
+ * @param {string} url - Path to the GLTF/GLB file
765
+ * @param {Object} options - Loading options
766
+ * @param {Array} options.position - Optional position offset [x, y, z]
767
+ * @param {Array} options.rotation - Optional rotation offset [x, y, z] in radians
768
+ * @param {number} options.scale - Optional uniform scale multiplier
769
+ * @param {boolean} options.doubleSided - Optional: force all materials to be double-sided
770
+ * @returns {Promise<Object>} Object containing { meshes, nodes, skins, animations }
771
+ */
772
+ async loadScene(url, options = {}) {
773
+ const result = await loadGltf(this, url, options)
774
+ const { meshes, nodes } = result
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
+
785
+ // Update node world matrices from their hierarchy
786
+ // This handles Blender's Z-up to Y-up rotation in parent nodes
787
+ for (const node of nodes) {
788
+ if (!node.parent) {
789
+ // Root nodes - start the hierarchy update
790
+ node.updateMatrix(null)
791
+ }
792
+ }
793
+
794
+ // Optional root transform from options
795
+ const rootTransform = mat4.create()
796
+ if (options.position || options.rotation || options.scale) {
797
+ const pos = options.position || [0, 0, 0]
798
+ const rot = options.rotation || [0, 0, 0]
799
+ const scl = options.scale || 1
800
+
801
+ const rotQuat = quat.create()
802
+ quat.fromEuler(rotQuat, rot[0] * 180 / Math.PI, rot[1] * 180 / Math.PI, rot[2] * 180 / Math.PI)
803
+
804
+ mat4.fromRotationTranslationScale(
805
+ rootTransform,
806
+ rotQuat,
807
+ pos,
808
+ [scl, scl, scl]
809
+ )
810
+ }
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
+
836
+ // For each mesh, find its node and compute world transform
837
+ for (const [name, mesh] of Object.entries(meshes)) {
838
+ // Find the node that references this mesh by nodeIndex
839
+ let meshNode = null
840
+ if (mesh.nodeIndex !== null && mesh.nodeIndex !== undefined) {
841
+ meshNode = nodes[mesh.nodeIndex]
842
+ }
843
+
844
+ // Compute final world matrix
845
+ const worldMatrix = mat4.create()
846
+ if (meshNode) {
847
+ mat4.copy(worldMatrix, meshNode.world)
848
+ }
849
+
850
+ // Apply optional root transform
851
+ if (options.position || options.rotation || options.scale) {
852
+ mat4.multiply(worldMatrix, rootTransform, worldMatrix)
853
+ }
854
+
855
+ // Compute world bounding sphere from geometry bsphere + world transform
856
+ // For skinned models, use the combined bsphere so all submeshes are culled together
857
+ const localBsphere = (hasAnySkin && combinedBsphere) ? combinedBsphere : mesh.geometry.getBoundingSphere?.()
858
+ let worldCenter = [0, 0, 0]
859
+ let worldRadius = 1
860
+
861
+ if (localBsphere && localBsphere.radius > 0) {
862
+ // Transform local bsphere center by world matrix
863
+ const c = localBsphere.center
864
+ worldCenter = [
865
+ worldMatrix[0] * c[0] + worldMatrix[4] * c[1] + worldMatrix[8] * c[2] + worldMatrix[12],
866
+ worldMatrix[1] * c[0] + worldMatrix[5] * c[1] + worldMatrix[9] * c[2] + worldMatrix[13],
867
+ worldMatrix[2] * c[0] + worldMatrix[6] * c[1] + worldMatrix[10] * c[2] + worldMatrix[14]
868
+ ]
869
+ // Scale radius by the largest axis scale in the transform
870
+ const scaleX = Math.sqrt(worldMatrix[0]**2 + worldMatrix[1]**2 + worldMatrix[2]**2)
871
+ const scaleY = Math.sqrt(worldMatrix[4]**2 + worldMatrix[5]**2 + worldMatrix[6]**2)
872
+ const scaleZ = Math.sqrt(worldMatrix[8]**2 + worldMatrix[9]**2 + worldMatrix[10]**2)
873
+ worldRadius = localBsphere.radius * Math.max(scaleX, scaleY, scaleZ)
874
+ }
875
+
876
+ // Store combined bsphere on mesh for shadow pass culling
877
+ if (hasAnySkin && combinedBsphere) {
878
+ mesh.combinedBsphere = combinedBsphere
879
+ }
880
+
881
+ // Add instance with world bounding sphere
882
+ mesh.addInstance(worldCenter, worldRadius)
883
+ mesh.updateInstance(0, worldMatrix)
884
+
885
+ // Mark as static so instance count doesn't get reset by entity system
886
+ mesh.static = true
887
+
888
+ // Update geometry buffers
889
+ if (mesh.geometry?.update) {
890
+ mesh.geometry.update()
891
+ }
892
+
893
+ // Register mesh for rendering
894
+ this.meshes[name] = mesh
895
+ }
896
+
897
+ // Store skins and animations
898
+ if (result.skins) {
899
+ this.skins = this.skins || []
900
+ this.skins.push(...result.skins)
901
+ }
902
+ if (result.animations) {
903
+ this.animations = this.animations || []
904
+ this.animations.push(...result.animations)
905
+ }
906
+
907
+ return result
908
+ }
909
+
910
+ /**
911
+ * Create a new entity (new data-oriented API)
912
+ * @param {Object} data - Entity data
913
+ * @returns {string} Entity ID
914
+ */
915
+ createEntity(data = {}) {
916
+ const entityId = this.entityManager.create(data)
917
+
918
+ // If entity has a model, ensure asset is loaded
919
+ if (data.model) {
920
+ const { path, meshName } = this.assetManager.parseModelId(data.model)
921
+
922
+ // Check if asset is ready, if so update bounding sphere
923
+ const meshAsset = this.assetManager.get(data.model)
924
+ if (meshAsset) {
925
+ this.entityManager.updateBoundingSphere(entityId, meshAsset.bsphere)
926
+ } else {
927
+ // Register callback to update bsphere when asset loads
928
+ this.assetManager.onReady(data.model, (asset) => {
929
+ this.entityManager.updateBoundingSphere(entityId, asset.bsphere)
930
+ })
931
+ }
932
+ }
933
+
934
+ return entityId
935
+ }
936
+
937
+ /**
938
+ * Update an entity
939
+ * @param {string} id - Entity ID
940
+ * @param {Object} data - Properties to update
941
+ */
942
+ updateEntity(id, data) {
943
+ const result = this.entityManager.update(id, data)
944
+
945
+ // If model changed, update bounding sphere
946
+ if (data.model) {
947
+ const meshAsset = this.assetManager.get(data.model)
948
+ if (meshAsset) {
949
+ this.entityManager.updateBoundingSphere(id, meshAsset.bsphere)
950
+ }
951
+ }
952
+
953
+ return result
954
+ }
955
+
956
+ /**
957
+ * Delete an entity
958
+ * @param {string} id - Entity ID
959
+ */
960
+ deleteEntity(id) {
961
+ return this.entityManager.delete(id)
962
+ }
963
+
964
+ /**
965
+ * Get entity by ID
966
+ * @param {string} id - Entity ID
967
+ * @returns {Object|null} Entity or null if not found
968
+ */
969
+ getEntity(id) {
970
+ return this.entityManager.get(id)
971
+ }
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
+
984
+ async _create() {
985
+ let camera = new Camera(this) // Pass engine reference
986
+ camera.updateMatrix()
987
+ camera.updateView()
988
+ this.camera = camera
989
+
990
+ // Create hidden GUI canvas for 2D overlay (UI, debugging)
991
+ this.guiCanvas = document.createElement('canvas')
992
+ this.guiCanvas.style.display = 'none'
993
+ this.guiCtx = this.guiCanvas.getContext('2d')
994
+ // Initial size will be set by _resize()
995
+
996
+ // Load environment based on file extension
997
+ // .jpg = octahedral RGBM pair, .hdr = equirectangular
998
+ const envTexture = this.settings.environment.texture
999
+ if (envTexture.toLowerCase().endsWith('.jpg') || envTexture.toLowerCase().endsWith('.jpeg')) {
1000
+ // Load octahedral RGBM JPG pair
1001
+ this.environment = await Texture.fromJPGPair(this, envTexture)
1002
+ this.environmentEncoding = 1 // octahedral
1003
+ } else {
1004
+ // Load equirectangular HDR
1005
+ this.environment = await Texture.fromImage(this, envTexture)
1006
+ this.environmentEncoding = 0 // equirectangular
1007
+ }
1008
+ this._setupInput()
1009
+ }
1010
+
1011
+ async create() {
1012
+ }
1013
+
1014
+ async _after_create() {
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()
1020
+ }
1021
+
1022
+ _update(dt) {
1023
+ // Process input every frame
1024
+ this._updateInput();
1025
+ const ctx = this.guiCtx;
1026
+ const w = this.guiCanvas.width;
1027
+ const h = this.guiCanvas.height;
1028
+ ctx.clearRect(0, 0, w, h);
1029
+
1030
+ // Debug: render light positions (uses engine's _debugSettings from DebugUI)
1031
+ if (this._debugSettings?.showLights) {
1032
+ this.debugRenderLights();
1033
+ }
1034
+
1035
+ /*
1036
+ // Debug: visualize HiZ occlusion buffer
1037
+ const hizPass = this.renderer?.getPass('hiz');
1038
+ if (hizPass) {
1039
+ const info = hizPass.getTileInfo();
1040
+ const data = hizPass.getHiZData();
1041
+
1042
+ // Log sample depth values once per second
1043
+ if (data && info.hizDataReady && Math.floor(this.time) !== this._lastHizLog) {
1044
+ this._lastHizLog = Math.floor(this.time);
1045
+ const centerIdx = Math.floor(info.tileCountY / 2) * info.tileCountX + Math.floor(info.tileCountX / 2);
1046
+ console.log(`HiZ center tile: depth=${data[centerIdx].toFixed(6)}, near=${this.camera.near}, far=${this.camera.far}`);
1047
+ }
1048
+ if (data && info.hizDataReady) {
1049
+ const tileW = w / info.tileCountX;
1050
+ const tileH = h / info.tileCountY;
1051
+
1052
+ // Get camera near/far for depth linearization
1053
+ const near = this.camera?.near ?? 0.05;
1054
+ const far = this.camera?.far ?? 5000;
1055
+
1056
+ ctx.font = '12px monospace';
1057
+ ctx.textAlign = 'center';
1058
+ ctx.textBaseline = 'middle';
1059
+
1060
+ for (let y = 0; y < info.tileCountY; y++) {
1061
+ for (let x = 0; x < info.tileCountX; x++) {
1062
+ const idx = y * info.tileCountX + x;
1063
+ const maxZ = data[idx];
1064
+
1065
+ // Linear depth: depth 0 = near, depth 1 = far
1066
+ // z = near + depth * (far - near)
1067
+ const linearZ = near + maxZ * (far - near);
1068
+ const dist = linearZ.toFixed(1);
1069
+ const label = `${dist}m`;
1070
+
1071
+ ctx.fillStyle = 'rgba(0,0,0,0.2)';
1072
+ ctx.fillText(label, x * tileW + tileW / 2 + 1, y * tileH + tileH / 2 + 1);
1073
+
1074
+ // White if gap (far/sky), red if geometry
1075
+ ctx.fillStyle = maxZ >= 0.999 ? 'rgba(255,255,255,0.2)' : 'rgba(255,32,32,0.2)';
1076
+ ctx.fillText(label, x * tileW + tileW / 2, y * tileH + tileH / 2);
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ */
1082
+ }
1083
+
1084
+ update(dt) {
1085
+ }
1086
+
1087
+ async _resize() {
1088
+ // Wait for any in-progress render to complete before resizing
1089
+ if (this._renderInProgress) {
1090
+ await new Promise(resolve => {
1091
+ const checkRender = () => {
1092
+ if (!this._renderInProgress) {
1093
+ resolve()
1094
+ } else {
1095
+ setTimeout(checkRender, 5)
1096
+ }
1097
+ }
1098
+ checkRender()
1099
+ })
1100
+ }
1101
+
1102
+ this._resizing = true
1103
+ let t1 = performance.now()
1104
+ const { canvas, configureContext } = this
1105
+
1106
+ // Calculate effective render scale with auto-scaling
1107
+ const autoScale = this.settings?.rendering?.autoScale
1108
+ const configuredScale = this.settings?.rendering?.renderScale ?? 1.0
1109
+ let effectiveScale = configuredScale
1110
+
1111
+ if (autoScale?.enabled) {
1112
+ const devicePixelRatio = window.devicePixelRatio || 1
1113
+ const nativeHeight = canvas.clientHeight * devicePixelRatio
1114
+
1115
+ if (nativeHeight > autoScale.maxHeight) {
1116
+ // Apply scale reduction for high-res displays
1117
+ effectiveScale = configuredScale * (autoScale.scaleFactor ?? 0.5)
1118
+ if (!this._autoScaleWarned) {
1119
+ console.log(`Auto-scale: Reducing render scale from ${configuredScale} to ${effectiveScale.toFixed(2)} (native height: ${nativeHeight}px > ${autoScale.maxHeight}px)`)
1120
+ this._autoScaleWarned = true
1121
+ }
1122
+ } else {
1123
+ // Restore configured scale for lower resolutions
1124
+ if (this._autoScaleWarned) {
1125
+ console.log(`Auto-scale: Restoring render scale to ${configuredScale} (native height: ${nativeHeight}px <= ${autoScale.maxHeight}px)`)
1126
+ this._autoScaleWarned = false
1127
+ }
1128
+ }
1129
+ }
1130
+
1131
+ // Update the effective render scale
1132
+ this.renderScale = effectiveScale
1133
+
1134
+ configureContext()
1135
+
1136
+ // Resize GUI canvas to match render size
1137
+ if (this.guiCanvas) {
1138
+ this.guiCanvas.width = canvas.width
1139
+ this.guiCanvas.height = canvas.height
1140
+ this.guiCtx.clearRect(0, 0, canvas.width, canvas.height)
1141
+ }
1142
+
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)
1146
+ this.resize()
1147
+
1148
+ // Small delay before allowing renders to ensure all GPU resources are ready
1149
+ await new Promise(resolve => setTimeout(resolve, 16))
1150
+ this._resizing = false
1151
+ }
1152
+
1153
+ async resize() {
1154
+ }
1155
+
1156
+ _setupInput() {
1157
+ this.keys = {};
1158
+
1159
+ // Mouse/touch movement state
1160
+ this._inputDeltaX = 0; // Accumulated raw input delta
1161
+ this._inputDeltaY = 0;
1162
+ this._smoothedX = 0; // Smoothed output
1163
+ this._smoothedY = 0;
1164
+ this._mouseSmoothing = this.settings.engine.mouseSmoothing;
1165
+
1166
+ // Idle detection for normal mode
1167
+ this._mouseIdleTime = 0;
1168
+ this._mouseIdleThreshold = this.settings.engine.mouseIdleThreshold;
1169
+ this._mouseMovedThisFrame = false;
1170
+
1171
+ // Pointer lock state
1172
+ this._pointerLocked = false;
1173
+ this._mouseOnCanvas = false;
1174
+
1175
+ // Touch tracking
1176
+ this._lastTouchX = 0;
1177
+ this._lastTouchY = 0;
1178
+
1179
+ // Keyboard events
1180
+ window.addEventListener('keydown', (e) => {
1181
+ this.keys[e.key.toLowerCase()] = true;
1182
+
1183
+ // F10 toggles debug mode (both fly camera and debug panel)
1184
+ if (e.key === 'F10') {
1185
+ this.debugMode = !this.debugMode;
1186
+ this.settings.rendering.debug = this.debugMode;
1187
+ console.log(`Debug mode: ${this.debugMode ? 'ON' : 'OFF'}`);
1188
+
1189
+ // Exit pointer lock when entering debug mode
1190
+ if (this.debugMode && document.pointerLockElement) {
1191
+ document.exitPointerLock();
1192
+ }
1193
+ e.preventDefault();
1194
+ }
1195
+ });
1196
+ window.addEventListener('keyup', (e) => { this.keys[e.key.toLowerCase()] = false; });
1197
+ window.addEventListener('blur', (e) => {
1198
+ this.keys = {}
1199
+ })
1200
+ // Mouse events
1201
+ window.addEventListener('mousedown', (e) => {
1202
+ this.keys['lmb'] = true;
1203
+ this._mouseOnCanvas = e.target === this.canvas;
1204
+
1205
+ // In normal mode, click requests pointer lock for character controller
1206
+ if (!this.debugMode && e.button === 0 && this._mouseOnCanvas) {
1207
+ this.canvas.requestPointerLock();
1208
+ }
1209
+ });
1210
+ window.addEventListener('mouseup', (e) => {
1211
+ this.keys['lmb'] = false;
1212
+ this._mouseOnCanvas = false;
1213
+ });
1214
+
1215
+ // Pointer lock change handler
1216
+ document.addEventListener('pointerlockchange', () => {
1217
+ this._pointerLocked = document.pointerLockElement !== null;
1218
+ });
1219
+
1220
+ window.addEventListener('mousemove', (e) => {
1221
+ // In debug mode: only track when LMB pressed on canvas
1222
+ // In normal mode: always track (for character controller) when pointer locked
1223
+ if (this.debugMode) {
1224
+ if (this.keys['lmb'] && this._mouseOnCanvas) {
1225
+ this._inputDeltaX += e.movementX;
1226
+ this._inputDeltaY += e.movementY;
1227
+ }
1228
+ } else {
1229
+ // Normal mode: only when pointer is locked (clicked on canvas)
1230
+ if (this._pointerLocked) {
1231
+ this._inputDeltaX += e.movementX;
1232
+ this._inputDeltaY += e.movementY;
1233
+ this._mouseMovedThisFrame = true;
1234
+ }
1235
+ }
1236
+ });
1237
+
1238
+ // Touch events - simulate LMB + mouse movement
1239
+ window.addEventListener('touchstart', (e) => {
1240
+ this.keys['lmb'] = true;
1241
+ if (e.touches.length > 0) {
1242
+ this._lastTouchX = e.touches[0].clientX;
1243
+ this._lastTouchY = e.touches[0].clientY;
1244
+ }
1245
+ e.preventDefault();
1246
+ }, { passive: false });
1247
+
1248
+ window.addEventListener('touchend', (e) => {
1249
+ if (e.touches.length === 0) {
1250
+ this.keys['lmb'] = false;
1251
+ }
1252
+ });
1253
+
1254
+ window.addEventListener('touchcancel', (e) => {
1255
+ this.keys['lmb'] = false;
1256
+ });
1257
+
1258
+ window.addEventListener('touchmove', (e) => {
1259
+ if (e.touches.length > 0) {
1260
+ const touch = e.touches[0];
1261
+ const dx = touch.clientX - this._lastTouchX;
1262
+ const dy = touch.clientY - this._lastTouchY;
1263
+ this._inputDeltaX += dx;
1264
+ this._inputDeltaY += dy;
1265
+ this._lastTouchX = touch.clientX;
1266
+ this._lastTouchY = touch.clientY;
1267
+ this._mouseMovedThisFrame = true;
1268
+ }
1269
+ e.preventDefault();
1270
+ }, { passive: false });
1271
+
1272
+ // Escape exits pointer lock in normal mode
1273
+ document.addEventListener('keydown', (e) => {
1274
+ if (e.key === 'Escape' && this._pointerLocked) {
1275
+ document.exitPointerLock();
1276
+ }
1277
+ });
1278
+ }
1279
+
1280
+ /**
1281
+ * Called every frame to process smoothed input
1282
+ * Call this from your _update() method
1283
+ */
1284
+ _updateInput() {
1285
+ const dt = this.stats.dt ? this.stats.dt / 1000.0 : 0.016;
1286
+ let camera = this.camera;
1287
+
1288
+ if (this.debugMode) {
1289
+ let moveSpeed = 0.1;
1290
+ if (this.keys["shift"]) moveSpeed *= 5;
1291
+ if (this.keys[" "]) moveSpeed *= 0.1;
1292
+
1293
+ // Debug mode: fly camera with WASD
1294
+ // Camera movement
1295
+ if (this.keys["w"]) {
1296
+ camera.position[0] += camera.direction[0] * moveSpeed;
1297
+ camera.position[1] += camera.direction[1] * moveSpeed;
1298
+ camera.position[2] += camera.direction[2] * moveSpeed;
1299
+ }
1300
+ if (this.keys["s"]) {
1301
+ camera.position[0] -= camera.direction[0] * moveSpeed;
1302
+ camera.position[1] -= camera.direction[1] * moveSpeed;
1303
+ camera.position[2] -= camera.direction[2] * moveSpeed;
1304
+ }
1305
+ if (this.keys["a"]) {
1306
+ camera.position[0] -= camera.right[0] * moveSpeed;
1307
+ camera.position[1] -= camera.right[1] * moveSpeed;
1308
+ camera.position[2] -= camera.right[2] * moveSpeed;
1309
+ }
1310
+ if (this.keys["d"]) {
1311
+ camera.position[0] += camera.right[0] * moveSpeed;
1312
+ camera.position[1] += camera.right[1] * moveSpeed;
1313
+ camera.position[2] += camera.right[2] * moveSpeed;
1314
+ }
1315
+ if (this.keys["space"] || this.keys["e"]) {
1316
+ camera.position[1] += moveSpeed;
1317
+ }
1318
+ if (this.keys["c"] || this.keys["q"]) {
1319
+ camera.position[1] -= moveSpeed;
1320
+ }
1321
+
1322
+ if (this.keys["arrowleft"]) camera.yaw += 0.02;
1323
+ if (this.keys["arrowright"]) camera.yaw -= 0.02;
1324
+ if (this.keys["arrowup"]) camera.pitch += 0.02;
1325
+ if (this.keys["arrowdown"]) camera.pitch -= 0.02;
1326
+
1327
+ // Debug mode: original behavior - only when LMB pressed
1328
+ if (this.keys['lmb']) {
1329
+ // Apply smoothing
1330
+ const dx = this._inputDeltaX - this._smoothedX;
1331
+ const dy = this._inputDeltaY - this._smoothedY;
1332
+ this._smoothedX += dx * this._mouseSmoothing;
1333
+ this._smoothedY += dy * this._mouseSmoothing;
1334
+
1335
+ // Call handler with smoothed movement
1336
+ this.onMouseMove(this._smoothedX, this._smoothedY);
1337
+
1338
+ // Reset accumulated input (it's been consumed)
1339
+ this._inputDeltaX = 0;
1340
+ this._inputDeltaY = 0;
1341
+ } else {
1342
+ // Decay smoothed values when not pressing
1343
+ this._smoothedX *= 0.8;
1344
+ this._smoothedY *= 0.8;
1345
+ this._inputDeltaX = 0;
1346
+ this._inputDeltaY = 0;
1347
+
1348
+ // Still call onMouseMove with decaying values for smooth stop
1349
+ if (Math.abs(this._smoothedX) > 0.01 || Math.abs(this._smoothedY) > 0.01) {
1350
+ this.onMouseMove(this._smoothedX, this._smoothedY);
1351
+ }
1352
+ }
1353
+ } else {
1354
+ // Normal mode: always smooth, call onMouseMove, stop when idle
1355
+
1356
+ // Check if mouse moved this frame
1357
+ if (this._mouseMovedThisFrame) {
1358
+ this._mouseIdleTime = 0;
1359
+ this._mouseMovedThisFrame = false;
1360
+ } else {
1361
+ this._mouseIdleTime += dt;
1362
+ }
1363
+
1364
+ // Apply smoothing to accumulated input
1365
+ const dx = this._inputDeltaX - this._smoothedX;
1366
+ const dy = this._inputDeltaY - this._smoothedY;
1367
+ this._smoothedX += dx * this._mouseSmoothing;
1368
+ this._smoothedY += dy * this._mouseSmoothing;
1369
+
1370
+ // Reset accumulated input
1371
+ this._inputDeltaX = 0;
1372
+ this._inputDeltaY = 0;
1373
+
1374
+ // Check if there's significant movement
1375
+ const hasMovement = Math.abs(this._smoothedX) > 0.01 || Math.abs(this._smoothedY) > 0.01;
1376
+
1377
+ // Only call onMouseMove if there's movement and not idle for too long
1378
+ if (hasMovement && this._mouseIdleTime < this._mouseIdleThreshold) {
1379
+ this.onMouseMove(this._smoothedX, this._smoothedY);
1380
+ }
1381
+
1382
+ // Decay smoothed values when idle
1383
+ if (this._mouseIdleTime >= this._mouseIdleThreshold) {
1384
+ this._smoothedX *= 0.8;
1385
+ this._smoothedY *= 0.8;
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Debug render: draw crosses at all light positions
1392
+ * Green = visible (in front of camera), Red = not visible (behind camera or culled)
1393
+ */
1394
+ debugRenderLights() {
1395
+ const ctx = this.guiCtx;
1396
+ const w = this.guiCanvas.width;
1397
+ const h = this.guiCanvas.height;
1398
+ const crossSize = this._debugSettings?.lightCrossSize || 10;
1399
+
1400
+ // Get camera viewProj matrix (already computed by camera)
1401
+ const viewProj = this.camera.viewProj;
1402
+ if (!viewProj) return;
1403
+
1404
+ // Iterate through all entities with lights
1405
+ for (const entityId in this.entityManager.entities) {
1406
+ const entity = this.entityManager.entities[entityId];
1407
+ if (!entity.light?.enabled) continue;
1408
+
1409
+ // Get world position of light
1410
+ const lightPos = [
1411
+ entity.position[0] + (entity.light.position?.[0] || 0),
1412
+ entity.position[1] + (entity.light.position?.[1] || 0),
1413
+ entity.position[2] + (entity.light.position?.[2] || 0)
1414
+ ];
1415
+
1416
+ // Transform to clip space
1417
+ const clipPos = vec4.fromValues(lightPos[0], lightPos[1], lightPos[2], 1.0);
1418
+ vec4.transformMat4(clipPos, clipPos, viewProj);
1419
+
1420
+ // Check if behind camera (w <= 0 means behind)
1421
+ const isBehindCamera = clipPos[3] <= 0;
1422
+
1423
+ // Perspective divide to get NDC
1424
+ let ndcX, ndcY;
1425
+ if (!isBehindCamera) {
1426
+ ndcX = clipPos[0] / clipPos[3];
1427
+ ndcY = clipPos[1] / clipPos[3];
1428
+ } else {
1429
+ // For lights behind camera, project them to edge of screen
1430
+ ndcX = clipPos[0] < 0 ? -2 : 2;
1431
+ ndcY = clipPos[1] < 0 ? -2 : 2;
1432
+ }
1433
+
1434
+ // Convert NDC (-1 to 1) to screen coordinates
1435
+ const screenX = (ndcX + 1) * 0.5 * w;
1436
+ const screenY = (1 - ndcY) * 0.5 * h; // Y is inverted
1437
+
1438
+ // Check if on screen
1439
+ const isOnScreen = !isBehindCamera &&
1440
+ screenX >= 0 && screenX <= w &&
1441
+ screenY >= 0 && screenY <= h;
1442
+
1443
+ // Color: green if visible, red if not
1444
+ ctx.strokeStyle = isOnScreen ? 'rgba(0, 255, 0, 0.9)' : 'rgba(255, 0, 0, 0.7)';
1445
+ ctx.lineWidth = 2;
1446
+
1447
+ // Clamp to screen bounds for drawing
1448
+ const drawX = Math.max(crossSize, Math.min(w - crossSize, screenX));
1449
+ const drawY = Math.max(crossSize, Math.min(h - crossSize, screenY));
1450
+
1451
+ // Draw cross
1452
+ ctx.beginPath();
1453
+ ctx.moveTo(drawX - crossSize, drawY);
1454
+ ctx.lineTo(drawX + crossSize, drawY);
1455
+ ctx.moveTo(drawX, drawY - crossSize);
1456
+ ctx.lineTo(drawX, drawY + crossSize);
1457
+ ctx.stroke();
1458
+
1459
+ // Draw light type indicator
1460
+ const lightType = entity.light.lightType || 0;
1461
+ if (lightType === 2) {
1462
+ // Spotlight: draw a small cone indicator
1463
+ ctx.beginPath();
1464
+ ctx.arc(drawX, drawY, crossSize * 0.5, 0, Math.PI * 2);
1465
+ ctx.stroke();
1466
+ } else if (lightType === 1) {
1467
+ // Point light: draw small circle
1468
+ ctx.beginPath();
1469
+ ctx.arc(drawX, drawY, crossSize * 0.3, 0, Math.PI * 2);
1470
+ ctx.stroke();
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ onMouseMove(dx, dy) {
1476
+ }
1477
+ }
1478
+
1479
+ export {
1480
+ Engine,
1481
+ fail,
1482
+ Texture,
1483
+ Material,
1484
+ Geometry,
1485
+ Mesh,
1486
+ Camera,
1487
+ EntityManager,
1488
+ AssetManager,
1489
+ CullingSystem,
1490
+ InstanceManager,
1491
+ RenderGraph,
1492
+ ParticleSystem,
1493
+ ParticleEmitter,
1494
+ DebugUI,
1495
+ Raycaster,
1496
+ }