@marble.fun/track-catalog 3.0.0

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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/assets.json +346 -0
  4. package/package.json +32 -0
  5. package/release.json +135 -0
  6. package/src/audio/game-audio-preference.ts +9 -0
  7. package/src/authoritative.ts +6 -0
  8. package/src/catalog.ts +1 -0
  9. package/src/index.ts +14 -0
  10. package/src/legacy-stock-catalog.ts +130 -0
  11. package/src/pages/lobby/lobby-types.ts +140 -0
  12. package/src/pages/lobby/marble-factory.ts +931 -0
  13. package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
  14. package/src/pages/play/race/egypt-portal-frame.ts +382 -0
  15. package/src/pages/play/race/finish-detection.ts +1 -0
  16. package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
  17. package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
  18. package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
  19. package/src/pages/play/race/jungle-portal-frame.ts +308 -0
  20. package/src/pages/play/race/jungle-temple-lift.ts +366 -0
  21. package/src/pages/play/race/marble-entity.ts +257 -0
  22. package/src/pages/play/race/marble-material.ts +20 -0
  23. package/src/pages/play/race/marble-race-engine.ts +2652 -0
  24. package/src/pages/play/race/path-pattern-layout.ts +142 -0
  25. package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
  26. package/src/pages/play/race/physics-debug.ts +1 -0
  27. package/src/pages/play/race/physics-dry-run.ts +90 -0
  28. package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
  29. package/src/pages/play/race/portal-pair-color.ts +20 -0
  30. package/src/pages/play/race/portal-proportions.ts +25 -0
  31. package/src/pages/play/race/portal-visual.ts +1108 -0
  32. package/src/pages/play/race/race-camera-director.ts +586 -0
  33. package/src/pages/play/race/race-camera-obstruction.ts +37 -0
  34. package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
  35. package/src/pages/play/race/race-camera.ts +206 -0
  36. package/src/pages/play/race/race-completion.ts +5 -0
  37. package/src/pages/play/race/race-configuration.ts +57 -0
  38. package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
  39. package/src/pages/play/race/race-physics-profile.ts +7 -0
  40. package/src/pages/play/race/race-physics.ts +16 -0
  41. package/src/pages/play/race/race-playback.ts +41 -0
  42. package/src/pages/play/race/race-rolling-audio.ts +616 -0
  43. package/src/pages/play/race/race-simulation-renderer.ts +221 -0
  44. package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
  45. package/src/pages/play/race/race-transform-interpolation.ts +50 -0
  46. package/src/pages/play/race/race-types.ts +12 -0
  47. package/src/pages/play/race/rebound-visual.ts +70 -0
  48. package/src/pages/play/race/seeded-random.ts +23 -0
  49. package/src/pages/play/race/space-station-portal-frame.ts +275 -0
  50. package/src/pages/play/race/track-appearance-canopy.ts +946 -0
  51. package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
  52. package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
  53. package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
  54. package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
  55. package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
  56. package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
  57. package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
  58. package/src/pages/play/race/track-appearance-support.ts +983 -0
  59. package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
  60. package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
  61. package/src/pages/play/race/track-course.ts +271 -0
  62. package/src/pages/play/race/track-definition.ts +443 -0
  63. package/src/pages/play/race/track-fence-architecture.ts +33 -0
  64. package/src/pages/play/race/track-floor-material-rules.ts +51 -0
  65. package/src/pages/play/race/track-mechanisms.ts +1350 -0
  66. package/src/pages/play/race/track-physics-surface.ts +6 -0
  67. package/src/pages/play/race/track-route-network.ts +9 -0
  68. package/src/pages/play/race/track-shadow-batching.ts +760 -0
  69. package/src/pages/play/race/track-surface-basis.ts +1 -0
  70. package/src/pages/play/race/track-surface-topology.ts +326 -0
  71. package/src/pages/play/race/track-world.ts +10181 -0
  72. package/src/pages/play/race/under-track-support-architecture.ts +122 -0
  73. package/src/pages/play/race/vortex-profile.ts +5 -0
  74. package/src/pages/play/race/vortex-water-effect.ts +863 -0
  75. package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
  76. package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
  77. package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
  78. package/src/pages/track-lab/cone-field-layout.ts +1 -0
  79. package/src/pages/track-lab/conveyor-layout.ts +1 -0
  80. package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
  81. package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
  82. package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
  83. package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
  84. package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
  85. package/src/pages/track-lab/flipper-component.ts +127 -0
  86. package/src/pages/track-lab/rising-wall-layout.ts +1 -0
  87. package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
  88. package/src/pages/track-lab/track-piece-layout.ts +28 -0
  89. package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
  90. package/src/three/geometry-attribute-aliases.ts +24 -0
  91. package/src/three/geometry-detail.ts +36 -0
  92. package/src/three/homepage-marble-geometry.ts +15 -0
  93. package/src/three/japanese-sakura-appearance.ts +252 -0
  94. package/src/three/jungle-flower-hedge-appearance.ts +7 -0
  95. package/src/three/jungle-temple-appearance.ts +5 -0
  96. package/src/three/low-quality-scene-renderer.ts +132 -0
  97. package/src/three/marble-sphere-geometry.ts +13 -0
  98. package/src/three/mobile-rendering-budget.ts +11 -0
  99. package/src/three/painted-valley-appearance.ts +270 -0
  100. package/src/three/railroad-appearance.ts +71 -0
  101. package/src/three/renderer-resize.ts +32 -0
  102. package/src/three/rope-wall-appearance.ts +91 -0
  103. package/src/three/rugged-track-appearance.ts +2 -0
  104. package/src/three/scene-lighting.ts +183 -0
  105. package/src/three/scene-shader-preparation.ts +64 -0
  106. package/src/three/scene-theme-controller.ts +461 -0
  107. package/src/three/shadow-shader-preparation.ts +162 -0
  108. package/src/three/shared-obstacle-metal.ts +44 -0
  109. package/src/three/soft-additive-particles.ts +57 -0
  110. package/src/three/space-station-appearance.ts +213 -0
  111. package/src/three/static-track-batched-mesh.ts +132 -0
  112. package/src/three/stone-track-shell-appearance.ts +12 -0
  113. package/src/three/theme-ambience-controller.ts +226 -0
  114. package/src/three/theme-asset-cache.ts +120 -0
  115. package/src/three/theme-audio-preference.ts +43 -0
  116. package/src/three/theme-catalog.ts +1457 -0
  117. package/src/three/theme-material-assets.ts +258 -0
  118. package/src/three/theme-material-ktx2-manifest.ts +190 -0
  119. package/src/three/theme-material-registry.ts +1952 -0
  120. package/src/three/theme-world-factory.ts +590 -0
  121. package/src/three/track-connector-batch.ts +125 -0
  122. package/src/three/track-material-continuity.ts +442 -0
  123. package/src/three/track-theme.ts +535 -0
  124. package/src/three/vampire-castle-appearance.ts +258 -0
  125. package/src/three/visual-frame-pacing.ts +19 -0
  126. package/src/three/visual-geometry.ts +120 -0
  127. package/src/three/visual-quality.ts +83 -0
  128. package/src/three/water-appearance.ts +29 -0
  129. package/src/three/wild-west-sand-appearance.ts +2 -0
