@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.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- 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
|
+
}
|