@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,1108 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualSphereGeometry, VisualCylinderGeometry, VisualTorusGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+
5
+ import { createSoftAdditiveParticleMaterial } from '../../../three/soft-additive-particles'
6
+ import { addEgyptPortalFrame, EGYPT_PORTAL_FRAME_THEME_ID, type EgyptPortalFrameMaterials } from './egypt-portal-frame'
7
+ import {
8
+ addJunglePortalFrame,
9
+ JUNGLE_PORTAL_FRAME_THEME_ID,
10
+ type JunglePortalFrameMaterials,
11
+ } from './jungle-portal-frame'
12
+ import {
13
+ addJapaneseSakuraPortalFrame,
14
+ JAPANESE_SAKURA_PORTAL_FRAME_THEME_ID,
15
+ type JapaneseSakuraPortalFrameMaterials,
16
+ } from './japanese-sakura-portal-frame'
17
+ import {
18
+ addSpaceStationPortalFrame,
19
+ SPACE_STATION_PORTAL_FRAME_THEME_ID,
20
+ type SpaceStationPortalFrameMaterials,
21
+ updateSpaceStationPortalFrame,
22
+ } from './space-station-portal-frame'
23
+ import {
24
+ PORTAL_APERTURE_HALF_WIDTH,
25
+ PORTAL_DECK_WIDTH,
26
+ PORTAL_DUST_PLUME_WIDTH,
27
+ PORTAL_RIFT_HALF_HEIGHT,
28
+ PORTAL_RIFT_HALF_WIDTH,
29
+ } from './portal-proportions'
30
+ import type { PortalVisualStyle } from './track-definition'
31
+
32
+ export const PORTAL_LIVE_WINDOW_ID = 'portal.live-window'
33
+ export const PORTAL_FROZEN_RIFT_ID = 'portal.frozen-rift'
34
+ export const PORTAL_RENDER_TARGET_TEXTURE = 'portalRenderTargetTexture'
35
+
36
+ const PORTAL_AUXILIARY_EFFECT = 'portalAuxiliaryEffect'
37
+
38
+ export const PORTAL_VISUAL_OPTIONS = [{ id: PORTAL_LIVE_WINDOW_ID, label: 'Paired portal' }] as const
39
+
40
+ export interface PortalVisualMaterials {
41
+ activeThemeId?: string
42
+ structure: THREE.Material
43
+ timber: THREE.Material
44
+ metal: THREE.Material
45
+ portal: THREE.Material
46
+ energy: THREE.Material
47
+ jungle?: JunglePortalFrameMaterials
48
+ egypt?: EgyptPortalFrameMaterials
49
+ sakura?: JapaneseSakuraPortalFrameMaterials
50
+ spaceStation?: SpaceStationPortalFrameMaterials
51
+ }
52
+
53
+ export interface PortalEndpointVisual {
54
+ group: THREE.Group
55
+ screen: THREE.Mesh
56
+ setTransparentAperture(): void
57
+ setFallback(color: string): void
58
+ markLiveTextureStale(): void
59
+ setLiveTexture(texture: THREE.Texture): void
60
+ setLiveBlend(blend: number): void
61
+ update(timeSeconds: number): void
62
+ }
63
+
64
+ export const PORTAL_LIVE_VIEW_FADE_START_RATIO = 0.017
65
+ const PORTAL_LIVE_VIEW_FADE_END_RATIO = 0.04
66
+
67
+ export function portalLiveViewBlend(screenRatio: number): number {
68
+ return THREE.MathUtils.smoothstep(
69
+ screenRatio,
70
+ PORTAL_LIVE_VIEW_FADE_START_RATIO,
71
+ PORTAL_LIVE_VIEW_FADE_END_RATIO,
72
+ )
73
+ }
74
+
75
+ const portalViewFlip = new THREE.Matrix4().makeRotationY(Math.PI)
76
+ const portalViewFlipQuaternion = new THREE.Quaternion().setFromAxisAngle(THREE.Object3D.DEFAULT_UP, Math.PI)
77
+ const portalViewSourceInverse = new THREE.Matrix4()
78
+ const portalViewWorldMatrix = new THREE.Matrix4()
79
+ const portalViewScale = new THREE.Vector3()
80
+ const portalViewDestinationQuaternion = new THREE.Quaternion()
81
+ const portalViewCorner = new THREE.Vector3()
82
+ const portalViewCornerSelectors = [
83
+ [false, false],
84
+ [true, false],
85
+ [true, true],
86
+ [false, true],
87
+ ] as const
88
+ const fixedPortalViewPosition = new THREE.Vector3()
89
+ const fixedPortalViewQuaternion = new THREE.Quaternion()
90
+ const fixedPortalViewScale = new THREE.Vector3()
91
+
92
+ /** Places a stable security-camera-style view immediately beyond a doorway. */
93
+ export function positionFixedPortalViewCamera(
94
+ portalCamera: THREE.PerspectiveCamera,
95
+ viewerCamera: THREE.Camera,
96
+ destinationPortal: THREE.Object3D,
97
+ targetAspect: number,
98
+ ): void {
99
+ destinationPortal.updateWorldMatrix(true, false)
100
+ destinationPortal.matrixWorld.decompose(fixedPortalViewPosition, fixedPortalViewQuaternion, fixedPortalViewScale)
101
+ fixedPortalViewQuaternion.multiply(portalViewFlipQuaternion)
102
+ portalCamera.position.copy(fixedPortalViewPosition)
103
+ portalCamera.quaternion.copy(fixedPortalViewQuaternion)
104
+ portalCamera.up.set(0, 1, 0).applyQuaternion(fixedPortalViewQuaternion).normalize()
105
+ portalCamera.fov = 62
106
+ portalCamera.zoom = 1
107
+ portalCamera.near = 0.08
108
+ portalCamera.far = 260
109
+ portalCamera.filmGauge = 35
110
+ portalCamera.filmOffset = 0
111
+ portalCamera.aspect = targetAspect
112
+ portalCamera.layers.mask = viewerCamera.layers.mask
113
+ portalCamera.updateProjectionMatrix()
114
+ portalCamera.updateMatrixWorld(true)
115
+ }
116
+
117
+ /**
118
+ * Places the auxiliary camera at the viewer's equivalent position through a
119
+ * paired doorway. This is allocation-free and changes only the existing
120
+ * portal pass; it does not add another render or alter simulation state.
121
+ */
122
+ export function positionPortalViewCamera(
123
+ portalCamera: THREE.PerspectiveCamera,
124
+ viewerCamera: THREE.Camera,
125
+ sourcePortal: THREE.Object3D,
126
+ destinationPortal: THREE.Object3D,
127
+ destinationAperture: THREE.Mesh,
128
+ targetAspect: number,
129
+ ): void {
130
+ viewerCamera.updateWorldMatrix(true, false)
131
+ sourcePortal.updateWorldMatrix(true, false)
132
+ destinationPortal.updateWorldMatrix(true, false)
133
+
134
+ portalViewSourceInverse.copy(sourcePortal.matrixWorld).invert()
135
+ portalViewWorldMatrix
136
+ .copy(destinationPortal.matrixWorld)
137
+ .multiply(portalViewFlip)
138
+ .multiply(portalViewSourceInverse)
139
+ .multiply(viewerCamera.matrixWorld)
140
+ portalViewWorldMatrix.decompose(portalCamera.position, portalCamera.quaternion, portalViewScale)
141
+ destinationPortal.getWorldQuaternion(portalViewDestinationQuaternion)
142
+ portalCamera.quaternion.copy(portalViewDestinationQuaternion).multiply(portalViewFlipQuaternion)
143
+ portalCamera.up.set(0, 1, 0).applyQuaternion(portalCamera.quaternion).normalize()
144
+
145
+ if (viewerCamera instanceof THREE.PerspectiveCamera) {
146
+ portalCamera.fov = viewerCamera.fov
147
+ portalCamera.zoom = viewerCamera.zoom
148
+ portalCamera.filmGauge = viewerCamera.filmGauge
149
+ portalCamera.filmOffset = viewerCamera.filmOffset
150
+ } else {
151
+ portalCamera.fov = 62
152
+ portalCamera.zoom = 1
153
+ portalCamera.filmGauge = 35
154
+ portalCamera.filmOffset = 0
155
+ }
156
+ portalCamera.near = 0.08
157
+ portalCamera.far = 260
158
+ portalCamera.aspect = targetAspect
159
+ portalCamera.layers.mask = viewerCamera.layers.mask
160
+ portalCamera.updateMatrixWorld(true)
161
+
162
+ if (!destinationAperture.geometry.boundingBox) destinationAperture.geometry.computeBoundingBox()
163
+ const apertureBounds = destinationAperture.geometry.boundingBox
164
+ if (!apertureBounds) {
165
+ portalCamera.updateProjectionMatrix()
166
+ return
167
+ }
168
+ destinationAperture.updateWorldMatrix(true, false)
169
+ let left = Number.POSITIVE_INFINITY
170
+ let right = Number.NEGATIVE_INFINITY
171
+ let bottom = Number.POSITIVE_INFINITY
172
+ let top = Number.NEGATIVE_INFINITY
173
+ let minimumDistance = Number.POSITIVE_INFINITY
174
+ for (const [useMaximumX, useMaximumY] of portalViewCornerSelectors) {
175
+ portalViewCorner
176
+ .set(
177
+ useMaximumX ? apertureBounds.max.x : apertureBounds.min.x,
178
+ useMaximumY ? apertureBounds.max.y : apertureBounds.min.y,
179
+ 0,
180
+ )
181
+ .applyMatrix4(destinationAperture.matrixWorld)
182
+ .applyMatrix4(portalCamera.matrixWorldInverse)
183
+ const distance = -portalViewCorner.z
184
+ if (distance <= 0.001) {
185
+ portalCamera.updateProjectionMatrix()
186
+ return
187
+ }
188
+ minimumDistance = Math.min(minimumDistance, distance)
189
+ left = Math.min(left, portalViewCorner.x / distance)
190
+ right = Math.max(right, portalViewCorner.x / distance)
191
+ bottom = Math.min(bottom, portalViewCorner.y / distance)
192
+ top = Math.max(top, portalViewCorner.y / distance)
193
+ }
194
+ portalCamera.near = Math.max(0.01, Math.min(0.08, minimumDistance * 0.5))
195
+ left *= portalCamera.near
196
+ right *= portalCamera.near
197
+ bottom *= portalCamera.near
198
+ top *= portalCamera.near
199
+ portalCamera.projectionMatrix.makePerspective(
200
+ left,
201
+ right,
202
+ top,
203
+ bottom,
204
+ portalCamera.near,
205
+ portalCamera.far,
206
+ portalCamera.coordinateSystem,
207
+ )
208
+ portalCamera.projectionMatrixInverse.copy(portalCamera.projectionMatrix).invert()
209
+ }
210
+
211
+ export function renderFixedPortalView(
212
+ renderer: THREE.WebGLRenderer,
213
+ scene: THREE.Object3D,
214
+ camera: THREE.PerspectiveCamera,
215
+ target: THREE.WebGLRenderTarget,
216
+ ): void {
217
+ const panorama = scene.getObjectByName('lobby-panorama')
218
+ const material = panorama instanceof THREE.Mesh ? panorama.material : undefined
219
+ const shader = material instanceof THREE.ShaderMaterial ? material : undefined
220
+ const cameraMatrixUniform = shader?.uniforms.uCameraWorldMatrix
221
+ const projectionInverseUniform = shader?.uniforms.uProjectionMatrixInverse
222
+ const projectionModeUniform = shader?.uniforms.uUseProjectionMatrixInverse
223
+ const resolutionUniform = shader?.uniforms.uResolution
224
+ const aspectUniform = shader?.uniforms.uAspect
225
+ const fovUniform = shader?.uniforms.uTanHalfPanoramaFov
226
+ const panoramaFromUniform = shader?.uniforms.uPanoramaFrom
227
+ const panoramaToUniform = shader?.uniforms.uPanoramaTo
228
+ const panoramaTransitionUniform = shader?.uniforms.uRiftProgress
229
+ const previousCameraMatrix =
230
+ cameraMatrixUniform?.value instanceof THREE.Matrix4 ? cameraMatrixUniform.value.clone() : undefined
231
+ const previousProjectionInverse =
232
+ projectionInverseUniform?.value instanceof THREE.Matrix4 ? projectionInverseUniform.value.clone() : undefined
233
+ const previousProjectionMode = projectionModeUniform?.value
234
+ const previousResolution =
235
+ resolutionUniform?.value instanceof THREE.Vector2 ? resolutionUniform.value.clone() : undefined
236
+ const previousAspect = aspectUniform?.value
237
+ const previousFov = fovUniform?.value
238
+ const previousPanoramaFrom = panoramaFromUniform?.value
239
+ const previousPanoramaTransition = panoramaTransitionUniform?.value
240
+ const vignette = scene.getObjectByName('lobby-vignette')
241
+ const previousVignetteVisibility = vignette?.visible
242
+ const shadowMap = renderer.shadowMap
243
+ const previousShadowAutoUpdate = shadowMap?.autoUpdate
244
+ const previousShadowNeedsUpdate = shadowMap?.needsUpdate
245
+ const hiddenPortalEffects: Array<{ object: THREE.Object3D; visible: boolean }> = []
246
+
247
+ scene.traverse(object => {
248
+ if (object.userData[PORTAL_AUXILIARY_EFFECT] !== true) return
249
+ hiddenPortalEffects.push({ object, visible: object.visible })
250
+ object.visible = false
251
+ })
252
+
253
+ if (cameraMatrixUniform?.value instanceof THREE.Matrix4) cameraMatrixUniform.value.copy(camera.matrixWorld)
254
+ if (projectionInverseUniform?.value instanceof THREE.Matrix4) {
255
+ projectionInverseUniform.value.copy(camera.projectionMatrixInverse)
256
+ }
257
+ if (projectionModeUniform) projectionModeUniform.value = 1
258
+ if (resolutionUniform?.value instanceof THREE.Vector2) resolutionUniform.value.set(target.width, target.height)
259
+ if (aspectUniform) aspectUniform.value = camera.aspect
260
+ if (fovUniform) fovUniform.value = Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5))
261
+ // A Lab/race theme transition deliberately keeps the outgoing panorama in
262
+ // uPanoramaFrom for its visible crossfade. Portal views are cached, though:
263
+ // capturing that intermediate frame would freeze the old theme (for example
264
+ // Jungle inside a Wild West course) after the transition has completed.
265
+ // Capture the already-loaded destination panorama directly, then restore the
266
+ // transition uniforms for the main view.
267
+ if (panoramaFromUniform && panoramaToUniform?.value) panoramaFromUniform.value = panoramaToUniform.value
268
+ if (panoramaTransitionUniform) panoramaTransitionUniform.value = 1
269
+ if (vignette) vignette.visible = false
270
+ if (shadowMap) {
271
+ shadowMap.autoUpdate = false
272
+ // Portal cameras are auxiliary views. They must never consume the one
273
+ // event-driven shadow refresh requested for the main race camera.
274
+ shadowMap.needsUpdate = false
275
+ }
276
+
277
+ try {
278
+ renderer.setRenderTarget(target)
279
+ renderer.clear(true, true, true)
280
+ renderer.render(scene, camera)
281
+ } finally {
282
+ if (previousCameraMatrix && cameraMatrixUniform?.value instanceof THREE.Matrix4) {
283
+ cameraMatrixUniform.value.copy(previousCameraMatrix)
284
+ }
285
+ if (previousProjectionInverse && projectionInverseUniform?.value instanceof THREE.Matrix4) {
286
+ projectionInverseUniform.value.copy(previousProjectionInverse)
287
+ }
288
+ if (projectionModeUniform) projectionModeUniform.value = previousProjectionMode
289
+ if (previousResolution && resolutionUniform?.value instanceof THREE.Vector2) {
290
+ resolutionUniform.value.copy(previousResolution)
291
+ }
292
+ if (aspectUniform) aspectUniform.value = previousAspect
293
+ if (fovUniform) fovUniform.value = previousFov
294
+ if (panoramaFromUniform) panoramaFromUniform.value = previousPanoramaFrom
295
+ if (panoramaTransitionUniform) panoramaTransitionUniform.value = previousPanoramaTransition
296
+ if (vignette && previousVignetteVisibility !== undefined) vignette.visible = previousVignetteVisibility
297
+ if (shadowMap && previousShadowAutoUpdate !== undefined) shadowMap.autoUpdate = previousShadowAutoUpdate
298
+ if (shadowMap && previousShadowNeedsUpdate !== undefined) shadowMap.needsUpdate = previousShadowNeedsUpdate
299
+ for (const { object, visible } of hiddenPortalEffects) object.visible = visible
300
+ }
301
+ }
302
+
303
+ const WILD_WEST_THEME_ID = 'rocky-mountain-wild-west'
304
+ const APERTURE_BOTTOM = -1.66
305
+ const RIFT_CENTER_Y = -0.14
306
+ const RIFT_FIT_SCALE = 0.9
307
+
308
+ export function portalVisualStyleFromId(id: string | undefined): PortalVisualStyle {
309
+ void id
310
+ return 'live-window'
311
+ }
312
+
313
+ export function createPortalEndpointVisual(
314
+ name: string,
315
+ style: PortalVisualStyle,
316
+ color: string,
317
+ materials: PortalVisualMaterials,
318
+ ): PortalEndpointVisual {
319
+ const group = new THREE.Group()
320
+ group.name = name
321
+ // Portal descendants must never be merged into a static overview batch.
322
+ // The auxiliary camera hides this complete group while rendering the
323
+ // destination view; moving a descendant elsewhere can expose an
324
+ // uninitialised/recursive portal surface as a black frame.
325
+ group.userData.isPortalViewSurface = true
326
+
327
+ void style
328
+ const riftPoints = createRiftOutlinePoints()
329
+ const thresholdFloor = createPortalThresholdFloor(`${name}-threshold-floor`, materials.timber)
330
+ const screen = createLiveWindow(`${name}-live-window`, color, riftPoints)
331
+ const riftFill = createPortalRiftFill(`${name}-rift-fill`, color, screen.geometry)
332
+ screen.add(riftFill)
333
+ let cachedLiveTexture: THREE.Texture | null = null
334
+ let fallbackColor = color
335
+ const riftEdge = createRiftEdge(`${name}-rift-edge`, color, riftPoints)
336
+ const rearGlow = createRearRiftGlow(`${name}-rift-rear-glow`, color)
337
+ const rearRiftEdge = createRiftEdge(`${name}-rift-rear-edge`, color, riftPoints, THREE.BackSide)
338
+ const particles = createRiftParticles(`${name}-rift-particles`, color, riftPoints)
339
+ for (const layer of [screen, riftEdge, rearGlow, rearRiftEdge, particles]) {
340
+ layer.scale.setScalar(RIFT_FIT_SCALE)
341
+ // Portal cameras should show the physical doorway, but never another
342
+ // portal's live surface or layered rift. Besides avoiding visual feedback
343
+ // when portals face one another, this prevents clustered auxiliary views
344
+ // from redrawing every portal's transparent effects and particles.
345
+ layer.userData[PORTAL_AUXILIARY_EFFECT] = true
346
+ }
347
+ group.add(thresholdFloor, rearGlow, screen, riftEdge, rearRiftEdge, particles)
348
+ if (materials.activeThemeId === WILD_WEST_THEME_ID) {
349
+ addWildWestMinePortalFrame(group, name, color, materials)
350
+ }
351
+ if (materials.activeThemeId === JUNGLE_PORTAL_FRAME_THEME_ID && materials.jungle) {
352
+ addJunglePortalFrame({ root: group, name, color, materials: materials.jungle })
353
+ }
354
+ if (materials.activeThemeId === EGYPT_PORTAL_FRAME_THEME_ID && materials.egypt) {
355
+ addEgyptPortalFrame(group, name, color, materials.egypt)
356
+ }
357
+ if (materials.activeThemeId === JAPANESE_SAKURA_PORTAL_FRAME_THEME_ID && materials.sakura) {
358
+ addJapaneseSakuraPortalFrame(group, name, color, materials.sakura)
359
+ }
360
+ const spaceStationFrame =
361
+ materials.activeThemeId === SPACE_STATION_PORTAL_FRAME_THEME_ID && materials.spaceStation
362
+ ? addSpaceStationPortalFrame(group, name, color, materials.spaceStation)
363
+ : undefined
364
+
365
+ return {
366
+ group,
367
+ screen,
368
+ setTransparentAperture() {
369
+ if (!(screen.material instanceof THREE.MeshBasicMaterial)) return
370
+ cachedLiveTexture = null
371
+ screen.material.map = null
372
+ screen.material.color.set('#ffffff')
373
+ screen.material.opacity = 0
374
+ riftFill.visible = false
375
+ screen.visible = true
376
+ screen.material.needsUpdate = true
377
+ screen.userData.liveTextureReady = false
378
+ rearGlow.visible = false
379
+ },
380
+ setFallback(nextFallbackColor) {
381
+ if (!(screen.material instanceof THREE.MeshBasicMaterial)) return
382
+ cachedLiveTexture = null
383
+ fallbackColor = nextFallbackColor
384
+ screen.material.map = null
385
+ screen.material.color.set('#ffffff')
386
+ screen.material.opacity = 0
387
+ riftFill.material.color.set(nextFallbackColor)
388
+ riftFill.material.opacity = 1
389
+ riftFill.visible = false
390
+ screen.visible = true
391
+ screen.material.needsUpdate = true
392
+ screen.userData.liveTextureReady = false
393
+ // Static rifts use the existing dark animated back on both faces.
394
+ if (rearGlow.material.side !== THREE.DoubleSide) {
395
+ rearGlow.material.side = THREE.DoubleSide
396
+ rearGlow.material.needsUpdate = true
397
+ }
398
+ rearGlow.material.uniforms.uColor!.value.set(nextFallbackColor)
399
+ rearGlow.visible = true
400
+ },
401
+ markLiveTextureStale() {
402
+ // Keep the last complete capture on screen while its render target is
403
+ // queued for replacement. Rendering the new frame is synchronous, so
404
+ // the same texture updates between main-scene frames without exposing a
405
+ // blank/fallback aperture.
406
+ screen.userData.liveTextureReady = false
407
+ },
408
+ setLiveTexture(texture) {
409
+ if (!(screen.material instanceof THREE.MeshBasicMaterial)) return
410
+ // This texture is owned by the portal render target, not by the piece
411
+ // visual's material lifecycle. It must survive a visual-only rebuild so
412
+ // the replacement doorway can display the last complete frame.
413
+ texture.userData[PORTAL_RENDER_TARGET_TEXTURE] = true
414
+ cachedLiveTexture = texture
415
+ if (rearGlow.material.side !== THREE.BackSide) {
416
+ rearGlow.material.side = THREE.BackSide
417
+ rearGlow.material.needsUpdate = true
418
+ }
419
+ const textureChanged = screen.material.map !== texture
420
+ if (textureChanged) screen.material.map = texture
421
+ screen.material.color.set('#ffffff')
422
+ screen.material.opacity = 1
423
+ riftFill.material.opacity = 1
424
+ riftFill.visible = true
425
+ screen.visible = true
426
+ // The render target's contents update in place. Recompiling the portal
427
+ // material on every refresh can expose a black program/texture frame on
428
+ // some mobile GPUs; only the first attachment or a replaced target
429
+ // changes the shader's USE_MAP variant.
430
+ if (textureChanged) screen.material.needsUpdate = true
431
+ screen.userData.liveTextureReady = true
432
+ rearGlow.visible = true
433
+ },
434
+ setLiveBlend(blend) {
435
+ if (!(screen.material instanceof THREE.MeshBasicMaterial) || !cachedLiveTexture) return
436
+ const clampedBlend = THREE.MathUtils.clamp(blend, 0, 1)
437
+ const textureChanged = screen.material.map !== cachedLiveTexture
438
+ screen.material.map = cachedLiveTexture
439
+ screen.material.color.set('#ffffff')
440
+ screen.material.opacity = clampedBlend
441
+ riftFill.material.color.set(fallbackColor)
442
+ // The cached camera alpha-composites over an opaque pair-colour base.
443
+ // Keeping the base opaque prevents real scene geometry behind the
444
+ // doorway from bleeding through during the crossfade.
445
+ riftFill.material.opacity = 1
446
+ riftFill.visible = true
447
+ if (textureChanged) screen.material.needsUpdate = true
448
+ rearGlow.visible = true
449
+ },
450
+ update(timeSeconds) {
451
+ updateRiftWarp(screen, timeSeconds)
452
+ updateRiftGlow(riftEdge, timeSeconds)
453
+ updateRiftGlow(rearGlow, timeSeconds)
454
+ updateRiftGlow(rearRiftEdge, timeSeconds)
455
+ updateRiftDust(particles, timeSeconds)
456
+ updateSpaceStationPortalFrame(spaceStationFrame, timeSeconds)
457
+ },
458
+ }
459
+ }
460
+
461
+ function createPortalThresholdFloor(name: string, timber: THREE.Material): THREE.Mesh {
462
+ const depth = 0.48
463
+ const geometry = createMetricBeamGeometry(PORTAL_DECK_WIDTH - 0.08, 0.1, depth)
464
+ geometry.userData.portalThreshold = {
465
+ width: PORTAL_DECK_WIDTH - 0.08,
466
+ depth,
467
+ front: depth * 0.5,
468
+ back: -depth * 0.5,
469
+ top: APERTURE_BOTTOM,
470
+ }
471
+ const floor = new THREE.Mesh(geometry, timber)
472
+ floor.name = name
473
+ floor.position.set(0, APERTURE_BOTTOM - 0.05, 0)
474
+ floor.castShadow = true
475
+ floor.receiveShadow = true
476
+ floor.userData.portalFloorRole = 'shared-frame-depth-threshold'
477
+ floor.userData.decorative = true
478
+ return floor
479
+ }
480
+
481
+ function createLiveWindow(name: string, color: string, points: readonly THREE.Vector2[]): THREE.Mesh {
482
+ void color
483
+ const material = new THREE.MeshBasicMaterial({
484
+ color: '#ffffff',
485
+ map: null,
486
+ side: THREE.FrontSide,
487
+ toneMapped: false,
488
+ transparent: true,
489
+ opacity: 0,
490
+ depthWrite: false,
491
+ })
492
+ material.name = `${name}-material`
493
+ const screen = new THREE.Mesh(createRiftGeometry(points), material)
494
+ screen.name = name
495
+ screen.renderOrder = 2
496
+ screen.userData.ownsMaterial = true
497
+ screen.userData.portalSurfaceStyle = 'live-window'
498
+ screen.userData.decorative = true
499
+ screen.frustumCulled = false
500
+ return screen
501
+ }
502
+
503
+ function createPortalRiftFill(
504
+ name: string,
505
+ color: string,
506
+ geometry: THREE.BufferGeometry,
507
+ ): THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicMaterial> {
508
+ const material = new THREE.MeshBasicMaterial({
509
+ color,
510
+ side: THREE.FrontSide,
511
+ toneMapped: false,
512
+ transparent: false,
513
+ opacity: 1,
514
+ depthWrite: true,
515
+ })
516
+ material.name = `${name}-material`
517
+ const fill = new THREE.Mesh(geometry, material)
518
+ fill.name = name
519
+ fill.position.z = -0.004
520
+ fill.renderOrder = 1
521
+ fill.userData.ownsMaterial = true
522
+ fill.userData.portalRiftFill = true
523
+ fill.frustumCulled = false
524
+ return fill
525
+ }
526
+
527
+ function createRiftOutlinePoints(): THREE.Vector2[] {
528
+ const pointCount = 128
529
+ return Array.from({ length: pointCount }, (_, index) => {
530
+ const angle = (index / pointCount) * Math.PI * 2
531
+ const broadVariation =
532
+ Math.sin(angle * 3 + 0.7) * 0.038 +
533
+ Math.sin(angle * 7 - 0.4) * 0.022 +
534
+ Math.sin(angle * 13 + 1.2) * 0.015 +
535
+ Math.sin(angle * 19 - 0.3) * 0.009 +
536
+ Math.sin(angle * 31) * 0.006
537
+ const radius = 1 + broadVariation
538
+ return new THREE.Vector2(
539
+ Math.cos(angle) * PORTAL_RIFT_HALF_WIDTH * radius,
540
+ RIFT_CENTER_Y + Math.sin(angle) * PORTAL_RIFT_HALF_HEIGHT * radius,
541
+ )
542
+ })
543
+ }
544
+
545
+ function createRiftGeometry(points: readonly THREE.Vector2[]): THREE.ShapeGeometry {
546
+ const shape = shapeFromPoints(points)
547
+ const geometry = new THREE.ShapeGeometry(shape, 12)
548
+ normalizeShapeUvs(geometry)
549
+ prepareRiftWarpGeometry(geometry)
550
+ geometry.computeBoundingBox()
551
+ const bounds = geometry.boundingBox
552
+ geometry.userData.portalAperture = {
553
+ halfWidth: bounds ? Math.max(Math.abs(bounds.min.x), Math.abs(bounds.max.x)) : 1.8,
554
+ bottom: bounds?.min.y ?? -1.7,
555
+ top: bounds?.max.y ?? 1.26,
556
+ shape: 'smooth-rift',
557
+ }
558
+ return geometry
559
+ }
560
+
561
+ function normalizeShapeUvs(geometry: THREE.BufferGeometry): void {
562
+ geometry.computeBoundingBox()
563
+ const bounds = geometry.boundingBox
564
+ const positions = geometry.getAttribute('position')
565
+ const uvs = geometry.getAttribute('uv')
566
+ if (!bounds || !uvs) return
567
+ const width = Math.max(0.001, bounds.max.x - bounds.min.x)
568
+ const height = Math.max(0.001, bounds.max.y - bounds.min.y)
569
+ for (let index = 0; index < positions.count; index += 1) {
570
+ uvs.setXY(index, (positions.getX(index) - bounds.min.x) / width, (positions.getY(index) - bounds.min.y) / height)
571
+ }
572
+ uvs.needsUpdate = true
573
+ }
574
+
575
+ const RIFT_EFFECT_VERTEX_SHADER = /* glsl */ `
576
+ varying vec2 vRiftPosition;
577
+
578
+ void main() {
579
+ vRiftPosition = position.xy;
580
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
581
+ }
582
+ `
583
+
584
+ const RIFT_EFFECT_FRAGMENT_HEADER = /* glsl */ `
585
+ uniform float uTime;
586
+ uniform vec3 uColor;
587
+ varying vec2 vRiftPosition;
588
+
589
+ float riftDistance(out float angle) {
590
+ vec2 q = vRiftPosition / vec2(${PORTAL_RIFT_HALF_WIDTH.toFixed(6)}, ${PORTAL_RIFT_HALF_HEIGHT.toFixed(6)});
591
+ angle = atan(q.y, q.x);
592
+ float variation = sin(angle * 3.0 + uTime * 0.38 + 0.7) * 0.038
593
+ + sin(angle * 7.0 - uTime * 0.55 - 0.4) * 0.022
594
+ + sin(angle * 13.0 + uTime * 0.82 + 1.2) * 0.015
595
+ + sin(angle * 19.0 - uTime * 0.96 - 0.3) * 0.009
596
+ + sin(angle * 31.0 + uTime * 1.28) * 0.006;
597
+ return length(q) - (1.0 + variation);
598
+ }
599
+ `
600
+
601
+ const RIFT_AURA_FRAGMENT_SHADER = /* glsl */ `
602
+ ${RIFT_EFFECT_FRAGMENT_HEADER}
603
+
604
+ void main() {
605
+ float angle;
606
+ float distanceToEdge = riftDistance(angle);
607
+ float travelling = 0.5 + 0.5 * sin(angle * 6.0 - uTime * 1.7 + sin(angle * 3.0 + uTime * 0.35) * 1.8);
608
+ float hotspots = pow(travelling, 5.0);
609
+ float broadAura = exp(-abs(distanceToEdge) * 5.2) * 0.34
610
+ + exp(-abs(distanceToEdge) * 13.0) * 0.4;
611
+ float turbulentOffset = sin(angle * 19.0 - uTime * 2.3) * 0.008
612
+ + sin(angle * 37.0 + uTime * 3.1) * 0.0035;
613
+ float energyBand = 1.0 - smoothstep(0.022, 0.115, abs(distanceToEdge + turbulentOffset));
614
+ float intensity = broadAura + energyBand * (0.52 + hotspots * 0.68);
615
+ if (intensity < 0.018) discard;
616
+ gl_FragColor = vec4(uColor * intensity, 0.96);
617
+ }
618
+ `
619
+
620
+ const RIFT_CORE_FRAGMENT_SHADER = /* glsl */ `
621
+ ${RIFT_EFFECT_FRAGMENT_HEADER}
622
+
623
+ void main() {
624
+ float angle;
625
+ float distanceToEdge = riftDistance(angle);
626
+ float travelling = 0.5 + 0.5 * sin(angle * 6.0 - uTime * 1.7 + sin(angle * 3.0 + uTime * 0.35) * 1.8);
627
+ float hotspots = pow(travelling, 5.0);
628
+ float flicker = 0.82 + 0.18 * sin(uTime * 7.0 + angle * 17.0);
629
+ float coreOffset = sin(angle * 23.0 + uTime * 2.6) * 0.006
630
+ + sin(angle * 53.0 - uTime * 4.1) * 0.0024;
631
+ float core = 1.0 - smoothstep(0.004, 0.016, abs(distanceToEdge + coreOffset));
632
+ float branchGateA = pow(max(0.0, sin(angle * 2.5 - uTime * 0.72 + 0.8)), 18.0);
633
+ float branchGateB = pow(max(0.0, sin(angle * 3.5 + uTime * 0.54 - 1.1)), 24.0);
634
+ float branchDistanceA = abs(distanceToEdge - 0.045 - sin(angle * 31.0 + uTime * 3.8) * 0.026);
635
+ float branchDistanceB = abs(distanceToEdge + 0.04 - sin(angle * 41.0 - uTime * 3.2) * 0.021);
636
+ float branches = (1.0 - smoothstep(0.004, 0.014, branchDistanceA)) * branchGateA
637
+ + (1.0 - smoothstep(0.004, 0.012, branchDistanceB)) * branchGateB;
638
+ float intensity = core * (1.42 + hotspots * 0.9) * flicker + branches * 1.35;
639
+ if (intensity < 0.012) discard;
640
+ vec3 hotColor = mix(uColor, vec3(1.0), clamp(core * 0.76 + branches * 0.7, 0.0, 0.92));
641
+ gl_FragColor = vec4(hotColor * intensity, 0.98);
642
+ }
643
+ `
644
+
645
+ const RIFT_REAR_GLOW_FRAGMENT_SHADER = /* glsl */ `
646
+ ${RIFT_EFFECT_FRAGMENT_HEADER}
647
+
648
+ void main() {
649
+ float angle;
650
+ float distanceToEdge = riftDistance(angle);
651
+ if (distanceToEdge > 0.018) discard;
652
+ float edgeLift = 1.0 - smoothstep(-0.12, 0.0, distanceToEdge);
653
+ float pulse = 0.82 + 0.18 * sin(uTime * 1.25 + angle * 4.0);
654
+ float intensity = 0.72 + pulse * 0.12 + edgeLift * 0.16;
655
+ gl_FragColor = vec4(uColor * intensity, 1.0);
656
+ }
657
+ `
658
+
659
+ function createRiftShaderMaterial(
660
+ name: string,
661
+ color: string,
662
+ fragmentShader: string,
663
+ side: THREE.Side = THREE.FrontSide,
664
+ ): THREE.ShaderMaterial {
665
+ const material = new THREE.ShaderMaterial({
666
+ uniforms: {
667
+ uTime: { value: 0 },
668
+ uColor: { value: new THREE.Color(color) },
669
+ },
670
+ vertexShader: RIFT_EFFECT_VERTEX_SHADER,
671
+ fragmentShader,
672
+ transparent: true,
673
+ depthWrite: false,
674
+ blending: THREE.AdditiveBlending,
675
+ side,
676
+ toneMapped: false,
677
+ })
678
+ material.name = name
679
+ return material
680
+ }
681
+
682
+ function createRiftEffectGeometry(): THREE.PlaneGeometry {
683
+ return new THREE.PlaneGeometry(PORTAL_RIFT_HALF_WIDTH * 3.9, PORTAL_RIFT_HALF_HEIGHT * 3.2)
684
+ }
685
+
686
+ function createRiftEdge(
687
+ name: string,
688
+ color: string,
689
+ _points: readonly THREE.Vector2[],
690
+ side: THREE.Side = THREE.FrontSide,
691
+ ): THREE.Mesh {
692
+ const edge = new THREE.Mesh(
693
+ createRiftEffectGeometry(),
694
+ createRiftShaderMaterial(`${name}-aura-material`, color, RIFT_AURA_FRAGMENT_SHADER, side),
695
+ )
696
+ edge.name = name
697
+ edge.position.set(0, RIFT_CENTER_Y, side === THREE.BackSide ? -0.035 : 0.035)
698
+ edge.renderOrder = 3
699
+ edge.frustumCulled = false
700
+ edge.userData.ownsMaterial = true
701
+ edge.userData.portalEffectRole = side === THREE.BackSide ? 'pair-color-rift-rear-edge' : 'pair-color-rift-edge'
702
+ edge.userData.decorative = true
703
+
704
+ const core = new THREE.Mesh(
705
+ createRiftEffectGeometry(),
706
+ createRiftShaderMaterial(`${name}-core-material`, color, RIFT_CORE_FRAGMENT_SHADER, side),
707
+ )
708
+ core.name = `${name}-core`
709
+ core.position.z = side === THREE.BackSide ? -0.004 : 0.004
710
+ core.renderOrder = 4
711
+ core.frustumCulled = false
712
+ core.userData.ownsMaterial = true
713
+ core.userData.portalEffectRole = side === THREE.BackSide ? 'pair-color-rift-rear-core' : 'pair-color-rift-core'
714
+ core.userData.decorative = true
715
+ edge.add(core)
716
+ return edge
717
+ }
718
+
719
+ function createRearRiftGlow(name: string, color: string): THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial> {
720
+ const material = new THREE.ShaderMaterial({
721
+ uniforms: {
722
+ uTime: { value: 0 },
723
+ uColor: { value: new THREE.Color(color) },
724
+ },
725
+ vertexShader: RIFT_EFFECT_VERTEX_SHADER,
726
+ fragmentShader: RIFT_REAR_GLOW_FRAGMENT_SHADER,
727
+ transparent: false,
728
+ depthWrite: true,
729
+ blending: THREE.NormalBlending,
730
+ side: THREE.BackSide,
731
+ toneMapped: false,
732
+ })
733
+ material.name = `${name}-material`
734
+ const glow = new THREE.Mesh(createRiftEffectGeometry(), material)
735
+ glow.name = name
736
+ glow.position.set(0, RIFT_CENTER_Y, -0.012)
737
+ glow.renderOrder = 2
738
+ glow.frustumCulled = false
739
+ glow.userData.ownsMaterial = true
740
+ glow.userData.portalEffectRole = 'pair-color-rift-rear-glow'
741
+ glow.userData.decorative = true
742
+ return glow
743
+ }
744
+
745
+ interface RiftWarpGeometryData {
746
+ basePositions: Float32Array
747
+ }
748
+
749
+ function prepareRiftWarpGeometry(geometry: THREE.BufferGeometry): void {
750
+ const positions = geometry.getAttribute('position')
751
+ if (!(positions instanceof THREE.BufferAttribute)) return
752
+ geometry.userData.riftWarp = {
753
+ basePositions: Float32Array.from(positions.array as ArrayLike<number>),
754
+ } satisfies RiftWarpGeometryData
755
+ positions.setUsage(THREE.DynamicDrawUsage)
756
+ }
757
+
758
+ function updateRiftWarp(root: THREE.Object3D, timeSeconds: number): void {
759
+ root.traverse(object => {
760
+ if (!(object instanceof THREE.Mesh)) return
761
+ if (object.userData.portalRiftFill === true) return
762
+ const warp = object.geometry.userData.riftWarp as RiftWarpGeometryData | undefined
763
+ const positions = object.geometry.getAttribute('position')
764
+ if (!warp || !(positions instanceof THREE.BufferAttribute)) return
765
+ for (let index = 0; index < positions.count; index += 1) {
766
+ const baseX = warp.basePositions[index * 3]!
767
+ const baseY = warp.basePositions[index * 3 + 1]!
768
+ const deltaX = baseX
769
+ const deltaY = baseY - RIFT_CENTER_Y
770
+ const angle = Math.atan2(deltaY / PORTAL_RIFT_HALF_HEIGHT, deltaX / PORTAL_RIFT_HALF_WIDTH)
771
+ const radius = Math.min(1, Math.hypot(deltaX / PORTAL_RIFT_HALF_WIDTH, deltaY / PORTAL_RIFT_HALF_HEIGHT))
772
+ const breathing =
773
+ Math.sin(angle * 3 + timeSeconds * 0.82) * 0.014 + Math.sin(angle * 5 - timeSeconds * 1.08 + 0.6) * 0.008
774
+ const surfaceRipple = Math.sin(baseY * 5.4 + timeSeconds * 1.35) * 0.009 * radius
775
+ const scale = 1 + breathing * (0.35 + radius * 0.65)
776
+ positions.setXYZ(
777
+ index,
778
+ deltaX * scale + surfaceRipple,
779
+ RIFT_CENTER_Y + deltaY * scale + Math.sin(baseX * 4.2 - timeSeconds * 1.1) * 0.005 * radius,
780
+ warp.basePositions[index * 3 + 2]!,
781
+ )
782
+ }
783
+ positions.needsUpdate = true
784
+ })
785
+ }
786
+
787
+ function updateRiftGlow(edge: THREE.Mesh, timeSeconds: number): void {
788
+ if (edge.material instanceof THREE.ShaderMaterial) edge.material.uniforms.uTime!.value = timeSeconds
789
+ const core = edge.children[0]
790
+ if (core instanceof THREE.Mesh && core.material instanceof THREE.ShaderMaterial) {
791
+ core.material.uniforms.uTime!.value = timeSeconds
792
+ }
793
+ }
794
+
795
+ function shapeFromPoints(points: readonly THREE.Vector2[]): THREE.Shape {
796
+ const shape = new THREE.Shape()
797
+ shape.moveTo(points[0]!.x, points[0]!.y)
798
+ for (const point of points.slice(1)) shape.lineTo(point.x, point.y)
799
+ shape.closePath()
800
+ return shape
801
+ }
802
+
803
+ interface RiftDustMotion {
804
+ basePositions: Float32Array
805
+ modes: Uint8Array
806
+ phases: Float32Array
807
+ speeds: Float32Array
808
+ tangents: Float32Array
809
+ }
810
+
811
+ function createRiftParticles(name: string, color: string, outline: readonly THREE.Vector2[]): THREE.Points {
812
+ const edgeCount = 138
813
+ const plumeCount = 42
814
+ const count = edgeCount + plumeCount
815
+ const positions = new Float32Array(count * 3)
816
+ const modes = new Uint8Array(count)
817
+ const phases = new Float32Array(count)
818
+ const speeds = new Float32Array(count)
819
+ const tangents = new Float32Array(count * 2)
820
+ const center = new THREE.Vector2(0, RIFT_CENTER_Y)
821
+ for (let index = 0; index < count; index += 1) {
822
+ phases[index] = deterministic(`${name}:${index}:phase`) * Math.PI * 2
823
+ speeds[index] = 0.45 + deterministic(`${name}:${index}:speed`) * 0.65
824
+
825
+ if (index >= edgeCount) {
826
+ const progress = deterministic(`${name}:${index}:progress`)
827
+ const horizontal = (deterministic(`${name}:${index}:horizontal`) - 0.5) * PORTAL_DUST_PLUME_WIDTH
828
+ const startY = -1.22 + deterministic(`${name}:${index}:height`) * 0.82
829
+ modes[index] = 1
830
+ positions[index * 3] = horizontal
831
+ positions[index * 3 + 1] = THREE.MathUtils.lerp(startY, -1.58, THREE.MathUtils.smoothstep(progress, 0, 1))
832
+ positions[index * 3 + 2] = 0.08 + progress * 2.25
833
+ tangents[index * 2] = horizontal
834
+ tangents[index * 2 + 1] = startY
835
+ continue
836
+ }
837
+
838
+ const outlineIndex = Math.floor(deterministic(`${name}:${index}:position`) * outline.length)
839
+ const point = outline[outlineIndex]!
840
+ const outward = point.clone().sub(center).normalize()
841
+ const tangent = new THREE.Vector2(-outward.y, outward.x)
842
+ const distanceNoise = deterministic(`${name}:${index}:distance`)
843
+ const distance = 0.018 + distanceNoise * distanceNoise * 0.2
844
+ const tangentScatter = (deterministic(`${name}:${index}:scatter`) - 0.5) * 0.16
845
+ positions[index * 3] = point.x + outward.x * distance + tangent.x * tangentScatter
846
+ positions[index * 3 + 1] = point.y + outward.y * distance + tangent.y * tangentScatter
847
+ positions[index * 3 + 2] = 0.06 + deterministic(`${name}:${index}:depth`) * 0.035
848
+ tangents[index * 2] = tangent.x
849
+ tangents[index * 2 + 1] = tangent.y
850
+ }
851
+ const geometry = new THREE.BufferGeometry()
852
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions.slice(), 3))
853
+ const material = createSoftAdditiveParticleMaterial({
854
+ color,
855
+ size: 2.2,
856
+ sizeAttenuation: false,
857
+ opacity: 0.44,
858
+ fog: false,
859
+ })
860
+ material.name = `${name}-material`
861
+ const particles = new THREE.Points(geometry, material)
862
+ particles.name = name
863
+ particles.renderOrder = 5
864
+ particles.userData.ownsMaterial = true
865
+ particles.userData.portalParticleStyle = 'portal-colored-dust'
866
+ particles.userData.portalParticleCount = count
867
+ particles.userData.portalParticlePlumeCount = plumeCount
868
+ particles.userData.riftDustMotion = {
869
+ basePositions: positions,
870
+ modes,
871
+ phases,
872
+ speeds,
873
+ tangents,
874
+ } satisfies RiftDustMotion
875
+ particles.userData.decorative = true
876
+ particles.frustumCulled = false
877
+ return particles
878
+ }
879
+
880
+ function updateRiftDust(particles: THREE.Points, timeSeconds: number): void {
881
+ const motion = particles.userData.riftDustMotion as RiftDustMotion | undefined
882
+ const positions = particles.geometry.getAttribute('position')
883
+ if (!motion || !(positions instanceof THREE.BufferAttribute)) return
884
+ for (let index = 0; index < motion.phases.length; index += 1) {
885
+ const phase = motion.phases[index]!
886
+ const speed = motion.speeds[index]!
887
+ if (motion.modes[index] === 1) {
888
+ const progress = (phase / (Math.PI * 2) + timeSeconds * speed * 0.11) % 1
889
+ const startX = motion.tangents[index * 2]!
890
+ const startY = motion.tangents[index * 2 + 1]!
891
+ const floorFall = THREE.MathUtils.smoothstep(progress, 0.08, 0.92)
892
+ positions.setXYZ(
893
+ index,
894
+ startX + Math.sin(timeSeconds * 0.72 + phase) * (0.025 + progress * 0.1),
895
+ THREE.MathUtils.lerp(startY, -1.58, floorFall) + Math.sin(timeSeconds * speed + phase) * 0.025,
896
+ 0.08 + progress * 2.25,
897
+ )
898
+ continue
899
+ }
900
+ const drift = Math.sin(timeSeconds * speed + phase) * 0.035
901
+ const lift = ((timeSeconds * speed * 0.045 + phase / (Math.PI * 2)) % 0.09) - 0.045
902
+ positions.setXYZ(
903
+ index,
904
+ motion.basePositions[index * 3]! + motion.tangents[index * 2]! * drift,
905
+ motion.basePositions[index * 3 + 1]! + motion.tangents[index * 2 + 1]! * drift + lift,
906
+ motion.basePositions[index * 3 + 2]!,
907
+ )
908
+ }
909
+ positions.needsUpdate = true
910
+ if (particles.material instanceof THREE.PointsMaterial) {
911
+ particles.material.opacity = 0.4 + Math.sin(timeSeconds * 0.85) * 0.04
912
+ }
913
+ }
914
+
915
+ function addWildWestMinePortalFrame(
916
+ root: THREE.Group,
917
+ name: string,
918
+ color: string,
919
+ materials: PortalVisualMaterials,
920
+ ): void {
921
+ const frame = new THREE.Group()
922
+ frame.name = `${name}-wild-west-mine-frame`
923
+ frame.userData.themeOnly = WILD_WEST_THEME_ID
924
+ frame.userData.portalFrameStyle = 'wild-west-mine-shaft'
925
+
926
+ const postCenterX = 1.78
927
+ const beams = [
928
+ [new THREE.Vector3(-postCenterX, -1.66, 0), new THREE.Vector3(-postCenterX, 1.62, 0), 0.42],
929
+ [new THREE.Vector3(postCenterX, -1.66, 0), new THREE.Vector3(postCenterX, 1.62, 0), 0.42],
930
+ [new THREE.Vector3(-2.52, 1.5, 0), new THREE.Vector3(2.52, 1.5, 0), 0.52],
931
+ [new THREE.Vector3(-postCenterX, 0.56, 0), new THREE.Vector3(-0.92, 1.48, 0), 0.23],
932
+ [new THREE.Vector3(postCenterX, 0.56, 0), new THREE.Vector3(0.92, 1.48, 0), 0.23],
933
+ ] as const
934
+ beams.forEach(([start, end, thickness], index) => {
935
+ const beam = createBeam(start, end, thickness, materials.timber)
936
+ beam.name = `${name}-wild-west-timber-${index + 1}`
937
+ frame.add(beam)
938
+ })
939
+
940
+ const crownBandGeometry = new VisualBoxGeometry(0.26, 0.6, 0.47, 2, 0.035)
941
+ for (const [bandIndex, x] of [-1.48, 0, 1.48].entries()) {
942
+ const band = new THREE.Mesh(crownBandGeometry, materials.metal)
943
+ band.name = `${name}-wild-west-crown-band-${bandIndex + 1}`
944
+ band.position.set(x, 1.5, 0)
945
+ frame.add(band)
946
+ for (const boltY of [-0.14, 0.14]) {
947
+ const bolt = new THREE.Mesh(new VisualCylinderGeometry(0.047, 0.047, 0.08, 10), materials.metal)
948
+ bolt.name = `${name}-wild-west-crown-band-${bandIndex + 1}-bolt`
949
+ bolt.position.set(x, 1.5 + boltY, 0.265)
950
+ bolt.rotation.x = Math.PI / 2
951
+ frame.add(bolt)
952
+ }
953
+ }
954
+
955
+ const strapGeometry = new VisualBoxGeometry(0.48, 0.16, 0.39, 2, 0.025)
956
+ for (const [sideIndex, x] of [-postCenterX, postCenterX].entries()) {
957
+ for (const [strapIndex, y] of [-0.72, 0.62].entries()) {
958
+ const strap = new THREE.Mesh(strapGeometry, materials.metal)
959
+ strap.name = `${name}-wild-west-strap-${sideIndex + 1}-${strapIndex + 1}`
960
+ strap.position.set(x, y, 0)
961
+ frame.add(strap)
962
+ for (const boltX of [-0.13, 0.13]) {
963
+ const bolt = new THREE.Mesh(new VisualCylinderGeometry(0.045, 0.045, 0.075, 10), materials.metal)
964
+ bolt.position.set(x + boltX, y, 0.225)
965
+ bolt.rotation.x = Math.PI / 2
966
+ frame.add(bolt)
967
+ }
968
+ }
969
+ }
970
+ const lantern = createWildWestPortalLantern(name, color, materials.metal)
971
+ lantern.position.set(0, 0.94, 0.3)
972
+ frame.add(lantern)
973
+
974
+ frame.userData.portalApertureClearHalfWidth = PORTAL_APERTURE_HALF_WIDTH
975
+ frame.traverse(object => {
976
+ object.userData.themeOnly = WILD_WEST_THEME_ID
977
+ object.userData.portalFrameStyle = 'wild-west-mine-shaft'
978
+ object.userData.decorative = true
979
+ object.castShadow = object.type !== 'PointLight'
980
+ object.receiveShadow = object.type !== 'PointLight'
981
+ })
982
+ root.add(frame)
983
+ }
984
+
985
+ export function createWildWestPortalLantern(name: string, color: string, metal: THREE.Material): THREE.Group {
986
+ const lantern = new THREE.Group()
987
+ lantern.name = `${name}-wild-west-pair-lantern`
988
+ lantern.userData.portalPairColor = color
989
+
990
+ const chainGeometry = new VisualTorusGeometry(0.038, 0.009, 6, 12)
991
+ for (const [index, y] of [0.43, 0.51, 0.59].entries()) {
992
+ const link = new THREE.Mesh(chainGeometry, metal)
993
+ link.name = `${name}-wild-west-lantern-chain-${index + 1}`
994
+ link.position.y = y
995
+ link.rotation.y = index % 2 === 0 ? 0 : Math.PI / 2
996
+ lantern.add(link)
997
+ }
998
+
999
+ const handle = new THREE.Mesh(new VisualTorusGeometry(0.16, 0.012, 7, 20, Math.PI), metal)
1000
+ handle.name = `${name}-wild-west-lantern-handle`
1001
+ handle.position.y = 0.2
1002
+ lantern.add(handle)
1003
+
1004
+ const roof = new THREE.Mesh(new VisualCylinderGeometry(0.09, 0.205, 0.11, 16), metal)
1005
+ roof.name = `${name}-wild-west-lantern-roof`
1006
+ roof.position.y = 0.18
1007
+ lantern.add(roof)
1008
+ const vent = new THREE.Mesh(new VisualCylinderGeometry(0.065, 0.085, 0.09, 12), metal)
1009
+ vent.position.y = 0.275
1010
+ lantern.add(vent)
1011
+ const base = new THREE.Mesh(new VisualCylinderGeometry(0.18, 0.15, 0.11, 16), metal)
1012
+ base.name = `${name}-wild-west-lantern-base`
1013
+ base.position.y = -0.235
1014
+ lantern.add(base)
1015
+
1016
+ const glowMaterial = new THREE.MeshBasicMaterial({
1017
+ color,
1018
+ transparent: true,
1019
+ opacity: 0.68,
1020
+ depthWrite: false,
1021
+ toneMapped: false,
1022
+ })
1023
+ const glass = new THREE.Mesh(new VisualCylinderGeometry(0.13, 0.13, 0.3, 16, 1, true), glowMaterial)
1024
+ glass.name = `${name}-wild-west-pair-lantern-glass`
1025
+ glass.position.y = -0.045
1026
+ glass.userData.ownsMaterial = true
1027
+ glass.userData.portalPairColor = color
1028
+ lantern.add(glass)
1029
+
1030
+ const cageBarGeometry = new VisualCylinderGeometry(0.009, 0.009, 0.32, 6)
1031
+ for (const [index, angle] of [Math.PI * 0.25, Math.PI * 0.75, Math.PI * 1.25, Math.PI * 1.75].entries()) {
1032
+ const bar = new THREE.Mesh(cageBarGeometry, metal)
1033
+ bar.name = `${name}-wild-west-lantern-cage-${index + 1}`
1034
+ bar.position.set(Math.cos(angle) * 0.14, -0.045, Math.sin(angle) * 0.14)
1035
+ lantern.add(bar)
1036
+ }
1037
+ const cageRingGeometry = new VisualTorusGeometry(0.14, 0.012, 6, 18)
1038
+ for (const y of [-0.19, 0.1]) {
1039
+ const ring = new THREE.Mesh(cageRingGeometry, metal)
1040
+ ring.position.y = y
1041
+ ring.rotation.x = Math.PI / 2
1042
+ lantern.add(ring)
1043
+ }
1044
+
1045
+ const flameMaterial = new THREE.MeshBasicMaterial({
1046
+ color: new THREE.Color(color).lerp(new THREE.Color('#fff5d8'), 0.5),
1047
+ transparent: true,
1048
+ opacity: 0.92,
1049
+ depthWrite: false,
1050
+ toneMapped: false,
1051
+ })
1052
+ const flame = new THREE.Mesh(new VisualSphereGeometry(0.075, 10, 8), flameMaterial)
1053
+ flame.name = `${name}-wild-west-lantern-flame`
1054
+ flame.position.y = -0.07
1055
+ flame.scale.set(0.72, 1.35, 0.72)
1056
+ flame.userData.ownsMaterial = true
1057
+ flame.userData.portalPairColor = color
1058
+ lantern.add(flame)
1059
+
1060
+ const light = new THREE.PointLight(color, 0.72, 2.8, 2)
1061
+ light.name = `${name}-wild-west-pair-light`
1062
+ light.position.set(0, -0.05, 0.38)
1063
+ light.userData.portalPairColor = color
1064
+ light.userData.portalLightRole = 'pair-color-lantern'
1065
+ lantern.add(light)
1066
+ return lantern
1067
+ }
1068
+
1069
+ function createBeam(start: THREE.Vector3, end: THREE.Vector3, thickness: number, material: THREE.Material): THREE.Mesh {
1070
+ const direction = end.clone().sub(start)
1071
+ const length = direction.length()
1072
+ const beam = new THREE.Mesh(createMetricBeamGeometry(thickness, length, thickness * 0.82), material)
1073
+ beam.position.copy(start).lerp(end, 0.5)
1074
+ beam.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize())
1075
+ return beam
1076
+ }
1077
+
1078
+ function createMetricBeamGeometry(width: number, height: number, depth: number): THREE.BufferGeometry {
1079
+ const geometry = new VisualBoxGeometry(width, height, depth, 3, Math.min(width, depth) * 0.075)
1080
+ const positions = geometry.getAttribute('position')
1081
+ const normals = geometry.getAttribute('normal')
1082
+ const uvs = geometry.getAttribute('uv')
1083
+ const metricFlags = new Float32Array(positions.count * 4)
1084
+ for (let index = 0; index < positions.count; index += 1) {
1085
+ const x = positions.getX(index)
1086
+ const y = positions.getY(index)
1087
+ const z = positions.getZ(index)
1088
+ const nx = Math.abs(normals.getX(index))
1089
+ const ny = Math.abs(normals.getY(index))
1090
+ const nz = Math.abs(normals.getZ(index))
1091
+ if (ny > nx && ny > nz) uvs.setXY(index, x, z)
1092
+ else if (nx > nz) uvs.setXY(index, y, z)
1093
+ else uvs.setXY(index, y, x)
1094
+ metricFlags[index * 4 + 2] = 1
1095
+ }
1096
+ uvs.needsUpdate = true
1097
+ geometry.setAttribute('themeNetUv', new THREE.BufferAttribute(metricFlags, 4))
1098
+ return geometry
1099
+ }
1100
+
1101
+ function deterministic(seed: string): number {
1102
+ let hash = 2_166_136_261
1103
+ for (const character of seed) {
1104
+ hash ^= character.charCodeAt(0)
1105
+ hash = Math.imul(hash, 16_777_619)
1106
+ }
1107
+ return (hash >>> 0) / 4_294_967_296
1108
+ }