@@ -0,0 +1,64 @@
1
+ import * as THREE from 'three'
2
+
3
+ interface SceneShaderPreparationOptions {
4
+ visibleRoot?: THREE.Object3D
5
+ yieldWork?: () => Promise<void>
6
+ shouldContinue?: () => boolean
7
+ includeVisibleScene?: boolean
8
+ }
9
+
10
+ /** Compile actual render objects under their final lighting, without drawing. */
11
+ export async function prepareSceneShaders(
12
+ renderer: THREE.WebGLRenderer,
13
+ root: THREE.Object3D,
14
+ camera: THREE.Camera,
15
+ scene: THREE.Scene,
16
+ options: SceneShaderPreparationOptions = {},
17
+ ): Promise<void> {
18
+ const {
19
+ visibleRoot = root,
20
+ yieldWork = () => new Promise<void>(resolve => setTimeout(resolve, 0)),
21
+ shouldContinue = () => true,
22
+ includeVisibleScene = false,
23
+ } = options
24
+ const proxies: THREE.Object3D[] = []
25
+ const seen = new Set<THREE.Object3D>()
26
+ const collect = (object: THREE.Object3D): void => {
27
+ if (seen.has(object)) return
28
+ seen.add(object)
29
+ if (!(
30
+ object instanceof THREE.Mesh ||
31
+ object instanceof THREE.Line ||
32
+ object instanceof THREE.Points ||
33
+ object instanceof THREE.Sprite
34
+ ))
35
+ return
36
+ // Preserve geometry/instancing/skinning identity without copying buffers.
37
+ // Exclude children so every render object is visited exactly once.
38
+ const proxy = Object.create(object) as THREE.Object3D
39
+ proxy.children = []
40
+ proxies.push(proxy)
41
+ }
42
+ root.traverse(collect)
43
+ // The active theme can place foliage/world objects outside the track root.
44
+ // Include them at race handoff, while excluding unrelated hidden views.
45
+ if (includeVisibleScene) scene.traverseVisible(collect)
46
+ for (let offset = 0; offset < proxies.length; offset += 128) {
47
+ if (!shouldContinue()) return
48
+ const batch = new THREE.Group()
49
+ batch.children = proxies.slice(offset, offset + 128)
50
+ const visible = visibleRoot.visible
51
+ let pending: Promise<THREE.Object3D>
52
+ try {
53
+ // Three gathers lights synchronously inside compileAsync. Restore the
54
+ // cover's visibility before awaiting, so a shared animation loop never
55
+ // sees the hidden track. Hidden portal lights still enter the shader key.
56
+ visibleRoot.visible = true
57
+ pending = renderer.compileAsync(batch, camera, scene)
58
+ } finally {
59
+ visibleRoot.visible = visible
60
+ }
61
+ await pending
62
+ if (offset + 128 < proxies.length) await yieldWork()
63
+ }
64
+ }
@@ -0,0 +1,461 @@
1
+ import * as THREE from 'three'
2
+
3
+ import {
4
+ computeShadowFocus,
5
+ configureKeyShadow,
6
+ configureSceneRenderer,
7
+ disposeSceneLightShadows,
8
+ resolveDynamicShadowRefreshRate,
9
+ resolveSceneLightingRig,
10
+ usesMobileSceneLighting,
11
+ type SceneLightingRig,
12
+ } from './scene-lighting'
13
+ import { themeAssetCache, type ThemePanoramaLease } from './theme-asset-cache'
14
+ import { ThemeAmbienceController } from './theme-ambience-controller'
15
+ import { ThemeMaterialRegistry } from './theme-material-registry'
16
+ import { configureThemeMaterialTextureLoader } from './theme-material-assets'
17
+ import { NEUTRAL_STUDIO_THEME, type TrackThemeDefinition } from './track-theme'
18
+ import { createThemeWorld, type ThemeWorldState } from './theme-world-factory'
19
+ import { visualQualityPolicy, type VisualQualityPolicy } from './visual-quality'
20
+
21
+ export interface SceneThemeDiagnostics {
22
+ themeId: string
23
+ materialCount: number
24
+ worldMeshes: number
25
+ instancedMeshes: number
26
+ particleCount: number
27
+ particleSample?: readonly [number, number, number]
28
+ practicalLightCount: number
29
+ practicalLightPositions: readonly (readonly [number, number, number])[]
30
+ shadowMapSize: number
31
+ shadowExtent: number
32
+ shadowIntensity: number
33
+ exposure: number
34
+ environmentIntensity: number
35
+ }
36
+
37
+ export class SceneThemeController {
38
+ readonly materials = new ThemeMaterialRegistry(NEUTRAL_STUDIO_THEME)
39
+
40
+ private readonly lightRoot = new THREE.Group()
41
+ private readonly practicalLights: THREE.PointLight[] = []
42
+ private readonly ambience = new ThemeAmbienceController()
43
+ private readonly keyOffset = new THREE.Vector3()
44
+ private readonly shadowFocus = new THREE.Vector3()
45
+ private readonly nextShadowFocus = new THREE.Vector3()
46
+ private keyLight?: THREE.DirectionalLight
47
+ private lightingRig = resolveSceneLightingRig(NEUTRAL_STUDIO_THEME, false)
48
+ private world?: ThemeWorldState
49
+ private activeTheme = NEUTRAL_STUDIO_THEME
50
+ private panoramaLease?: ThemePanoramaLease
51
+ private requestVersion = 0
52
+ private elapsedSeconds = 0
53
+ private practicalLightRefreshElapsed = 0
54
+ private shadowRefreshElapsed = Number.POSITIVE_INFINITY
55
+ private shadowFocusInitialized = false
56
+ private dynamicShadowBudgetApplied = false
57
+ private adaptiveDynamicShadowRefreshRate: 30 | 60 = 60
58
+ private dynamicShadowRefreshRate = 60
59
+ private visualQualityPolicy = visualQualityPolicy('quality')
60
+ private disposed = false
61
+
62
+ constructor(
63
+ private readonly scene: THREE.Scene,
64
+ private readonly renderer: THREE.WebGLRenderer,
65
+ private readonly container: HTMLElement,
66
+ ) {
67
+ configureThemeMaterialTextureLoader(this.renderer)
68
+ this.scene.add(this.lightRoot)
69
+ this.applyPreparedTheme(NEUTRAL_STUDIO_THEME, undefined)
70
+ }
71
+
72
+ get theme(): TrackThemeDefinition {
73
+ return this.activeTheme
74
+ }
75
+
76
+ async applyTheme(theme: TrackThemeDefinition): Promise<boolean> {
77
+ this.assertActive()
78
+ const requestVersion = ++this.requestVersion
79
+ this.container.dataset.themeLoading = theme.id
80
+ let pendingPanoramaLease: ThemePanoramaLease | undefined
81
+ try {
82
+ pendingPanoramaLease = await themeAssetCache.acquirePanorama(theme)
83
+ if (requestVersion !== this.requestVersion || this.disposed) {
84
+ return false
85
+ }
86
+
87
+ this.container.classList.add('is-theme-transitioning')
88
+ this.applyPreparedTheme(theme, pendingPanoramaLease.texture)
89
+ this.panoramaLease?.release()
90
+ this.panoramaLease = pendingPanoramaLease
91
+ pendingPanoramaLease = undefined
92
+ window.setTimeout(() => this.container.classList.remove('is-theme-transitioning'), 320)
93
+ return true
94
+ } catch (error) {
95
+ if (requestVersion === this.requestVersion && !this.disposed) {
96
+ this.container.dataset.themeError = theme.id
97
+ }
98
+ console.error(`Unable to load the "${theme.id}" theme panorama.`, error)
99
+ return false
100
+ } finally {
101
+ pendingPanoramaLease?.release()
102
+ if (requestVersion === this.requestVersion) delete this.container.dataset.themeLoading
103
+ }
104
+ }
105
+
106
+ update(deltaSeconds: number, camera?: THREE.Camera, dynamicShadows = false, shadowFocusTarget?: THREE.Vector3): void {
107
+ if (this.disposed) return
108
+ this.elapsedSeconds += deltaSeconds
109
+ this.world?.update(deltaSeconds, this.elapsedSeconds)
110
+ if (camera && this.updateKeyShadowFocus(camera, shadowFocusTarget)) this.requestShadowUpdate()
111
+ if (dynamicShadows) {
112
+ this.applyDynamicShadowBudget()
113
+ this.shadowRefreshElapsed += deltaSeconds
114
+ this.adaptiveDynamicShadowRefreshRate = resolveDynamicShadowRefreshRate(
115
+ this.container.clientWidth,
116
+ deltaSeconds,
117
+ this.adaptiveDynamicShadowRefreshRate,
118
+ )
119
+ this.dynamicShadowRefreshRate = Math.min(
120
+ this.adaptiveDynamicShadowRefreshRate,
121
+ this.visualQualityPolicy.shadowRefreshRate,
122
+ )
123
+ if (this.shadowRefreshElapsed >= 1 / this.dynamicShadowRefreshRate) this.requestShadowUpdate()
124
+ } else {
125
+ this.restoreStaticShadowBudget()
126
+ }
127
+ if (this.practicalLights.length > 0) {
128
+ this.practicalLightRefreshElapsed += deltaSeconds
129
+ if (this.practicalLightRefreshElapsed >= 1.5) {
130
+ this.practicalLightRefreshElapsed = 0
131
+ this.refreshPracticalLightPositions()
132
+ }
133
+ }
134
+ }
135
+
136
+ requestShadowUpdate(): void {
137
+ this.shadowRefreshElapsed = 0
138
+ this.renderer.shadowMap.needsUpdate = true
139
+ }
140
+
141
+ setVisualQualityPolicy(policy: VisualQualityPolicy): void {
142
+ if (this.disposed || this.visualQualityPolicy === policy) return
143
+ this.visualQualityPolicy = policy
144
+ this.renderer.shadowMap.enabled = policy.tier !== 'low'
145
+ this.dynamicShadowRefreshRate = Math.min(this.dynamicShadowRefreshRate, policy.shadowRefreshRate)
146
+ this.applyParticleBudget()
147
+ if (!this.keyLight) return
148
+ const shadow = this.keyLight.shadow
149
+ const mapSize = this.desiredShadowMapSize(this.dynamicShadowBudgetApplied)
150
+ if (shadow.mapSize.width === mapSize && shadow.mapSize.height === mapSize) return
151
+ shadow.map?.dispose()
152
+ shadow.map = null
153
+ shadow.mapSize.set(mapSize, mapSize)
154
+ this.requestShadowUpdate()
155
+ }
156
+
157
+ diagnostics(): SceneThemeDiagnostics {
158
+ let worldMeshes = 0
159
+ let instancedMeshes = 0
160
+ let particleCount = 0
161
+ let particleSample: readonly [number, number, number] | undefined
162
+ this.world?.root.traverse(object => {
163
+ if (object instanceof THREE.InstancedMesh) {
164
+ instancedMeshes += 1
165
+ worldMeshes += 1
166
+ } else if (object instanceof THREE.Mesh) {
167
+ worldMeshes += 1
168
+ } else if (object instanceof THREE.Points) {
169
+ const positions = object.geometry.getAttribute('position')
170
+ particleCount += positions?.count ?? 0
171
+ if (!particleSample && positions?.count) {
172
+ particleSample = [positions.getX(0), positions.getY(0), positions.getZ(0)]
173
+ }
174
+ }
175
+ })
176
+ return {
177
+ themeId: this.activeTheme.id,
178
+ materialCount: Object.keys(this.activeTheme.materials).length,
179
+ worldMeshes,
180
+ instancedMeshes,
181
+ particleCount,
182
+ particleSample,
183
+ practicalLightCount: this.practicalLights.length,
184
+ practicalLightPositions: this.practicalLights.map(
185
+ light => [light.position.x, light.position.y, light.position.z] as const,
186
+ ),
187
+ shadowMapSize: this.keyLight?.shadow.mapSize.width ?? this.lightingRig.shadow.mapSize,
188
+ shadowExtent: this.lightingRig.shadow.extent,
189
+ shadowIntensity: this.lightingRig.shadow.intensity,
190
+ exposure: this.lightingRig.exposure,
191
+ environmentIntensity: this.lightingRig.environmentIntensity,
192
+ }
193
+ }
194
+
195
+ dispose(): void {
196
+ if (this.disposed) return
197
+ this.disposed = true
198
+ this.requestVersion += 1
199
+ this.world?.dispose()
200
+ this.world = undefined
201
+ disposeSceneLightShadows(this.lightRoot)
202
+ this.lightRoot.removeFromParent()
203
+ this.materials.dispose()
204
+ this.panoramaLease?.release()
205
+ this.panoramaLease = undefined
206
+ this.ambience.dispose()
207
+ this.scene.environment = null
208
+ }
209
+
210
+ private applyPreparedTheme(theme: TrackThemeDefinition, panorama: THREE.Texture | undefined): void {
211
+ this.materials.applyTheme(theme)
212
+ const mobile = usesMobileSceneLighting(this.container.clientWidth)
213
+ const preparedWorld = createThemeWorld(theme, this.materials, mobile)
214
+ this.world?.dispose()
215
+ this.world = preparedWorld
216
+ this.applyParticleBudget()
217
+ this.scene.add(preparedWorld.root)
218
+ disposeSceneLightShadows(this.lightRoot)
219
+ this.lightRoot.clear()
220
+ this.keyLight = undefined
221
+ this.practicalLights.length = 0
222
+ this.practicalLightRefreshElapsed = 0
223
+ this.shadowFocusInitialized = false
224
+ this.shadowRefreshElapsed = Number.POSITIVE_INFINITY
225
+ this.dynamicShadowBudgetApplied = false
226
+ this.adaptiveDynamicShadowRefreshRate = mobile ? 30 : 60
227
+ this.dynamicShadowRefreshRate = Math.min(
228
+ this.adaptiveDynamicShadowRefreshRate,
229
+ this.visualQualityPolicy.shadowRefreshRate,
230
+ )
231
+ this.lightingRig = resolveSceneLightingRig(theme, mobile)
232
+ this.configureLights(theme, this.lightingRig, mobile)
233
+ this.setKeyShadowMapSize(this.desiredShadowMapSize(false))
234
+
235
+ this.scene.background = panorama ?? new THREE.Color(theme.environment.background)
236
+ this.scene.environment = panorama ?? null
237
+ this.scene.environmentIntensity = this.lightingRig.environmentIntensity
238
+ this.scene.fog = new THREE.Fog(
239
+ theme.environment.fog,
240
+ theme.environment.fogNear,
241
+ Math.max(theme.environment.fogFar, 480),
242
+ )
243
+ configureSceneRenderer(this.renderer, this.lightingRig.exposure)
244
+ this.renderer.shadowMap.enabled = this.visualQualityPolicy.tier !== 'low'
245
+ this.activeTheme = theme
246
+ this.ambience.applyTheme(theme)
247
+ this.container.dataset.themeId = theme.id
248
+ delete this.container.dataset.themeError
249
+ }
250
+
251
+ private configureLights(theme: TrackThemeDefinition, rig: SceneLightingRig, mobile: boolean): void {
252
+ const hemisphere = new THREE.HemisphereLight(
253
+ rig.hemisphere.skyColor,
254
+ rig.hemisphere.groundColor,
255
+ rig.hemisphere.intensity,
256
+ )
257
+ hemisphere.name = 'theme-hemisphere'
258
+
259
+ const key = new THREE.DirectionalLight(rig.key.color, rig.key.intensity)
260
+ key.name = 'theme-key'
261
+ key.position.set(...rig.key.position)
262
+ this.keyOffset.copy(key.position)
263
+ configureKeyShadow(key, rig)
264
+ key.target.name = 'theme-key-target'
265
+ this.keyLight = key
266
+
267
+ const rim = new THREE.DirectionalLight(rig.rim.color, rig.rim.intensity)
268
+ rim.name = 'theme-rim'
269
+ rim.position.set(...rig.rim.position)
270
+ this.lightRoot.add(hemisphere, key, key.target, rim)
271
+
272
+ const practical = theme.lighting.practical
273
+ if (!practical) return
274
+ const lightCount = Math.max(1, Math.min(mobile ? 2 : 4, Math.round(practical.count)))
275
+ for (let index = 0; index < lightCount; index += 1) {
276
+ const light = new THREE.PointLight(practical.color, practical.intensity, practical.distance, practical.decay)
277
+ light.name = `theme-practical-${index + 1}`
278
+ light.castShadow = false
279
+ this.practicalLights.push(light)
280
+ this.lightRoot.add(light)
281
+ }
282
+ this.refreshPracticalLightPositions(practical)
283
+ }
284
+
285
+ private updateKeyShadowFocus(camera: THREE.Camera, focusTarget?: THREE.Vector3): boolean {
286
+ if (!this.keyLight) return false
287
+ computeShadowFocus(camera, this.lightingRig, this.nextShadowFocus, focusTarget, this.keyLight.shadow.mapSize.width)
288
+ const recenterDistance = this.lightingRig.shadow.extent * 0.18
289
+ if (
290
+ this.shadowFocusInitialized &&
291
+ this.nextShadowFocus.distanceToSquared(this.shadowFocus) < recenterDistance * recenterDistance
292
+ ) {
293
+ return false
294
+ }
295
+ this.shadowFocus.copy(this.nextShadowFocus)
296
+ this.shadowFocusInitialized = true
297
+ this.keyLight.target.position.copy(this.shadowFocus)
298
+ this.keyLight.position.copy(this.shadowFocus).add(this.keyOffset)
299
+ this.keyLight.target.updateMatrixWorld()
300
+ return true
301
+ }
302
+
303
+ private applyDynamicShadowBudget(): void {
304
+ if (this.dynamicShadowBudgetApplied || !this.keyLight) return
305
+ this.dynamicShadowBudgetApplied = true
306
+ const shadow = this.keyLight.shadow
307
+ shadow.map?.dispose()
308
+ shadow.map = null
309
+ shadow.mapSize.set(this.desiredShadowMapSize(true), this.desiredShadowMapSize(true))
310
+ this.requestShadowUpdate()
311
+ }
312
+
313
+ private restoreStaticShadowBudget(): void {
314
+ if (!this.dynamicShadowBudgetApplied || !this.keyLight) return
315
+ this.dynamicShadowBudgetApplied = false
316
+ const shadow = this.keyLight.shadow
317
+ shadow.map?.dispose()
318
+ shadow.map = null
319
+ const mapSize = this.desiredShadowMapSize(false)
320
+ shadow.mapSize.set(mapSize, mapSize)
321
+ this.requestShadowUpdate()
322
+ }
323
+
324
+ private applyParticleBudget(): void {
325
+ this.world?.root.traverse(object => {
326
+ if (!(object instanceof THREE.Points)) return
327
+ const count = object.geometry.getAttribute('position')?.count ?? 0
328
+ object.geometry.setDrawRange(0, Math.max(0, Math.round(count * this.visualQualityPolicy.particleScale)))
329
+ })
330
+ }
331
+
332
+ private desiredShadowMapSize(dynamic: boolean): number {
333
+ if (dynamic) return this.visualQualityPolicy.shadowMapSize
334
+ return this.visualQualityPolicy.tier === 'performance'
335
+ ? Math.min(this.lightingRig.shadow.mapSize, this.visualQualityPolicy.shadowMapSize)
336
+ : this.lightingRig.shadow.mapSize
337
+ }
338
+
339
+ private setKeyShadowMapSize(mapSize: number): void {
340
+ if (!this.keyLight) return
341
+ const shadow = this.keyLight.shadow
342
+ if (shadow.mapSize.width === mapSize && shadow.mapSize.height === mapSize) return
343
+ shadow.map?.dispose()
344
+ shadow.map = null
345
+ shadow.mapSize.set(mapSize, mapSize)
346
+ this.requestShadowUpdate()
347
+ }
348
+
349
+ private refreshPracticalLightPositions(
350
+ practical: TrackThemeDefinition['lighting']['practical'] = this.activeTheme.lighting.practical,
351
+ ): void {
352
+ if (!practical || this.practicalLights.length === 0) return
353
+ const positions = distributePracticalLightPositions(
354
+ this.collectTrackLightCandidates(),
355
+ this.practicalLights.length,
356
+ practical.heightOffset,
357
+ )
358
+ for (const [index, light] of this.practicalLights.entries()) {
359
+ light.position.copy(positions[index] ?? new THREE.Vector3(0, practical.heightOffset, 0))
360
+ }
361
+ }
362
+
363
+ private collectTrackLightCandidates(): THREE.Vector3[] {
364
+ const pieceBounds = new Map<string, THREE.Box3>()
365
+ const ungroupedBounds: THREE.Box3[] = []
366
+
367
+ for (const root of this.scene.children) {
368
+ if (root === this.lightRoot || root === this.world?.root) continue
369
+ root.updateWorldMatrix(true, true)
370
+ root.traverseVisible(object => {
371
+ if (!(object instanceof THREE.Mesh) || !hasPracticalFloorMaterial(object.material)) return
372
+ const geometryBounds = object.geometry.boundingBox
373
+ if (!geometryBounds) object.geometry.computeBoundingBox()
374
+ if (!object.geometry.boundingBox) return
375
+ const worldBounds = object.geometry.boundingBox.clone().applyMatrix4(object.matrixWorld)
376
+ const pieceId = object.userData.pieceInstanceId
377
+ if (typeof pieceId === 'string') {
378
+ const bounds = pieceBounds.get(pieceId)
379
+ if (bounds) bounds.union(worldBounds)
380
+ else pieceBounds.set(pieceId, worldBounds)
381
+ } else {
382
+ ungroupedBounds.push(worldBounds)
383
+ }
384
+ })
385
+ }
386
+
387
+ return [...pieceBounds.values(), ...ungroupedBounds].map(bounds => bounds.getCenter(new THREE.Vector3()))
388
+ }
389
+
390
+ private assertActive(): void {
391
+ if (this.disposed) throw new Error('SceneThemeController has been disposed.')
392
+ }
393
+ }
394
+
395
+ function hasPracticalFloorMaterial(material: THREE.Material | THREE.Material[]): boolean {
396
+ const materials = Array.isArray(material) ? material : [material]
397
+ return materials.some(
398
+ candidate =>
399
+ candidate instanceof THREE.MeshStandardMaterial &&
400
+ candidate.emissiveMap instanceof THREE.Texture &&
401
+ candidate.emissiveIntensity > 0,
402
+ )
403
+ }
404
+
405
+ export function distributePracticalLightPositions(
406
+ candidates: readonly THREE.Vector3[],
407
+ count: number,
408
+ heightOffset: number,
409
+ ): THREE.Vector3[] {
410
+ const safeCount = Math.max(0, Math.round(count))
411
+ if (safeCount === 0) return []
412
+ if (candidates.length === 0) {
413
+ return Array.from({ length: safeCount }, (_, index) => {
414
+ const angle = (index / safeCount) * Math.PI * 2 + Math.PI / 4
415
+ return new THREE.Vector3(Math.cos(angle) * 5, heightOffset, Math.sin(angle) * 5)
416
+ })
417
+ }
418
+
419
+ const bounds = new THREE.Box3().setFromPoints([...candidates])
420
+ const size = bounds.getSize(new THREE.Vector3())
421
+ const layouts: readonly (readonly [number, number])[][] = [
422
+ [[0.5, 0.5]],
423
+ [
424
+ [0.25, 0.3],
425
+ [0.75, 0.7],
426
+ ],
427
+ [
428
+ [0.2, 0.25],
429
+ [0.8, 0.3],
430
+ [0.5, 0.78],
431
+ ],
432
+ [
433
+ [0.2, 0.22],
434
+ [0.78, 0.28],
435
+ [0.28, 0.76],
436
+ [0.82, 0.8],
437
+ ],
438
+ ]
439
+ const layout = layouts[Math.min(safeCount, layouts.length) - 1]!
440
+ const available = candidates.map(candidate => candidate.clone())
441
+ const positions: THREE.Vector3[] = []
442
+
443
+ for (let index = 0; index < safeCount; index += 1) {
444
+ const normalized = layout[index % layout.length]!
445
+ const targetX = bounds.min.x + size.x * normalized[0]
446
+ const targetZ = bounds.min.z + size.z * normalized[1]
447
+ let nearestIndex = 0
448
+ let nearestDistance = Number.POSITIVE_INFINITY
449
+ for (const [candidateIndex, candidate] of available.entries()) {
450
+ const distance = Math.hypot(candidate.x - targetX, candidate.z - targetZ)
451
+ if (distance < nearestDistance) {
452
+ nearestIndex = candidateIndex
453
+ nearestDistance = distance
454
+ }
455
+ }
456
+ const candidate = available.splice(nearestIndex, 1)[0] ?? candidates[index % candidates.length]!.clone()
457
+ positions.push(candidate.add(new THREE.Vector3(0, heightOffset, 0)))
458
+ }
459
+
460
+ return positions
461
+ }
@@ -0,0 +1,162 @@
1
+ import * as THREE from 'three'
2
+
3
+ type ShadowMaterial = THREE.MeshDepthMaterial | THREE.MeshDistanceMaterial
4
+ type Caster = THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>
5
+
6
+ /** Shader-only preparation; never draws a scene or copies/uploads track geometry. */
7
+ export class ShadowShaderPreparation {
8
+ private retained = new Set<ShadowMaterial>()
9
+ private generation = 0
10
+ private disposed = false
11
+
12
+ async prepare(
13
+ renderer: THREE.WebGLRenderer,
14
+ root: THREE.Object3D,
15
+ camera: THREE.Camera,
16
+ scene: THREE.Scene,
17
+ yieldWork: () => Promise<void> = () => new Promise(resolve => setTimeout(resolve, 0)),
18
+ ): Promise<void> {
19
+ if (this.disposed) return
20
+ const generation = ++this.generation
21
+ if (!renderer.shadowMap.enabled) {
22
+ this.releaseRetained()
23
+ return
24
+ }
25
+ const lighting = new THREE.Scene()
26
+ let directional = false
27
+ let point = false
28
+ // Shadow draws use the current light configuration, but no scene fog or
29
+ // environment. Copies keep the actual light hierarchy entirely untouched.
30
+ const lights = new Set<THREE.Light>()
31
+ const collectLight = (object: THREE.Object3D): void => {
32
+ if (!(object instanceof THREE.Light) || !object.layers.test(camera.layers)) return
33
+ if (lights.has(object)) return
34
+ lights.add(object)
35
+ lighting.add(object.clone(false))
36
+ if (object.castShadow) {
37
+ if (object instanceof THREE.PointLight) point = true
38
+ else directional = true
39
+ }
40
+ }
41
+ scene.traverseVisible(collectLight)
42
+ // A loading cover can hide the prepared track root. Its visible children
43
+ // will contribute lights when the track is revealed; compile that state.
44
+ collectLight(root)
45
+ root.children.forEach(child => child.traverseVisible(collectLight))
46
+ if (!directional && !point) {
47
+ this.releaseRetained()
48
+ return
49
+ }
50
+
51
+ const retained = new Set<ShadowMaterial>()
52
+ const depthCache = new Map<THREE.Material, ShadowMaterial>()
53
+ const distanceCache = new Map<THREE.Material, ShadowMaterial>()
54
+ const proxies: THREE.Object3D[] = []
55
+ scene.traverse(object => {
56
+ if (!(object instanceof THREE.Mesh) || !object.castShadow) return
57
+ const caster = object as Caster
58
+ for (const distance of point ? (directional ? [false, true] : [true]) : [false]) {
59
+ const cache = distance ? distanceCache : depthCache
60
+ const custom = distance ? caster.customDistanceMaterial : caster.customDepthMaterial
61
+ const materials = (Array.isArray(caster.material) ? caster.material : [caster.material]).map(source => {
62
+ // Custom shadow shaders are object-specific; ordinary casters can
63
+ // share one preparation material for each source material.
64
+ let material = custom ? undefined : cache.get(source)
65
+ if (!material) {
66
+ material = custom
67
+ ? (custom.clone() as ShadowMaterial)
68
+ : distance
69
+ ? new THREE.MeshDistanceMaterial()
70
+ : new THREE.MeshDepthMaterial()
71
+ if (custom) {
72
+ material.onBeforeCompile = custom.onBeforeCompile
73
+ material.customProgramCacheKey = custom.customProgramCacheKey
74
+ }
75
+ configureShadowMaterial(material, source, renderer.shadowMap.type)
76
+ retained.add(material)
77
+ if (!custom) cache.set(source, material)
78
+ }
79
+ return material
80
+ })
81
+ // A read-only view preserves Three's instancing, batching, morph and
82
+ // skinning flags without cloning their buffers or reparenting objects.
83
+ const proxy = Object.create(caster) as Caster
84
+ proxy.material = Array.isArray(caster.material) ? materials : materials[0]!
85
+ proxy.children = []
86
+ proxies.push(proxy)
87
+ }
88
+ })
89
+
90
+ // compileAsync derives the output colour space from the active target.
91
+ // A single temporary pixel matches a shadow pass without allocating a
92
+ // shadow-sized buffer, uploading geometry, or rendering hidden views.
93
+ const target = new THREE.WebGLRenderTarget(1, 1, { depthBuffer: false })
94
+ let committed = false
95
+ try {
96
+ for (let offset = 0; offset < proxies.length; offset += 128) {
97
+ if (this.disposed || generation !== this.generation) return
98
+ const batch = new THREE.Group()
99
+ batch.children = proxies.slice(offset, offset + 128)
100
+ const previous = renderer.getRenderTarget()
101
+ const face = renderer.getActiveCubeFace()
102
+ const mip = renderer.getActiveMipmapLevel()
103
+ let pending: Promise<THREE.Object3D>
104
+ try {
105
+ renderer.setRenderTarget(target)
106
+ pending = renderer.compileAsync(batch, camera, lighting)
107
+ } finally {
108
+ // Restore synchronously: the shared lobby may render while shader
109
+ // completion is awaited. Never leave it pointed at the scratch pixel.
110
+ renderer.setRenderTarget(previous, face, mip)
111
+ }
112
+ await pending
113
+ if (offset + 128 < proxies.length) await yieldWork()
114
+ }
115
+ if (this.disposed || generation !== this.generation) return
116
+ // Hold program references until replacement/disposal; disposing them at
117
+ // the end of preparation would evict shaders not yet used by a shadow.
118
+ this.retained.forEach(material => material.dispose())
119
+ this.retained = retained
120
+ committed = true
121
+ } finally {
122
+ target.dispose()
123
+ if (!committed) retained.forEach(material => material.dispose())
124
+ }
125
+ }
126
+
127
+ dispose(): void {
128
+ this.disposed = true
129
+ this.generation++
130
+ this.releaseRetained()
131
+ }
132
+
133
+ private releaseRetained(): void {
134
+ this.retained.forEach(material => material.dispose())
135
+ this.retained.clear()
136
+ }
137
+ }
138
+
139
+ function configureShadowMaterial(target: ShadowMaterial, source: THREE.Material, type: THREE.ShadowMapType): void {
140
+ // Mirrors WebGLShadowMap.getDepthMaterial in pinned Three 0.185.1. Keep
141
+ // alpha/displacement/clipping and side variants identical to real shadows.
142
+ const material = source as THREE.MeshStandardMaterial
143
+ target.side =
144
+ source.shadowSide ??
145
+ (type === THREE.VSMShadowMap
146
+ ? source.side
147
+ : source.side === THREE.FrontSide
148
+ ? THREE.BackSide
149
+ : source.side === THREE.BackSide
150
+ ? THREE.FrontSide
151
+ : THREE.DoubleSide)
152
+ target.map = material.map ?? null
153
+ target.alphaMap = material.alphaMap ?? null
154
+ target.alphaTest = source.alphaToCoverage ? 0.5 : source.alphaTest
155
+ target.displacementMap = material.displacementMap ?? null
156
+ target.displacementScale = material.displacementScale ?? 1
157
+ target.displacementBias = material.displacementBias ?? 0
158
+ target.clippingPlanes = source.clippingPlanes
159
+ target.clipShadows = source.clipShadows
160
+ target.clipIntersection = source.clipIntersection
161
+ if (target instanceof THREE.MeshDepthMaterial) target.wireframe = material.wireframe ?? false
162
+ }