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,730 @@
1
+ import { BasePass } from "./BasePass.js"
2
+ import { Pipeline } from "../../Pipeline.js"
3
+ import { Texture } from "../../Texture.js"
4
+ import { Frustum } from "../../utils/Frustum.js"
5
+ import { transformBoundingSphere } from "../../utils/BoundingSphere.js"
6
+
7
+ import geometryWGSL from "../shaders/geometry.wgsl"
8
+
9
+ /**
10
+ * GBuffer textures container
11
+ */
12
+ class GBuffer {
13
+ constructor() {
14
+ this.isGBuffer = true
15
+ this.albedo = null // rgba8unorm - Base color
16
+ this.normal = null // rgba16float - World-space normals
17
+ this.arm = null // rgba8unorm - Ambient Occlusion, Roughness, Metallic
18
+ this.emission = null // rgba16float - Emissive color
19
+ this.velocity = null // rg16float - Motion vectors (screen-space pixels)
20
+ this.depth = null // depth32float - Scene depth
21
+ }
22
+
23
+ static async create(engine, width, height) {
24
+ const gbuffer = new GBuffer()
25
+ gbuffer.albedo = await Texture.renderTarget(engine, 'rgba8unorm', width, height)
26
+ gbuffer.normal = await Texture.renderTarget(engine, 'rgba16float', width, height)
27
+ gbuffer.arm = await Texture.renderTarget(engine, 'rgba8unorm', width, height)
28
+ gbuffer.emission = await Texture.renderTarget(engine, 'rgba16float', width, height)
29
+ gbuffer.velocity = await Texture.renderTarget(engine, 'rg16float', width, height)
30
+ gbuffer.depth = await Texture.depth(engine, width, height)
31
+ gbuffer.width = width
32
+ gbuffer.height = height
33
+ return gbuffer
34
+ }
35
+
36
+ getTargets() {
37
+ return [
38
+ { format: "rgba8unorm" },
39
+ { format: "rgba16float" },
40
+ { format: "rgba8unorm" },
41
+ { format: "rgba16float" },
42
+ { format: "rg16float" },
43
+ ]
44
+ }
45
+
46
+ getColorAttachments() {
47
+ return [
48
+ {
49
+ view: this.albedo.view,
50
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
51
+ loadOp: 'clear',
52
+ storeOp: 'store',
53
+ },
54
+ {
55
+ view: this.normal.view,
56
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
57
+ loadOp: 'clear',
58
+ storeOp: 'store',
59
+ },
60
+ {
61
+ view: this.arm.view,
62
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
63
+ loadOp: 'clear',
64
+ storeOp: 'store',
65
+ },
66
+ {
67
+ view: this.emission.view,
68
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
69
+ loadOp: 'clear',
70
+ storeOp: 'store',
71
+ },
72
+ {
73
+ view: this.velocity.view,
74
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 0.0 },
75
+ loadOp: 'clear',
76
+ storeOp: 'store',
77
+ },
78
+ ]
79
+ }
80
+
81
+ getDepthStencilAttachment() {
82
+ return {
83
+ view: this.depth.view,
84
+ depthClearValue: 1.0,
85
+ depthLoadOp: 'clear',
86
+ depthStoreOp: 'store',
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * GBufferPass - Renders scene geometry to GBuffer textures
93
+ *
94
+ * Pass 4 in the 7-pass pipeline.
95
+ * Outputs: Albedo, Normal, ARM, Emission, Depth
96
+ */
97
+ class GBufferPass extends BasePass {
98
+ constructor(engine = null) {
99
+ super('GBuffer', engine)
100
+
101
+ this.gbuffer = null
102
+ this.pipelines = new Map() // materialId -> pipeline (ready)
103
+ this.skinnedPipelines = new Map() // materialId -> skinned pipeline (ready)
104
+ this.pendingPipelines = new Map() // materialId -> Promise<pipeline> (compiling)
105
+
106
+ // Clip plane for planar reflections
107
+ this.clipPlaneY = 0
108
+ this.clipPlaneEnabled = false
109
+ this.clipPlaneDirection = 1.0 // 1.0 = discard below, -1.0 = discard above
110
+
111
+ // Distance fade for preventing object popping at culling distance
112
+ this.distanceFadeStart = 0 // Distance where fade begins
113
+ this.distanceFadeEnd = 0 // Distance where fade completes (0 = disabled)
114
+
115
+ // Noise texture for alpha hashing
116
+ this.noiseTexture = null
117
+ this.noiseSize = 64
118
+ this.noiseAnimated = true
119
+
120
+ // HiZ pass reference for occlusion culling of legacy meshes
121
+ this.hizPass = null
122
+
123
+ // Frustum for legacy mesh culling
124
+ this.frustum = new Frustum()
125
+
126
+ // Billboard camera vectors (extracted from view matrix)
127
+ this._billboardCameraRight = [1, 0, 0]
128
+ this._billboardCameraUp = [0, 1, 0]
129
+ this._billboardCameraForward = [0, 0, -1]
130
+
131
+ // Culling stats for legacy meshes
132
+ this.legacyCullingStats = {
133
+ total: 0,
134
+ rendered: 0,
135
+ culledByFrustum: 0,
136
+ culledByDistance: 0,
137
+ culledByOcclusion: 0
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Set the HiZ pass for occlusion culling of legacy meshes
143
+ * @param {HiZPass} hizPass - The HiZ pass instance
144
+ */
145
+ setHiZPass(hizPass) {
146
+ this.hizPass = hizPass
147
+ }
148
+
149
+ /**
150
+ * Test if a legacy mesh should be culled (per-instance occlusion culling)
151
+ * @param {Mesh} mesh - The mesh to test
152
+ * @param {Camera} camera - Current camera
153
+ * @param {boolean} canCull - Whether frustum/occlusion culling is available
154
+ * @returns {string|null} - Reason for culling or null if visible
155
+ */
156
+ _shouldCullLegacyMesh(mesh, camera, canCull) {
157
+ // Skip culling if disabled or no HiZ pass
158
+ if (!canCull || !this.hizPass || !camera) {
159
+ return null
160
+ }
161
+
162
+ // Skip for entity-managed meshes - they're already culled by CullingSystem
163
+ // Only perform per-mesh occlusion culling for static (non-entity-managed) meshes
164
+ if (!mesh.static) {
165
+ return null
166
+ }
167
+
168
+ const occlusionEnabled = this.settings?.occlusionCulling?.enabled
169
+ if (!occlusionEnabled) {
170
+ return null
171
+ }
172
+
173
+ // Get local bounding sphere from geometry
174
+ const localBsphere = mesh.geometry?.getBoundingSphere?.()
175
+ if (!localBsphere || localBsphere.radius <= 0) {
176
+ this.legacyCullingStats.skippedNoBsphere = (this.legacyCullingStats.skippedNoBsphere || 0) + 1
177
+ return null // No valid bsphere, don't cull
178
+ }
179
+
180
+ const instanceCount = mesh.geometry?.instanceCount || 0
181
+ if (instanceCount === 0) {
182
+ return null // No instances to test
183
+ }
184
+
185
+ const instanceData = mesh.geometry?.instanceData
186
+ if (!instanceData) {
187
+ return null // No instance data
188
+ }
189
+
190
+ // Test each instance - if ANY is visible, mesh is visible
191
+ // Instance data layout: 20 floats per instance (4x4 matrix + 4 extra)
192
+ const floatsPerInstance = 20
193
+ let allOccluded = true
194
+
195
+ // Copy camera data to avoid mutable reference issues
196
+ const cameraPos = [camera.position[0], camera.position[1], camera.position[2]]
197
+ const viewProj = camera.viewProj
198
+ const near = camera.near
199
+ const far = camera.far
200
+
201
+ for (let i = 0; i < instanceCount; i++) {
202
+ const offset = i * floatsPerInstance
203
+
204
+ // Extract 4x4 matrix from instance data
205
+ const matrix = instanceData.subarray(offset, offset + 16)
206
+
207
+ // Transform local bsphere by instance matrix
208
+ const worldBsphere = transformBoundingSphere(localBsphere, matrix)
209
+
210
+ // Test against HiZ - if visible, mesh is visible
211
+ const occluded = this.hizPass.testSphereOcclusion(
212
+ worldBsphere,
213
+ viewProj,
214
+ near,
215
+ far,
216
+ cameraPos
217
+ )
218
+
219
+ if (!occluded) {
220
+ allOccluded = false
221
+ break // At least one instance visible, no need to test more
222
+ }
223
+ }
224
+
225
+ return allOccluded ? 'occlusion' : null
226
+ }
227
+
228
+ /**
229
+ * Extract camera vectors from view matrix for billboarding
230
+ * The view matrix transforms world to view space. Camera basis vectors:
231
+ * - Right: first row of view matrix
232
+ * - Up: second row of view matrix
233
+ * - Forward: negative third row (camera looks down -Z in view space)
234
+ * @param {Float32Array|Array} viewMatrix - 4x4 view matrix
235
+ */
236
+ _extractCameraVectors(viewMatrix) {
237
+ // View matrix is column-major, so row vectors are at indices:
238
+ // Row 0 (right): [0], [4], [8]
239
+ // Row 1 (up): [1], [5], [9]
240
+ // Row 2 (forward): [2], [6], [10] (negated because -Z is forward)
241
+ this._billboardCameraRight[0] = viewMatrix[0]
242
+ this._billboardCameraRight[1] = viewMatrix[4]
243
+ this._billboardCameraRight[2] = viewMatrix[8]
244
+
245
+ this._billboardCameraUp[0] = viewMatrix[1]
246
+ this._billboardCameraUp[1] = viewMatrix[5]
247
+ this._billboardCameraUp[2] = viewMatrix[9]
248
+
249
+ // Negate Z row for forward direction
250
+ this._billboardCameraForward[0] = -viewMatrix[2]
251
+ this._billboardCameraForward[1] = -viewMatrix[6]
252
+ this._billboardCameraForward[2] = -viewMatrix[10]
253
+ }
254
+
255
+ /**
256
+ * Get billboard mode from material
257
+ * @param {Material} material - Material with optional billboardMode uniform
258
+ * @returns {number} Billboard mode: 0=none, 1=center, 2=bottom, 3=horizontal
259
+ */
260
+ _getBillboardMode(material) {
261
+ const mode = material?.uniforms?.billboardMode
262
+ if (typeof mode === 'number') return mode
263
+ if (mode === 'center') return 1
264
+ if (mode === 'bottom') return 2
265
+ if (mode === 'horizontal') return 3
266
+ return 0
267
+ }
268
+
269
+ /**
270
+ * Set the noise texture for alpha hashing
271
+ * @param {Texture} noise - Noise texture (blue noise or bayer dither)
272
+ * @param {number} size - Texture size
273
+ * @param {boolean} animated - Whether to animate noise offset each frame
274
+ */
275
+ setNoise(noise, size = 64, animated = true) {
276
+ this.noiseTexture = noise
277
+ this.noiseSize = size
278
+ this.noiseAnimated = animated
279
+ // Mark all pipelines for rebuild (they need noise texture binding)
280
+ this.pipelines.clear()
281
+ this.skinnedPipelines.clear()
282
+ }
283
+
284
+ async _init() {
285
+ const { canvas } = this.engine
286
+ this.gbuffer = await GBuffer.create(this.engine, canvas.width, canvas.height)
287
+ }
288
+
289
+ /**
290
+ * Get pipeline key for a mesh
291
+ */
292
+ _getPipelineKey(mesh) {
293
+ const isSkinned = mesh.hasSkin && mesh.skin
294
+ const meshId = mesh.uid || mesh.geometry?.uid || 'default'
295
+ const forceEmissive = mesh.material?.forceEmissive ? '_emissive' : ''
296
+ const doubleSided = mesh.material?.doubleSided ? '_dbl' : ''
297
+ return `${mesh.material.uid}_${meshId}${isSkinned ? '_skinned' : ''}${forceEmissive}${doubleSided}`
298
+ }
299
+
300
+ /**
301
+ * Check if pipeline is ready for a mesh (non-blocking)
302
+ * @param {Mesh} mesh - The mesh to check
303
+ * @returns {Pipeline|null} The pipeline if ready, null if still compiling
304
+ */
305
+ _getPipelineIfReady(mesh) {
306
+ const isSkinned = mesh.hasSkin && mesh.skin
307
+ const pipelinesMap = isSkinned ? this.skinnedPipelines : this.pipelines
308
+ const key = this._getPipelineKey(mesh)
309
+ return pipelinesMap.get(key) || null
310
+ }
311
+
312
+ /**
313
+ * Check if pipeline is ready AND warmed up (stable for rendering)
314
+ * @param {Mesh} mesh - The mesh to check
315
+ * @returns {boolean} True if pipeline is ready and warmed up
316
+ */
317
+ isPipelineStable(mesh) {
318
+ const pipeline = this._getPipelineIfReady(mesh)
319
+ return pipeline && (!pipeline._warmupFrames || pipeline._warmupFrames <= 0)
320
+ }
321
+
322
+ /**
323
+ * Start pipeline creation in background (non-blocking)
324
+ * @param {Mesh} mesh - The mesh to create pipeline for
325
+ */
326
+ _startPipelineCreation(mesh) {
327
+ const isSkinned = mesh.hasSkin && mesh.skin
328
+ const pipelinesMap = isSkinned ? this.skinnedPipelines : this.pipelines
329
+ const key = this._getPipelineKey(mesh)
330
+
331
+ // Already ready or already pending
332
+ if (pipelinesMap.has(key) || this.pendingPipelines.has(key)) {
333
+ return
334
+ }
335
+
336
+ // Start async compilation without awaiting
337
+ const pipelinePromise = Pipeline.create(this.engine, {
338
+ label: `gbuffer-${key}`,
339
+ wgslSource: geometryWGSL,
340
+ geometry: mesh.geometry,
341
+ textures: mesh.material.textures,
342
+ renderTarget: this.gbuffer,
343
+ skin: isSkinned ? mesh.skin : null,
344
+ noiseTexture: this.noiseTexture,
345
+ doubleSided: mesh.material?.doubleSided ?? false,
346
+ }).then(pipeline => {
347
+ // Move from pending to ready
348
+ this.pendingPipelines.delete(key)
349
+ // Mark as warming up - needs 2 frames to stabilize
350
+ pipeline._warmupFrames = 2
351
+ pipelinesMap.set(key, pipeline)
352
+ return pipeline
353
+ }).catch(err => {
354
+ console.error(`Failed to create pipeline for ${key}:`, err)
355
+ this.pendingPipelines.delete(key)
356
+ return null
357
+ })
358
+
359
+ this.pendingPipelines.set(key, pipelinePromise)
360
+ }
361
+
362
+ /**
363
+ * Get or create pipeline for a mesh (blocking - for batch system)
364
+ * @param {Mesh} mesh - The mesh to render
365
+ * @returns {Pipeline} The pipeline for this mesh
366
+ */
367
+ async _getOrCreatePipeline(mesh) {
368
+ const isSkinned = mesh.hasSkin && mesh.skin
369
+ const pipelinesMap = isSkinned ? this.skinnedPipelines : this.pipelines
370
+ const key = this._getPipelineKey(mesh)
371
+
372
+ // Return if already ready
373
+ if (pipelinesMap.has(key)) {
374
+ return pipelinesMap.get(key)
375
+ }
376
+
377
+ // Wait for pending if exists
378
+ if (this.pendingPipelines.has(key)) {
379
+ return await this.pendingPipelines.get(key)
380
+ }
381
+
382
+ // Create new pipeline
383
+ const pipeline = await Pipeline.create(this.engine, {
384
+ label: `gbuffer-${key}`,
385
+ wgslSource: geometryWGSL,
386
+ geometry: mesh.geometry,
387
+ textures: mesh.material.textures,
388
+ renderTarget: this.gbuffer,
389
+ skin: isSkinned ? mesh.skin : null,
390
+ noiseTexture: this.noiseTexture,
391
+ doubleSided: mesh.material?.doubleSided ?? false,
392
+ })
393
+ // Mark as warming up - needs 2 frames to stabilize
394
+ pipeline._warmupFrames = 2
395
+ pipelinesMap.set(key, pipeline)
396
+ return pipeline
397
+ }
398
+
399
+ /**
400
+ * Execute GBuffer pass
401
+ *
402
+ * @param {Object} context
403
+ * @param {Camera} context.camera - Current camera
404
+ * @param {Object} context.meshes - Legacy mesh dictionary (for backward compatibility)
405
+ * @param {Map} context.batches - Instance batches from InstanceManager (new system)
406
+ * @param {number} context.dt - Delta time for animation
407
+ * @param {HistoryBufferManager} context.historyManager - History buffer manager for motion vectors
408
+ */
409
+ async _execute(context) {
410
+ const { device, canvas, options, stats } = this.engine
411
+ const { camera, meshes, batches, dt = 0, historyManager } = context
412
+
413
+ // Get previous frame camera matrices for motion vectors
414
+ // If no valid history, use current viewProj (zero motion)
415
+ const prevData = historyManager?.getPrevious()
416
+ const prevViewProjMatrix = prevData?.hasValidHistory
417
+ ? prevData.viewProj
418
+ : camera.viewProj
419
+
420
+ // Get settings from engine (with fallbacks)
421
+ const emissionFactor = this.settings?.environment?.emissionFactor ?? [1.0, 1.0, 1.0, 4.0]
422
+ const mipBias = this.settings?.rendering?.mipBias ?? options.mipBias ?? 0
423
+
424
+ // Use absolute time for scene-loaded skins (same as entity animations)
425
+ // This ensures consistent timing regardless of frame rate fluctuations
426
+ const animationSpeed = this.settings?.animation?.speed ?? 1.0
427
+ const globalAnimTime = (performance.now() / 1000) * animationSpeed
428
+
429
+ stats.drawCalls = 0
430
+ stats.triangles = 0
431
+
432
+ // Update camera
433
+ camera.aspect = canvas.width / canvas.height
434
+ camera.updateMatrix()
435
+ camera.updateView()
436
+
437
+ // Extract camera vectors for billboarding
438
+ this._extractCameraVectors(camera.view)
439
+
440
+ let commandEncoder = null
441
+ let passEncoder = null
442
+
443
+ // Track which skins have been updated this frame (avoids duplicate updates)
444
+ const updatedSkins = new Set()
445
+
446
+ // New system: render batches from InstanceManager
447
+ if (batches && batches.size > 0) {
448
+ for (const [modelId, batch] of batches) {
449
+ const mesh = batch.mesh
450
+ if (!mesh) continue
451
+
452
+ // Update skin animation if skinned (skip if externally managed or already updated)
453
+ // Use absolute time (same as entities) for consistent animation speed
454
+ if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged && !updatedSkins.has(batch.skin)) {
455
+ // Track animation start time per skin for absolute timing
456
+ if (batch.skin._animStartTime === undefined) {
457
+ batch.skin._animStartTime = globalAnimTime
458
+ }
459
+ const skinAnimTime = globalAnimTime - batch.skin._animStartTime
460
+ batch.skin.updateAtTime(skinAnimTime)
461
+ updatedSkins.add(batch.skin)
462
+ }
463
+
464
+ const pipeline = await this._getOrCreatePipeline(mesh)
465
+
466
+ // On first render after pipeline creation, force skin update to ensure proper state
467
+ if (pipeline._warmupFrames > 0) {
468
+ pipeline._warmupFrames--
469
+ if (batch.hasSkin && batch.skin) {
470
+ // Force immediate skin update to avoid stale joint matrices
471
+ batch.skin.update(0)
472
+ }
473
+ }
474
+
475
+ // Update bind group if skinned
476
+ if (batch.hasSkin && batch.skin) {
477
+ pipeline.updateBindGroupForSkin(batch.skin)
478
+ }
479
+
480
+ // Update geometry buffers
481
+ mesh.geometry.update()
482
+
483
+ // Set uniforms
484
+ const jitterFadeDistance = this.settings?.rendering?.jitterFadeDistance ?? 30.0
485
+ // Get alpha hash settings (per-material or global)
486
+ const alphaHashEnabled = mesh.material?.alphaHash ?? this.settings?.rendering?.alphaHash ?? false
487
+ const alphaHashScale = mesh.material?.alphaHashScale ?? this.settings?.rendering?.alphaHashScale ?? 1.0
488
+ const luminanceToAlpha = mesh.material?.luminanceToAlpha ?? this.settings?.rendering?.luminanceToAlpha ?? false
489
+
490
+ pipeline.uniformValues.set({
491
+ viewMatrix: camera.view,
492
+ projectionMatrix: camera.proj,
493
+ prevViewProjMatrix: prevViewProjMatrix,
494
+ mipBias: mipBias,
495
+ skinEnabled: batch.hasSkin ? 1.0 : 0.0,
496
+ numJoints: batch.hasSkin && batch.skin ? batch.skin.numJoints : 0,
497
+ near: camera.near || 0.05,
498
+ far: camera.far || 1000,
499
+ jitterFadeDistance: jitterFadeDistance,
500
+ jitterOffset: camera.jitterOffset || [0, 0],
501
+ screenSize: camera.screenSize || [canvas.width, canvas.height],
502
+ emissionFactor: emissionFactor,
503
+ clipPlaneY: this.clipPlaneY,
504
+ clipPlaneEnabled: this.clipPlaneEnabled ? 1.0 : 0.0,
505
+ clipPlaneDirection: this.clipPlaneDirection,
506
+ pixelRounding: this.settings?.rendering?.pixelRounding || 0.0,
507
+ pixelExpansion: this.settings?.rendering?.pixelExpansion ?? 0.05,
508
+ positionRounding: this.settings?.rendering?.positionRounding || 0.0,
509
+ alphaHashEnabled: alphaHashEnabled ? 1.0 : 0.0,
510
+ alphaHashScale: alphaHashScale,
511
+ luminanceToAlpha: luminanceToAlpha ? 1.0 : 0.0,
512
+ noiseSize: this.noiseSize,
513
+ // Always use static noise for alpha hash to avoid shimmer on cutout edges
514
+ noiseOffsetX: 0,
515
+ noiseOffsetY: 0,
516
+ cameraPosition: camera.position,
517
+ distanceFadeStart: this.distanceFadeStart,
518
+ distanceFadeEnd: this.distanceFadeEnd,
519
+ // Billboard uniforms
520
+ billboardMode: this._getBillboardMode(mesh.material),
521
+ billboardCameraRight: this._billboardCameraRight,
522
+ billboardCameraUp: this._billboardCameraUp,
523
+ billboardCameraForward: this._billboardCameraForward,
524
+ // Per-material specular boost (0-1, default 0 = disabled)
525
+ specularBoost: mesh.material?.specularBoost ?? 0,
526
+ })
527
+
528
+ // Render
529
+ if (commandEncoder) {
530
+ pipeline.render({
531
+ commandEncoder,
532
+ passEncoder,
533
+ dontFinish: true,
534
+ instanceBuffer: batch.buffer?.gpuBuffer,
535
+ instanceCount: batch.instanceCount
536
+ })
537
+ } else {
538
+ const result = pipeline.render({
539
+ dontFinish: true,
540
+ instanceBuffer: batch.buffer?.gpuBuffer,
541
+ instanceCount: batch.instanceCount
542
+ })
543
+ commandEncoder = result.commandEncoder
544
+ passEncoder = result.passEncoder
545
+ }
546
+ }
547
+ }
548
+
549
+ // Legacy system: render individual meshes with progressive loading
550
+ // Meshes appear as their shaders compile - no blocking wait
551
+ let totalInstances = 0
552
+
553
+ if (meshes && Object.keys(meshes).length > 0) {
554
+ // Update frustum for legacy mesh culling (only if camera has required properties)
555
+ const canCull = camera.view && camera.proj && camera.position && camera.direction
556
+ if (canCull) {
557
+ const fovRadians = (camera.fov || 60) * (Math.PI / 180)
558
+ this.frustum.update(
559
+ camera.view,
560
+ camera.proj,
561
+ camera.position,
562
+ camera.direction,
563
+ fovRadians,
564
+ camera.aspect || (canvas.width / canvas.height),
565
+ camera.near || 0.05,
566
+ camera.far || 1000,
567
+ canvas.width,
568
+ canvas.height
569
+ )
570
+ }
571
+
572
+ // Reset culling stats
573
+ this.legacyCullingStats.total = 0
574
+ this.legacyCullingStats.rendered = 0
575
+ this.legacyCullingStats.culledByFrustum = 0
576
+ this.legacyCullingStats.culledByDistance = 0
577
+ this.legacyCullingStats.culledByOcclusion = 0
578
+ this.legacyCullingStats.skippedNoBsphere = 0
579
+
580
+ // Start pipeline creation for ALL meshes (non-blocking)
581
+ // This kicks off parallel shader compilation in the background
582
+ for (const name in meshes) {
583
+ const mesh = meshes[name]
584
+ if (!mesh || !mesh.geometry || !mesh.material) continue
585
+ this._startPipelineCreation(mesh)
586
+ }
587
+
588
+ // Render only meshes with READY pipelines (others will appear next frame)
589
+ for (const name in meshes) {
590
+ const mesh = meshes[name]
591
+ const instanceCount = mesh.geometry?.instanceCount || 0
592
+ totalInstances += instanceCount
593
+
594
+ // Skip meshes with no instances
595
+ if (instanceCount === 0) continue
596
+
597
+ this.legacyCullingStats.total++
598
+
599
+ // Apply culling to legacy meshes (frustum, distance, occlusion)
600
+ const cullReason = this._shouldCullLegacyMesh(mesh, camera, canCull)
601
+ if (cullReason) {
602
+ if (cullReason === 'frustum') this.legacyCullingStats.culledByFrustum++
603
+ else if (cullReason === 'distance') this.legacyCullingStats.culledByDistance++
604
+ else if (cullReason === 'occlusion') this.legacyCullingStats.culledByOcclusion++
605
+ continue
606
+ }
607
+
608
+ // Check if pipeline is ready (non-blocking)
609
+ const pipeline = this._getPipelineIfReady(mesh)
610
+ if (!pipeline) continue // Still compiling, skip for now
611
+
612
+ // Track warmup frames (pipeline just became ready)
613
+ if (pipeline._warmupFrames > 0) {
614
+ pipeline._warmupFrames--
615
+ }
616
+
617
+ this.legacyCullingStats.rendered++
618
+
619
+ // Update skin animation using absolute time (skip if externally managed or already updated)
620
+ if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged && !updatedSkins.has(mesh.skin)) {
621
+ // Track animation start time per skin for absolute timing
622
+ if (mesh.skin._animStartTime === undefined) {
623
+ mesh.skin._animStartTime = globalAnimTime
624
+ }
625
+ const skinAnimTime = globalAnimTime - mesh.skin._animStartTime
626
+ mesh.skin.updateAtTime(skinAnimTime)
627
+ updatedSkins.add(mesh.skin)
628
+ }
629
+
630
+ // Ensure pipeline geometry matches mesh geometry
631
+ if (pipeline.geometry !== mesh.geometry) {
632
+ pipeline.geometry = mesh.geometry
633
+ }
634
+
635
+ // Update bind group if skinned
636
+ if (mesh.hasSkin && mesh.skin) {
637
+ pipeline.updateBindGroupForSkin(mesh.skin)
638
+ }
639
+
640
+ // Update geometry buffers
641
+ mesh.geometry.update()
642
+
643
+ // Set uniforms
644
+ const jitterFadeDist = this.settings?.rendering?.jitterFadeDistance ?? 30.0
645
+ // Get alpha hash settings - check mesh material first, then global setting
646
+ const meshAlphaHash = mesh.material?.alphaHash ?? mesh.alphaHash
647
+ const alphaHashEnabled = meshAlphaHash ?? this.settings?.rendering?.alphaHash ?? false
648
+ const alphaHashScale = mesh.material?.alphaHashScale ?? this.settings?.rendering?.alphaHashScale ?? 1.0
649
+ const luminanceToAlpha = mesh.material?.luminanceToAlpha ?? this.settings?.rendering?.luminanceToAlpha ?? false
650
+
651
+ pipeline.uniformValues.set({
652
+ viewMatrix: camera.view,
653
+ projectionMatrix: camera.proj,
654
+ prevViewProjMatrix: prevViewProjMatrix,
655
+ mipBias: mipBias,
656
+ skinEnabled: mesh.hasSkin ? 1.0 : 0.0,
657
+ numJoints: mesh.hasSkin && mesh.skin ? mesh.skin.numJoints : 0,
658
+ near: camera.near || 0.05,
659
+ far: camera.far || 1000,
660
+ jitterFadeDistance: jitterFadeDist,
661
+ jitterOffset: camera.jitterOffset || [0, 0],
662
+ screenSize: camera.screenSize || [canvas.width, canvas.height],
663
+ emissionFactor: emissionFactor,
664
+ clipPlaneY: this.clipPlaneY,
665
+ clipPlaneEnabled: this.clipPlaneEnabled ? 1.0 : 0.0,
666
+ clipPlaneDirection: this.clipPlaneDirection,
667
+ pixelRounding: this.settings?.rendering?.pixelRounding || 0.0,
668
+ pixelExpansion: this.settings?.rendering?.pixelExpansion ?? 0.05,
669
+ positionRounding: this.settings?.rendering?.positionRounding || 0.0,
670
+ alphaHashEnabled: alphaHashEnabled ? 1.0 : 0.0,
671
+ alphaHashScale: alphaHashScale,
672
+ luminanceToAlpha: luminanceToAlpha ? 1.0 : 0.0,
673
+ noiseSize: this.noiseSize,
674
+ // Always use static noise for alpha hash to avoid shimmer on cutout edges
675
+ noiseOffsetX: 0,
676
+ noiseOffsetY: 0,
677
+ cameraPosition: camera.position,
678
+ distanceFadeStart: this.distanceFadeStart,
679
+ distanceFadeEnd: this.distanceFadeEnd,
680
+ // Billboard uniforms
681
+ billboardMode: this._getBillboardMode(mesh.material),
682
+ billboardCameraRight: this._billboardCameraRight,
683
+ billboardCameraUp: this._billboardCameraUp,
684
+ billboardCameraForward: this._billboardCameraForward,
685
+ // Per-material specular boost (0-1, default 0 = disabled)
686
+ specularBoost: mesh.material?.specularBoost ?? 0,
687
+ })
688
+
689
+ // Render
690
+ if (commandEncoder) {
691
+ pipeline.render({ commandEncoder, passEncoder, dontFinish: true })
692
+ } else {
693
+ const result = pipeline.render({ dontFinish: true })
694
+ commandEncoder = result.commandEncoder
695
+ passEncoder = result.passEncoder
696
+ }
697
+ }
698
+ }
699
+
700
+ // Finish the pass
701
+ if (passEncoder && commandEncoder) {
702
+ passEncoder.end()
703
+ device.queue.submit([commandEncoder.finish()])
704
+ }
705
+ }
706
+
707
+ async _resize(width, height) {
708
+ // Recreate GBuffer at new size
709
+ this.gbuffer = await GBuffer.create(this.engine, width, height)
710
+
711
+ // Clear pipeline caches (they reference old GBuffer)
712
+ this.pipelines.clear()
713
+ this.skinnedPipelines.clear()
714
+ }
715
+
716
+ _destroy() {
717
+ this.pipelines.clear()
718
+ this.skinnedPipelines.clear()
719
+ this.gbuffer = null
720
+ }
721
+
722
+ /**
723
+ * Get the GBuffer for use by subsequent passes
724
+ */
725
+ getGBuffer() {
726
+ return this.gbuffer
727
+ }
728
+ }
729
+
730
+ export { GBuffer, GBufferPass }