topazcube 0.1.31 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +0 -0
- package/README.md +0 -0
- package/dist/Renderer.cjs +20844 -0
- package/dist/Renderer.cjs.map +1 -0
- package/dist/Renderer.js +20827 -0
- package/dist/Renderer.js.map +1 -0
- package/dist/client.cjs +91 -260
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +68 -215
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +165 -432
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +117 -370
- package/dist/server.js.map +1 -1
- package/dist/terminal.cjs +113 -200
- package/dist/terminal.cjs.map +1 -1
- package/dist/terminal.js +50 -51
- package/dist/terminal.js.map +1 -1
- package/dist/utils-CRhi1BDa.cjs +259 -0
- package/dist/utils-CRhi1BDa.cjs.map +1 -0
- package/dist/utils-D7tXt6-2.js +260 -0
- package/dist/utils-D7tXt6-2.js.map +1 -0
- package/package.json +19 -15
- package/src/{client.ts → network/client.js} +170 -403
- package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
- package/src/{compress-node.ts → network/compress-node.js} +8 -14
- package/src/{server.ts → network/server.js} +229 -317
- package/src/{terminal.js → network/terminal.js} +0 -0
- package/src/{topazcube.ts → network/topazcube.js} +2 -2
- package/src/network/utils.js +375 -0
- package/src/renderer/Camera.js +191 -0
- package/src/renderer/DebugUI.js +703 -0
- package/src/renderer/Geometry.js +1049 -0
- package/src/renderer/Material.js +64 -0
- package/src/renderer/Mesh.js +211 -0
- package/src/renderer/Node.js +112 -0
- package/src/renderer/Pipeline.js +645 -0
- package/src/renderer/Renderer.js +1496 -0
- package/src/renderer/Skin.js +792 -0
- package/src/renderer/Texture.js +584 -0
- package/src/renderer/core/AssetManager.js +394 -0
- package/src/renderer/core/CullingSystem.js +308 -0
- package/src/renderer/core/EntityManager.js +541 -0
- package/src/renderer/core/InstanceManager.js +343 -0
- package/src/renderer/core/ParticleEmitter.js +358 -0
- package/src/renderer/core/ParticleSystem.js +564 -0
- package/src/renderer/core/SpriteSystem.js +349 -0
- package/src/renderer/gltf.js +563 -0
- package/src/renderer/math.js +161 -0
- package/src/renderer/rendering/HistoryBufferManager.js +333 -0
- package/src/renderer/rendering/ProbeCapture.js +1495 -0
- package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
- package/src/renderer/rendering/RenderGraph.js +2258 -0
- package/src/renderer/rendering/passes/AOPass.js +308 -0
- package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
- package/src/renderer/rendering/passes/BasePass.js +101 -0
- package/src/renderer/rendering/passes/BloomPass.js +420 -0
- package/src/renderer/rendering/passes/CRTPass.js +724 -0
- package/src/renderer/rendering/passes/FogPass.js +445 -0
- package/src/renderer/rendering/passes/GBufferPass.js +730 -0
- package/src/renderer/rendering/passes/HiZPass.js +744 -0
- package/src/renderer/rendering/passes/LightingPass.js +753 -0
- package/src/renderer/rendering/passes/ParticlePass.js +841 -0
- package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
- package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
- package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
- package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
- package/src/renderer/rendering/passes/SSGIPass.js +266 -0
- package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
- package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
- package/src/renderer/rendering/passes/TransparentPass.js +831 -0
- package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
- package/src/renderer/rendering/shaders/ao.wgsl +182 -0
- package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
- package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/crt.wgsl +455 -0
- package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
- package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
- package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
- package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
- package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
- package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
- package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
- package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
- package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
- package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
- package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
- package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
- package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
- package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
- package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
- package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
- package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
- package/src/renderer/utils/BoundingSphere.js +439 -0
- package/src/renderer/utils/Frustum.js +281 -0
- package/src/renderer/utils/Raycaster.js +761 -0
- package/dist/client.d.cts +0 -211
- package/dist/client.d.ts +0 -211
- package/dist/server.d.cts +0 -120
- package/dist/server.d.ts +0 -120
- package/dist/terminal.d.cts +0 -64
- package/dist/terminal.d.ts +0 -64
- package/src/utils.ts +0 -403
|
@@ -0,0 +1,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
|
+
}
|